跳至主要内容

Rosetta.ai PHP 面試實作題解析

· 閱讀時間約 10 分鐘
Vincent Chi

七月中旬,我離開了 Rosetta.ai

作為最後幾份工作,我與同事們一起設計了一系列的 PHP 軟體工程師(後端)的題目。其中,實作題的設計是由我所主導,而我個人認為它是我設計過最優秀的題目。

因為該題目已獲公司主管同意已經公佈在 PTT 的 Soft_job 版上,所以這邊寫下當時我設計題目的理念與解析。

備註

註:雖然 PTT 的討論串到最後演變成薪資之爭模糊焦點有些可惜,不過這並不妨礙這份題目本身的設計。

題目

下列 PHP 程式碼存在一些問題,請嘗試指出這些問題並且重構它。

註:下述程式隱藏了一些不重要的細節(例如資料庫連線、失敗處理等),回答時也可以隱藏實作細節(不一定要精準的使用所有的 PHP 內建函式)

<?php

extract($_POST);

$db = new DB();
$user = $db->query("SELECT * FROM users WHERE username=$username AND password=$password"); // query from DB

echo $user ? 'Login Access' : 'Login Failed';

問題剖析

基礎:extract 函式

extract 函式是一個 PHP 內建函式,它可以將陣列中的鍵值對轉為變數:

$foo = ['a' => 1, 'b' => 2];

extract($foo);

echo $a; // 1
echo $b; // 2

在某些時候,這是一個方便的函式,因為它可以讓我們省去指派變數的冗餘程式碼。

只不過,在預設的情況下,如果跟既有的變數衝突,extract 函式會覆蓋掉已經定義的變數(由第二個參數 flag 決定,預設是 EXTRA_OVERWRITE)。

危險

不要對不可信的資料使用 extract(),像是來自用戶的輸入 (例如 $_GET$_FILES)。

extract 最大的安全風險是拿來汙染超全域變數,例如 $_SESSION。一般來說用戶不應該能夠擅自修改 $_SESSION 的內容,但利用 extract 函式就可以做到這一點。

<?php

if (!session_id()) { session_start(); }

extract($_GET);

if (!($_SESSION['user'] ?? null)) { echo 'You shall not pass!'; exit; }

echo 'Welcome User!'

假設用戶尚未登入 $_SESSION['user'] 的值為 null,上述的程式應該會直接顯示 You shall not pass! 並且退出。

然而,因為使用了 extract($_GET),只要我們輸入 /?_SESSION[user]=true 就可以偽裝自己已經登入,甚至是將自己偽裝成任何用戶。

初級:未定義的變數

在 SQL Query 中,開發者假定 $usernmae$password 會被 extract 函式建立,這是個非常危險的思想,後端開發者絕不應該「假定」任何用戶輸入都符合預期。

在 PHP 中,如果使用了未定義變數,它預設會被視為「空」,並且丟出警告(只有警告、不是錯誤)。在這個例子中,這兩個變數會被視為空字串。

理論上來說,這並不是什麼大問題。照常識上來說,資料庫中應該是不會存在 usernamepassword 為空的資料(如果有,那表示資料表的設計存在一些邏輯上的問題)

初級:SQL Injection

我們知道,以單引號 '' 包裏的字串會以純文字呈現;雙引號 "" 包裏的字串會解析其變數值再呈現。


$name = 'Vincent';

echo 'Hello, $name'; // Hello, $name
echo "Hello, $name"; // Hello, Vincent

也就是說,以下程式中的 SQL Query 會被視為一個單純的字串,而其中的 $username$password 是用戶可以控制的值。

<?php

$db = new DB();
$user = $db->query("SELECT * FROM users WHERE username=$username AND password=$password"); // query from DB

綜上所述,這段程式存在 SQL Injection 問題,因為用戶可以自由的輸入單引號、雙引號或註釋符號改變原本 SQL Query 的語義。

中級:Query 中的密碼

我們可以觀察到,SQL Query 中直接把密碼作為 WHERE 的條件到資料庫查詢,這與「密碼應該被雜湊」的安全設計相悖。

