Kodokon kodokon.com

ความปลอดภัยบนเว็บ: XSS, CSRF และการแทรกโค้ด

ถอดชิ้นส่วน XSS, CSRF และการแทรกโค้ด SQL เพื่อเข้าใจอย่างชัดเจนว่า htmlspecialchars, โทเคน และ prepared statement ป้องกันอะไรได้จริง

12 นาที · 3 คำถาม

เปิดบทเรียนนี้ใน 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
<?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 ซึ่งเป็นความลับสุ่มที่เก็บไว้ในเซสชันและต้องมีในคำร้องขอที่เปลี่ยนแปลงข้อมูลทุกรายการ เว็บไซต์บุคคลที่สามไม่สามารถอ่านหรือเดามันได้ จึงไม่สามารถส่งมันมาได้

HTML
<?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
<?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));
ค่าไม่เคยเข้าไปรวมกับข้อความ SQL การแทรกโค้ดจึงเป็นไปไม่ได้

ทดสอบความรู้

ตรวจสอบว่าคุณจำประเด็นสำคัญของบทเรียนนี้ได้ครบถ้วน

  1. ทำไมต้องส่งแฟล็ก ENT_QUOTES ให้ htmlspecialchars?
    • เพื่อแปลงอัญประกาศเดี่ยวด้วย ซึ่งหากไม่มีมัน แอตทริบิวต์ที่คั่นด้วยอัญประกาศเดี่ยวก็ยังถูกแทรกได้
    • เพื่อบังคับให้แปลงสายอักขระเป็น UTF-8
    • เพื่อลอกแท็กสคริปต์ออกจากเนื้อหา
    • เพื่อเร่งความเร็วการหลีกสายอักขระที่ยาว
  2. ทำไมต้องเปรียบเทียบโทเคน CSRF ด้วย hash_equals แทนที่จะใช้ ===?
    • hash_equals เปรียบเทียบในเวลาคงที่ ซึ่งป้องกันการเดาโทเคนทีละไบต์ด้วยการวัดเวลาตอบสนอง
    • hash_equals คำนวณแฮชของโทเคนใหม่โดยอัตโนมัติก่อนเปรียบเทียบ
    • === ล้มเหลวทันทีที่สายอักขระสองตัวมีการเข้ารหัสต่างกัน
  3. อะไรทำให้ prepared statement ปลอดภัยจากการแทรกโค้ด SQL?
    • มันหลีกอัญประกาศที่อยู่ในค่าต่าง ๆ โดยอัตโนมัติ
    • คิวรีและค่าต่าง ๆ ถูกส่งไปยังเอนจินแยกกัน ซึ่งเอนจินไม่เคยตีความค่าเหล่านั้นใหม่เป็น SQL
    • มันตรวจสอบว่าค่าแต่ละค่าตรงกับชนิดของคอลัมน์เป้าหมาย
    • มันจำกัดการทำงานให้เหลือเพียงคำสั่ง SQL เดียว