Rosetta.ai PHP 面試實作題解析
七月中旬,我離開了 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 中,如果使用了未定義變數,它預設會被視為「空」,並且丟出警告(只有警告、不是錯誤)。在這個例子中,這兩個變數會被視為空字串。
理論上來說,這並不是什麼大問題。照常識上來說,資料庫中應該是不會存在 username 與 password 為空的資料(如果有,那表示資料表的設計存在一些邏輯上的問題)
初級: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),一些比較陳舊的系統甚至還有以 md5 或 sha1 作為雜湊的可能性。
絕對不應該使用 md5 或 sha1 作為密碼雜湊演算法,兩者皆已被認定為是不安全的雜湊演算法。
假設當前有一個既存系統仍使用 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 大行其道,基礎知識仍尤為重要。
參考資料
- How to demonstrate an exploit of extract($_POST)?: https://stackoverflow.com/questions/3837789/how-to-demonstrate-an-exploit-of-extract-post
