ช่องโหว่ที่พบบ่อยในโค้ดที่ AI สร้าง

AI เขียนโค้ดที่ "ทำงานได้" เร็วมาก แต่ทำงานได้กับปลอดภัยเป็นคนละเรื่องกัน เมื่อเราขอให้ AI ทำฟีเจอร์ให้ใช้ได้ มันจะหาทางที่สั้นที่สุดให้ใช้ได้ ซึ่งหลายครั้งหมายถึงการต่อ string เป็น SQL การแสดงข้อมูลโดยไม่ escape หรือการปิด CSRF เพื่อให้ฟอร์มส่งผ่าน ช่องโหว่เหล่านี้เป็นเรื่องพื้นฐานที่อยู่ในรายการความเสี่ยงของ OWASP มานาน แต่กลับมาพบบ่อยในโค้ดที่ AI สร้างเพราะไม่มีใครอ่านละเอียด บทความนี้รวบรวม 6 ช่องโหว่ที่พบบ่อย แต่ละข้อมีโค้ดผิดที่ AI มักเขียน โค้ดที่ถูกต้องใน PHP/Yii2 และวิธีสั่ง AI ให้ป้องกันตั้งแต่ต้น
1. SQL Injection
เกิดเมื่อนำค่าจากผู้ใช้ไปต่อเป็นคำสั่ง SQL โดยตรง ผู้โจมตีสามารถใส่ SQL ของตัวเองเข้าไป อ่านข้อมูลทั้งฐาน หรือแก้ไขข้อมูลได้ AI มักเขียนแบบนี้เมื่อเราขอ "ค้นหาแบบยืดหยุ่น" หรือ "เรียงตามคอลัมน์ที่ผู้ใช้เลือก"
// ผิด: ต่อ string ทั้งค่าค้นหาและชื่อคอลัมน์
$q = Yii::$app->request->get('q');
$sort = Yii::$app->request->get('sort', 'id');
$sql = "SELECT * FROM product WHERE name LIKE '%$q%' ORDER BY $sort";
$rows = Yii::$app->db->createCommand($sql)->queryAll();
// ถูก: ใช้ query builder และ whitelist ชื่อคอลัมน์
$q = (string) Yii::$app->request->get('q', '');
$sort = Yii::$app->request->get('sort', 'id');
$allowedSort = ['id', 'name', 'price', 'created_at'];
if (!in_array($sort, $allowedSort, true)) {
$sort = 'id';
}
$rows = (new \yii\db\Query())
->from('product')
->where(['ilike', 'name', $q]) // query builder ทำ binding และ escape ให้
->orderBy([$sort => SORT_ASC])
->limit(50)
->all();
// ถ้าจำเป็นต้องเขียน SQL เอง ให้ใช้ parameter binding
$rows = Yii::$app->db->createCommand(
'SELECT id, name FROM product WHERE category_id = :cat AND price <= :max',
[':cat' => (int) $catId, ':max' => (int) $maxPrice]
)->queryAll();
จำไว้ว่า parameter binding ใช้ได้กับ "ค่า" เท่านั้น ใช้กับชื่อตารางหรือชื่อคอลัมน์ไม่ได้ ส่วนนั้นต้อง whitelist เสมอ
ทดสอบอย่างไร: ลองใส่ค่าที่มีเครื่องหมาย ' ในช่องค้นหา และใส่ชื่อคอลัมน์ที่ไม่มีอยู่ใน parameter sort โค้ดที่ปลอดภัยต้องไม่เกิด SQL error และไม่เปลี่ยนรูปแบบ query ถ้าเห็นข้อความ error จากฐานข้อมูลหลุดออกมาที่หน้าเว็บ แปลว่ามีทั้งปัญหา injection และปัญหาการแสดง error ภายในให้ผู้ใช้เห็น
2. Cross-Site Scripting (XSS)
เกิดเมื่อแสดงข้อมูลที่ผู้ใช้กรอกบนหน้าเว็บโดยไม่ encode ผู้โจมตีใส่ JavaScript ลงไปในชื่อ ความคิดเห็น หรือที่อยู่ แล้วโค้ดนั้นจะรันในเบราว์เซอร์ของคนที่เปิดหน้านั้น รวมถึง admin
<!-- ผิด: แสดงค่าตรง ๆ -->
<h3><?= $comment->author ?></h3>
<div><?= $comment->body ?></div>
<?php
use yii\helpers\Html;
use yii\helpers\HtmlPurifier;
?>
<!-- ถูก: encode ข้อความธรรมดา และกรอง HTML ถ้าอนุญาตให้จัดรูปแบบ -->
<h3><?= Html::encode($comment->author) ?></h3>
<div><?= HtmlPurifier::process($comment->body) ?></div>
ฝั่ง JavaScript ก็มีปัญหาเดียวกัน AI มักใช้ innerHTML เพราะสะดวก
// ผิด
list.innerHTML += `<li>${item.name}</li>`;
// ถูก: ใช้ textContent สำหรับข้อความจากผู้ใช้
const li = document.createElement('li');
li.textContent = item.name;
list.appendChild(li);
ทำไม AI พลาดบ่อย: ตัวอย่างโค้ดจำนวนมากบนอินเทอร์เน็ตแสดงค่าโดยตรงเพื่อให้สั้น และการ encode ไม่ทำให้ฟีเจอร์ "ดูต่าง" ในการทดสอบปกติ ทดสอบได้ง่าย ๆ ด้วยการกรอกข้อความ <b>ทดสอบ</b> ในทุกช่อง ถ้าหน้าเว็บแสดงตัวหนา แทนที่จะแสดงแท็กเป็นข้อความ แปลว่ายังไม่ได้ encode
3. ตรวจสิทธิ์หาย (Broken Access Control)
นี่คือช่องโหว่ที่ AI พลาดบ่อยที่สุดและอันตรายที่สุด เพราะโค้ดทำงานถูกต้องทุกอย่างในการทดสอบปกติ แต่ไม่ได้เช็กว่า "คนนี้มีสิทธิ์ดูข้อมูลนี้ไหม" ทำให้ผู้ใช้เปลี่ยน id ใน URL แล้วเห็นข้อมูลของคนอื่นได้ (มักเรียกว่า IDOR)
// ผิด: login แล้วดู order ของใครก็ได้ แค่เปลี่ยน id
public function actionView(int $id)
{
$order = Order::findOne($id);
return $this->render('view', ['order' => $order]);
}
// ถูก: บังคับ login ด้วย AccessControl และจำกัด query ด้วยเจ้าของ
public function behaviors(): array
{
return [
'access' => [
'class' => \yii\filters\AccessControl::class,
'rules' => [
['allow' => true, 'roles' => ['@']],
],
],
];
}
public function actionView(int $id)
{
$order = Order::findOne([
'id' => $id,
'user_id' => Yii::$app->user->id,
]);
if ($order === null) {
throw new \yii\web\NotFoundHttpException('ไม่พบคำสั่งซื้อ');
}
return $this->render('view', ['order' => $order]);
}
การตอบ 404 แทน 403 เมื่อไม่ใช่เจ้าของ ช่วยไม่ให้ผู้โจมตีรู้ว่า id นั้นมีอยู่จริง ถ้าระบบมีหลายบทบาท ให้ใช้ RBAC ผ่าน Yii::$app->user->can() แทนการเช็ก role ด้วย if กระจายทั่วโค้ด
ทดสอบอย่างไร: สร้างผู้ใช้สองคน ให้คนแรกสร้างข้อมูล แล้ว login เป็นคนที่สองและเปิด URL เดียวกันโดยเปลี่ยนเฉพาะ id ทำแบบนี้กับทุก action ทั้ง view, update, delete และ download ควรเขียนเป็น functional test ไว้ด้วย เพราะช่องโหว่ประเภทนี้มักกลับมาเมื่อมีคนเพิ่ม action ใหม่โดยลืมเงื่อนไขเจ้าของ
4. Upload ไฟล์โดยไม่ตรวจ
AI มักเขียน upload ที่บันทึกไฟล์ด้วยชื่อเดิมลงในโฟลเดอร์ web/uploads โดยตรวจแค่นามสกุล ผู้โจมตีอาจอัปโหลดไฟล์ PHP ที่ตั้งชื่อหลอก แล้วเรียกรันผ่าน URL ได้
// ผิด: ใช้ชื่อไฟล์เดิม เก็บใน web root ไม่ตรวจ MIME
$file = UploadedFile::getInstanceByName('doc');
$file->saveAs(Yii::getAlias('@webroot/uploads/') . $file->name);
// ถูก: validate ทั้งนามสกุลและ MIME จากเนื้อไฟล์ จำกัดขนาด
// ตั้งชื่อใหม่แบบสุ่ม และเก็บนอก web root
class DocumentForm extends \yii\base\Model
{
public $file;
public function rules(): array
{
return [
[['file'], 'file',
'skipOnEmpty' => false,
'extensions' => ['pdf', 'jpg', 'png'],
'mimeTypes' => ['application/pdf', 'image/jpeg', 'image/png'],
'checkExtensionByMimeType' => true,
'maxSize' => 5 * 1024 * 1024,
],
];
}
public function upload(): ?string
{
if (!$this->validate()) {
return null;
}
$name = Yii::$app->security->generateRandomString(32) . '.' . $this->file->extension;
$path = Yii::getAlias('@app/storage/documents/') . $name;
return $this->file->saveAs($path) ? $name : null;
}
}
การดาวน์โหลดไฟล์ให้ผ่าน action ที่ตรวจสิทธิ์ก่อน แล้วใช้ Yii::$app->response->sendFile() ส่งไฟล์ แทนการเปิดโฟลเดอร์ให้เข้าถึงตรง ๆ
ข้อควรรู้: นามสกุลและ MIME type ที่เบราว์เซอร์ส่งมาปลอมได้ทั้งคู่ การตั้ง checkExtensionByMimeType ให้ Yii ตรวจ MIME จากเนื้อไฟล์จริงช่วยได้มาก แต่สิ่งที่ปกป้องได้ดีที่สุดคือการไม่เก็บไฟล์ในตำแหน่งที่ web server จะรันเป็นสคริปต์ได้ และการตั้งชื่อไฟล์ใหม่เองทั้งหมด
5. Secret ฝังในโค้ด
เมื่อเราบอก AI ว่า "เชื่อมต่อ API นี้" แล้วแปะ key ไปใน prompt มันจะฝัง key ลงในโค้ดตรง ๆ และเมื่อโค้ดถูก commit key ก็จะอยู่ในประวัติ Git ตลอดไป แม้ลบออกภายหลัง
// ผิด
$apiKey = 'sk-live-xxxxxxxxxxxxxxxx';
// ถูก: อ่านจาก environment และหยุดทำงานถ้าไม่ได้ตั้งค่า
$apiKey = getenv('LLM_API_KEY');
if ($apiKey === false || $apiKey === '') {
throw new \RuntimeException('LLM_API_KEY is not configured');
}
ถ้าพบว่า key เคยถูก commit ไปแล้ว ให้ถือว่า key รั่ว ต้อง rotate (ออก key ใหม่และยกเลิกตัวเก่า) ทันที การลบออกจากโค้ดอย่างเดียวไม่พอ รายละเอียดเพิ่มเติมอยู่ในบทความ "อย่าใส่ Secret ลงใน Prompt: จัดการ API Key ให้ปลอดภัย"
6. ปิด CSRF เพื่อให้ฟอร์มทำงาน
เมื่อส่ง POST ด้วย AJAX แล้วเจอ error 400 "Unable to verify your data submission" AI มักเสนอทางแก้ที่เร็วที่สุดคือปิด CSRF validation ทั้ง controller ซึ่งเปิดช่องให้เว็บอื่นหลอกให้ผู้ใช้ที่ login อยู่ส่งคำสั่งโดยไม่รู้ตัว
// ผิด
class OrderController extends Controller
{
public $enableCsrfValidation = false;
}
วิธีที่ถูกคือส่ง token ไปด้วย ใน layout ใส่ <?= Html::csrfMetaTags() ?> ไว้ใน <head> (ฟอร์มที่สร้างด้วย ActiveForm ใส่ token ให้อัตโนมัติอยู่แล้ว) แล้วฝั่ง JavaScript ส่ง header
const token = document.querySelector('meta[name="csrf-token"]').content;
await fetch('/order/cancel?id=' + encodeURIComponent(orderId), {
method: 'POST',
headers: { 'X-CSRF-Token': token },
});
ข้อยกเว้นที่ยอมรับได้คือ API แบบ stateless ที่ยืนยันตัวตนด้วย token ใน header (ไม่ใช้ cookie session) ซึ่งปิด CSRF ได้เฉพาะ controller ของ API นั้น ไม่ใช่ทั้งแอป
สั่ง AI ให้ป้องกันตั้งแต่ต้น
ใส่ข้อกำหนดเหล่านี้ในไฟล์แนวทางโปรเจกต์หรือใน prompt ทุกครั้งที่ให้เขียนฟีเจอร์ที่รับ input
- ใช้ ActiveRecord หรือ Query Builder ห้ามต่อ string เป็น SQL ชื่อคอลัมน์จาก input ต้อง whitelist
- ทุก output ใน view ผ่าน
Html::encode()ฝั่ง JS ใช้textContent - ทุก action อยู่ใน AccessControl และ query ต้องจำกัดด้วยเจ้าของข้อมูล
- upload ต้องตรวจ MIME จำกัดขนาด ตั้งชื่อสุ่ม และเก็บนอก web root
- ห้ามฝัง secret ใช้
getenv() - ห้ามปิด CSRF ให้ส่ง token แทน
และหลัง AI เขียนเสร็จ ให้ถามต่ออีกรอบว่า "ตรวจโค้ดนี้ตาม 6 ข้อด้านบน ชี้บรรทัดที่ไม่ผ่าน" ซึ่งมักจับสิ่งที่หลุดได้เพิ่ม แต่ยังต้องตรวจด้วยตัวเองเสมอ
สรุป
- AI หาทางสั้นที่สุดให้โค้ดทำงาน ซึ่งมักข้ามมาตรการความปลอดภัย ถ้าไม่ได้สั่งไว้
- SQL ใช้ query builder หรือ binding, output ใช้
Html::encode(), JS ใช้textContent - การตรวจสิทธิ์ระดับ record คือจุดที่หลุดบ่อยและอันตรายที่สุด
- upload ต้องตรวจ MIME เปลี่ยนชื่อ และเก็บนอก web root ส่วน secret อ่านจาก environment เสมอ
- เมื่อเจอ error CSRF ให้ส่ง token ไม่ใช่ปิดการตรวจ
ลองทำ
ตรวจโปรเจกต์ของคุณด้วยการค้นหาแบบง่าย ๆ แล้วไล่ดูผลทีละจุด
# หา SQL ที่อาจต่อ string
grep -rn "createCommand(\"" --include=*.php . | grep -v vendor
# หา view ที่อาจแสดงค่าโดยไม่ encode
grep -rn "<?= \$model->" --include=*.php . | grep -v vendor
# หาการปิด CSRF
grep -rn "enableCsrfValidation = false" --include=*.php . | grep -v vendor
# หา innerHTML ฝั่ง JS
grep -rn "innerHTML" --include=*.js . | grep -v node_modules
เลือกผลลัพธ์อย่างน้อยหนึ่งจุดที่เป็นปัญหาจริง เขียนเทสต์หรือขั้นตอนทดสอบด้วยมือที่แสดงช่องโหว่ แก้ตามแนวทางในบทความ แล้วยืนยันว่าทดสอบซ้ำแล้วโจมตีไม่ได้อีก
ความคิดเห็น (0)
ยังไม่มีความคิดเห็น — เริ่มคุยเป็นคนแรก ถามสิ่งที่สงสัย หรือแชร์ประสบการณ์ของคุณ