正常來說,應該是照 Unique Key(本例中的 username)找出用戶之後,再比對其密碼是否正確:

<?php

$db = new DB();
$user = $db->query("SELECT * FROM users WHERE username=$username");

echo verify_user($user, $password)
? 'Login Success' : 'Login Failed';

註:verify_user 是一個用戶自定義的函式,假設它具有驗證 $password 是否合法的功能。

Follow Up

Follow Up 部份是可選的,這部份會針對一些現實可能存在的系統設計作為附加條件。

中級:雜湊驗證

上述程式中,verify_user 函式應該如何被實作?

這主要分為兩種情況:

  • 資料庫中的密碼可以使用 password_verify() 驗證
  • 資料庫中的密碼無法使用 password_verify() 驗證

官方文件得知,password_verify 函式可以用在以 crypt()password_hash() 函式雜湊值中。

提示

password_hash() 是對 crypt() 函式的封裝,並且加入了一些密碼雜湊時應該注意的最佳實踐(例如 Salt 與 Cost)簡化開發者的知識負擔。

密碼雜湊應該優先考慮使用 password_hash() 函式,並且使用 password_verify() 驗證,這可以大程度上地提高安全性與降低複雜度。

假設可以使用 password_verify() 的情況,驗證函式的實作會變得非常容易:

function verify_user(User $user, string $password): bool
{
return password_verify($password, $user->password);
}

中高級:雜湊驗證 - 續

承上,有些時候我們不能期待所有的系統都是使用現代的密碼雜湊演算法(如 Argon2 或 Bcrypt),一些比較陳舊的系統甚至還有以 md5sha1 作為雜湊的可能性。

危險

絕對不應該使用 md5sha1 作為密碼雜湊演算法,兩者皆已被認定為是不安全的雜湊演算法。

假設當前有一個既存系統仍使用 sha1 作為密碼雜湊,應該如何設計 verify_user() 函式?

function verify_user(User $user, string $password): bool
{
return sha1($password) === $user->password;
return hash_equals($user->password, sha1($password));
}

可以注意到,在密碼的比較上不應該使用 ===,而應該使用 hash_equals(),詳細原因可以看PHP 的字串比較這篇文章。

個人解答

<?php
function login_failed(): never
{
echo 'Login failed';
exit;
}

function verify_user(User $user, string $password): bool
{
return password_verify($password, $user->password);
}

$username = $_POST['username'] ?? null;
$password = $_POST['password'] ?? null;

if ($username === null || $password === null) { // username or password not set
login_failed();
}

$db = new DB();
$user = $db->query('SELECT * FROM users WHERE username = ?', [$username]);

if (!$user) { // user doesn't exist
login_failed();
}

if (!verify_user($user, $password)) { // password verify failed
login_failed();
}

echo 'Login success';

上述程式中做了一些假設:

  • $db->query 可以允許並執行 Prepared Statement
  • $db->query 會回傳一個 User Object(類似於 ORM)

結論

這個題目的設計主要可以測試:

  • 是否有看官方文件的習慣
    • extract 的風險被明確寫在官方文件中
  • 是否瞭解一些 PHP 常見的陷阱與特性
    • 未定義變數的處理
    • "" 包裏含變數的字串問題
  • 安全風險與意識
    • SQL Injection
    • 密碼雜湊的實踐

並且在內容上涵蓋從初階到中階工程師所需的能力與意識,而且不同等級的工程師能夠各取所需,因此我認為這是我個人設計過最優秀的題目。

註:我曾經將這份考題拿去 ChatGPT 進行測試,在不曉得一些背景知識的情況下(例如常數時間字串比對、時序攻擊等)很難從 ChatGPT 中獲取有用的資訊。這也是我認為即便 ChatGPT 大行其道,基礎知識仍尤為重要。

參考資料

  1. How to demonstrate an exploit of extract($_POST)?: https://stackoverflow.com/questions/3837789/how-to-demonstrate-an-exploit-of-extract-post