ถอดชิ้นส่วน XSS, CSRF และการแทรกโค้ด SQL เพื่อเข้าใจอย่างชัดเจนว่า htmlspecialchars, โทเคน และ prepared statement ป้องกันอะไรได้จริง
เปิดบทเรียนนี้ใน Kodokonช่องโหว่บนเว็บสามตระกูลใหญ่ ได้แก่ XSS, CSRF และ การแทรกโค้ด (injection) ล้วนมีต้นตอเดียวกัน นั่นคือข้อมูลที่ผู้ใช้ป้อนเข้ามาลงเอยด้วยการถูกตีความเป็นโค้ด (HTML, SQL หรือคำร้องขอที่ดูถูกต้อง) เรามาเริ่มกันที่ XSS หากคุณแสดงค่าจาก $_GET โดยไม่หลีก (escape) ผู้โจมตีสามารถแทรกแท็ก <script> และรัน JavaScript ในเบราว์เซอร์ของผู้เข้าชมคนอื่น ๆ ของคุณ ทั้งขโมยเซสชันและกระทำการต่าง ๆ โดยที่พวกเขาไม่รู้ตัว ด้วย ?comment=<script>alert(1)</script> เอาต์พุตแรกด้านล่างจะรันสคริปต์ ส่วนเอาต์พุตที่สองจะทำให้มันเป็นกลาง มีแฟล็กสองตัวที่สำคัญตรงนี้ ENT_QUOTES จะแปลงอัญประกาศเดี่ยวด้วย (มิฉะนั้นแอตทริบิวต์ที่คั่นด้วยอัญประกาศเดี่ยวก็ยังถูกแทรกได้อยู่) และ ENT_SUBSTITUTE จะแทนที่ลำดับ UTF-8 ที่ไม่ถูกต้องแทนที่จะคืนสายอักขระว่าง ตั้งแต่ PHP 8.1 เป็นต้นมา แฟล็กทั้งสองกลายเป็นค่าเริ่มต้นในที่สุด แต่การระบุออกมาให้ชัดเจนช่วยบันทึกเจตนาของคุณและปกป้องโค้ดที่ทำงานบนเวอร์ชันเก่ากว่า
<?php
declare(strict_types=1);
$comment = $_GET['comment'] ?? '';
echo '<div>' . $comment . '</div>';
function e(string $value): string
{
return htmlspecialchars(
$value,
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8'
);
}
echo '<div>' . e($comment) . '</div>';CSRF ใช้ประโยชน์จากข้อเท็จจริงง่าย ๆ ข้อหนึ่ง นั่นคือเบราว์เซอร์จะแนบคุกกี้เซสชันไปกับคำร้องขอทุกรายการที่มุ่งไปยังโดเมนของคุณโดยอัตโนมัติ แม้จะเป็นคำร้องขอที่ถูกกระตุ้นจากเว็บไซต์ของบุคคลที่สามก็ตาม ดังนั้นแบบฟอร์มที่มุ่งร้ายจึงสามารถส่ง /account/delete โดยใช้เซสชันของเหยื่อได้ การป้องกันแบบคลาสสิกคือ synchronizer token ซึ่งเป็นความลับสุ่มที่เก็บไว้ในเซสชันและต้องมีในคำร้องขอที่เปลี่ยนแปลงข้อมูลทุกรายการ เว็บไซต์บุคคลที่สามไม่สามารถอ่านหรือเดามันได้ จึงไม่สามารถส่งมันมาได้
<?php
session_start();
if (!isset($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
$sent = $_POST['csrf'] ?? '';
$valid = is_string($sent)
&& hash_equals($_SESSION['csrf'], $sent);
$isPost = $_SERVER['REQUEST_METHOD'] === 'POST';
if ($isPost && !$valid) {
http_response_code(403);
exit('Invalid CSRF token');
}
?>
<form method="post">
<input type="hidden" name="csrf"
value="<?= htmlspecialchars($_SESSION['csrf']) ?>">
<button>Delete my account</button>
</form>การแทรกโค้ด SQL เป็นไปตามรูปแบบเดียวกัน การต่อค่าจากผู้ใช้เข้าไปในคิวรีเท่ากับการปล่อยให้ผู้ใช้เขียน SQL ' OR 1=1 -- เปลี่ยนตัวกรองให้กลายเป็นทั้งตาราง ทางแก้ไม่ใช่การหลีก แต่เป็น prepared statement ตัวข้อความคิวรี (พร้อมพเลซโฮลเดอร์ :email) และค่าต่าง ๆ เดินทางไปยังเอนจินแยกกัน ดังนั้นเอนจินจึงไม่มีวันสับสนระหว่างข้อมูลกับโค้ด รายละเอียดระดับผู้เชี่ยวชาญคือ เมื่อใช้กับ MySQL ตามค่าเริ่มต้น PDO จะ จำลอง (emulate) prepared statement โดยหลีกที่ฝั่งไคลเอนต์ ให้ตั้งค่า PDO::ATTR_EMULATE_PREPARES เป็น false เพื่อให้ได้ prepared statement แบบเนทีฟอย่างแท้จริง
<?php
declare(strict_types=1);
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(
PDO::ATTR_ERRMODE,
PDO::ERRMODE_EXCEPTION
);
$pdo->exec(
'CREATE TABLE users (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL
)'
);
$email = $_GET['email'] ?? '';
$stmt = $pdo->prepare(
'SELECT id FROM users WHERE email = :email'
);
$stmt->execute(['email' => $email]);
var_dump($stmt->fetch(PDO::FETCH_ASSOC));