WAF ตอบ 406 เพราะข้อความไทยใน POST — แก้ยังไงเมื่อไม่ใช่ root
เจอ 406 จาก WAF ตอนบันทึกข้อความไทยธรรมดา ๆ แล้วเปิด rule ไปแก้ไม่ได้เพราะไม่ใช่เครื่องเรา — ทางที่เหลือคือทำให้ WAF มองไม่เห็นข้อความนั้นตั้งแต่แรก แล้วประกอบกลับที่ฝั่งแอป
พอดีเว็บ Laravel ตัวหนึ่งที่ผมดูแลอยู่บน shared hosting (DirectAdmin + nginx + ModSecurity ของเจ้าของเครื่อง) จู่ ๆ ก็บันทึกข้อมูลบางรายการไม่ได้ กดปุ่มบันทึกแล้วหน้าเว็บเด้ง 406 ออกมาดื้อ ๆ ทั้งที่กรอกอย่างอื่นเหมือนเดิมทุกช่อง ไล่ดูสุดท้ายพบว่าตัวปัญหาคือข้อความภาษาไทยบางรูปแบบใน POST body ที่ไปตรงกับ rule ของ WAF พอดี
ประเด็นของบทความนี้ไม่ใช่การไปแก้ rule ครับ — บนเว็บนี้ผมเขียนเรื่อง ModSecurity ไว้หลายตอนแล้ว แต่ทุกตอนเขียนจากฝั่งคนที่มี root อยู่ในมือ เขียน exclusion ได้ รีโหลด nginx ได้ อ่าน modsec_audit.log ได้ ตอนนี้ผมสวมหมวกอีกใบ คือเป็นผู้เช่าโฮสต์ ที่ล็อกอินเข้าไปได้แค่ในบ้านตัวเอง แก้อะไรนอกบ้านไม่ได้เลย และงานต้องเดินต่อวันนี้ ไม่ใช่รอตั๋ว support สามวัน
โดเมน ชื่อผู้ใช้ และข้อความตัวอย่างทั้งหมดในบทความเป็นของสมมติ โครงสร้างโค้ดคือของจริงที่ใช้อยู่
อาการ: บันทึกข้อความไทยบางประโยคแล้วได้ 406 เฉย ๆ
ฟอร์มมีช่องกรอกข้อความไทยยาว ๆ ช่องหนึ่ง กรอกประโยคหนึ่งแล้วบันทึกผ่าน กรอกอีกประโยคแล้ว 406 ทันที ที่หลอกคนแก้ที่สุดคือมันไม่ใช่ทุกประโยค เลยดูเหมือนระบบ "งอแง" ทั้งที่จริง ๆ มันสม่ำเสมอมาก ประโยคเดิมโดนทุกครั้ง
สิ่งที่ทำให้ไล่ยากกว่าปกติคือ ฝั่งแอปไม่รู้เรื่องอะไรเลย — laravel.log ว่างเปล่า ไม่มี exception ไม่มี validation error ไม่มีแม้แต่บรรทัดว่ามี request เข้ามา เพราะ request ตัวนั้นไม่เคยเดินทางถึง PHP
ยืนยันก่อนว่าใครเป็นคนตอบ 406
ก่อนจะไปโทษโค้ดตัวเอง ต้องแยกให้ออกก่อนว่า 406 ออกมาจากชั้นไหน ดูจาก header ของ response ตรง ๆ:
curl -i -s -X POST https://app.example.com/records \
--data-urlencode 'note=1 เม็ด วันละ 3 ครั้ง หลังอาหาร' \
-H 'X-Requested-With: XMLHttpRequest' | head -12
HTTP/2 406
server: nginx
content-type: text/html
content-length: 226
จุดสังเกตคือ response สั้นมาก ไม่มี header ที่ Laravel ใส่ให้ (ไม่มี cookie session ใหม่ ไม่มี X-RateLimit-*) และตอบเร็วผิดปกติ เพราะไม่มีการ bootstrap framework เลย นี่คือลายเซ็นของการโดนตัดที่ชั้น web server ไม่ใช่ที่แอป
ส่วนตัวผมอยากดู modsec_audit.log เพื่อจะได้รู้ว่า rule id ไหนยิง แต่ไฟล์นั้นอยู่นอกบ้าน ผู้เช่าเปิดไม่ได้ ใน DirectAdmin เห็นได้แค่ error log ของโดเมนตัวเอง ซึ่งเงียบสนิทเพราะ Apache/PHP ไม่ได้เป็นคนตอบ สรุปคือต้องวินิจฉัยแบบ black-box คือยิงเข้าไปแล้วดูว่าอันไหนผ่านอันไหนไม่ผ่าน
isolate ให้เจอว่าข้อความท่อนไหนโดน
วิธีที่เร็วที่สุดคือทำ endpoint ทดสอบไว้ตัวหนึ่ง ที่ไม่ทำอะไรเลยนอกจากตอบ 200 กลับมา แล้วยิงข้อความเข้าไปทีละท่อนแบบตัดครึ่งไปเรื่อย ๆ
// routes/web.php — ชั่วคราว ลบทิ้งเมื่อ isolate เสร็จ
Route::post('/waf-probe', fn () => response('ok', 200))
->withoutMiddleware([\Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class]);
URL=https://app.example.com/waf-probe
for t in "1 เม็ด วันละ 3 ครั้ง หลังอาหาร" \
"1 เม็ด วันละ 3 ครั้ง" \
"วันละ 3 ครั้ง หลังอาหาร" \
"ครั้งละ 1 เม็ด" \
"1 เม็ด" ; do
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$URL" \
--data-urlencode "probe=$t")
printf '%s <- %s\n' "$code" "$t"
done
406 <- 1 เม็ด วันละ 3 ครั้ง หลังอาหาร
406 <- 1 เม็ด วันละ 3 ครั้ง
200 <- วันละ 3 ครั้ง หลังอาหาร
200 <- ครั้งละ 1 เม็ด
200 <- 1 เม็ด
เห็นชัดเลยครับ — คำแต่ละคำไม่ผิดอะไรสักคำ แต่พอเรียงเป็น "ตัวเลข + คำไทย + ตัวเลข + คำไทย" ในลำดับหนึ่งแล้วมันไปเข้าทาง rule ตรวจ SQL injection ของ CRS (กลุ่ม 942xxx ที่ใช้ libinjection) ซึ่งตัดสินจาก "รูปแบบ token" ไม่ได้ตัดสินจากความหมาย พอ tokenizer เจอสตริงที่มีตัวเลขสลับ separator ในจังหวะที่มันคุ้น ก็ตีเป็น payload
รู้ว่าไม่ใช่บั๊กของแอปเราแล้วหนึ่ง และได้ตัวอย่างข้อความที่ทำซ้ำได้เป๊ะ ๆ อีกหนึ่ง — ตัวอย่างชุดนี้แหละที่จะเอาไปแนบตอนเปิดตั๋วขอให้เจ้าของเครื่อง whitelist ให้ ถ้าไม่มีตัวอย่างที่ทำซ้ำได้ ตั๋วจะถูกตีกลับว่า "ทดสอบแล้วใช้งานได้ปกติ" ทุกครั้ง
ทางเลือกที่ผู้เช่าโฮสต์มีจริง ๆ
แก้ที่ต้นเหตุ
เขียนไฟล์ exclusion ให้โดเมนนี้ ปิดเฉพาะ rule id ที่ยิง แล้ว reload nginx จบใน 5 นาที และไม่ต้องแตะโค้ดแอปเลย
ทำให้ WAF ไม่มีอะไรให้จับ
เปลี่ยนรูปแบบ payload ที่ส่งออกจาก browser ให้ไม่เหลือ pattern ที่ rule จับได้ แล้วประกอบกลับที่ฝั่งแอปก่อน controller ทำงาน
ก่อนตัดสินใจผมชั่งอยู่สามทาง หนึ่งคือรอ host ปรับให้ — ทำแน่ แต่ระหว่างรอ ลูกค้าใช้งานไม่ได้ สองคือเปลี่ยนฟอร์มไปส่งเป็น JSON body ผ่าน fetch แทน form-urlencoded — ช่วยได้บางเคสแต่ไม่ทั้งหมด เพราะ ModSecurity อ่าน request body ได้ทั้ง JSON และ urlencoded ขึ้นกับว่าเปิด body processor ไว้แค่ไหน เดาไม่ได้ว่าเครื่องนั้นตั้งไว้ยังไง สามคือ encode payload ให้ไม่เหลือข้อความไทยดิบ ๆ ในสายตา WAF
ผมเลือกทางที่สาม เพราะเป็นทางเดียวที่ควบคุมได้เองทั้งหมดและไม่ขึ้นกับค่า config ที่มองไม่เห็น ส่วนตั๋วก็เปิดคู่ขนานไป ถ้าวันหนึ่งเจ้าของเครื่อง whitelist ให้แล้วก็ถอด workaround ออกได้
ฝั่งหน้าเว็บ: ดัก submit แล้ว encode ก่อนส่ง
หลักการคือดัก event submit ของทุกฟอร์ม POST ในเว็บ ยกเว้นที่ระบุไว้ กวาดค่าทุกช่องมาต่อเป็น query string แล้ว base64 ยัดลงช่องเดียวชื่อ __waf พร้อมกับถอด attribute name ของช่องเดิมออก ไม่งั้นข้อมูลจะถูกส่งซ้ำสองชุดและ WAF ก็ยังเห็นตัวดิบอยู่ดี
// resources/views/partials/waf-safe.blade.php — ส่วน <script> (include ใน layouts/app)
(function () {
const SKIP = ['_token', '_method', 'photo', 'avatar', 'attachment'];
const MAXLEN = 2000;
// btoa รับได้เฉพาะ byte เดียว ต้องแปลงเป็น UTF-8 ก่อน ไม่งั้นภาษาไทยพังทันที
const b64 = (s) => btoa(String.fromCharCode(...new TextEncoder().encode(s)));
document.addEventListener('submit', function (e) {
const form = e.target;
if (!form || (form.method || '').toLowerCase() !== 'post') return;
if (form.hasAttribute('data-waf-skip')) return;
if (form.querySelector('input[type=file]')) return; // multipart ไม่ยุ่งด้วย
const pairs = [];
form.querySelectorAll('input, textarea, select').forEach(function (el) {
if (!el.name || el.disabled) return;
if (SKIP.includes(el.name)) return;
if ((el.type === 'checkbox' || el.type === 'radio') && !el.checked) return;
if (String(el.value).length > MAXLEN) return; // ยาวเกิน ปล่อยส่งปกติ
pairs.push(encodeURIComponent(el.name) + '=' + encodeURIComponent(el.value));
el.removeAttribute('name'); // กันส่งซ้ำ + กัน WAF เห็นตัวดิบ
});
if (!pairs.length) return;
const box = document.createElement('input');
box.type = 'hidden';
box.name = '__waf';
box.value = b64(pairs.join('&'));
form.appendChild(box);
}, true);
})();
ที่ใช้ encodeURIComponent กับชื่อฟิลด์ด้วยเพราะฟอร์มจริงมีชื่อแบบ items[0][qty] เต็มไปหมด ถ้าไม่ encode ให้ครบ ตอนถอดกลับด้วย parse_str จะได้โครงสร้างเพี้ยน
ฝั่ง Laravel: middleware ที่ decode กลับ
ฝั่งเซิร์ฟเวอร์ต้องถอดกลับก่อนทุกอย่าง ให้ controller กับ FormRequest ทำงานเหมือนไม่มีอะไรเกิดขึ้น ตัวที่ต้องระวังคือค่าว่าง — Laravel มี ConvertEmptyStringsToNull อยู่ใน middleware group อยู่แล้ว แต่ตัวนั้นทำงานกับค่าที่มีอยู่ตอนมันรัน ของเราถูก merge เข้ามาทีหลัง เลยต้องเลียนแบบพฤติกรรมนั้นเองด้วยมือ
<?php
// app/Http/Middleware/DecodeWafSafe.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class DecodeWafSafe
{
public function handle(Request $request, Closure $next)
{
$blob = $request->input('__waf');
if (is_string($blob) && $blob !== '') {
$raw = base64_decode($blob, true); // strict: ขยะไม่ผ่าน
if ($raw !== false) {
parse_str($raw, $fields);
$request->request->remove('__waf');
$request->merge($this->emptyToNull($fields));
}
}
return $next($request);
}
/** เลียนแบบ ConvertEmptyStringsToNull เพราะเรา merge หลังตัวนั้นรันไปแล้ว */
private function emptyToNull(array $in): array
{
foreach ($in as $k => $v) {
$in[$k] = is_array($v) ? $this->emptyToNull($v) : ($v === '' ? null : $v);
}
return $in;
}
}
// bootstrap/app.php — ต้อง prepend ให้รันก่อนตัวอื่นใน group web
->withMiddleware(function (Middleware $middleware) {
$middleware->web(prepend: [
\App\Http\Middleware\DecodeWafSafe::class,
]);
})
เรื่องค่าว่างนี่ไม่ใช่รายละเอียดปลีกย่อยนะครับ ถ้าไม่แปลง '' เป็น null ช่องวันที่ที่ผู้ใช้ไม่กรอกจะกลายเป็นสตริงว่างวิ่งเข้า MariaDB ที่เปิด strict mode แล้วได้ 500 กลับมาแทน 406 — คือย้ายที่เจ๊งเฉย ๆ ผมเจอมาแล้วตอน deploy รอบแรก
ฟิลด์ที่ต้องยกเว้น และเหตุผลของแต่ละตัว
- _token — Laravel อ่าน CSRF token จาก request ก่อนจะถึง middleware ของเรา ถ้าเอาไปซ่อนใน __waf ด้วยจะโดน 419 ทุกครั้ง
- _method — ตัวบอก verb ปลอม (PUT/PATCH/DELETE) ถูกอ่านตั้งแต่ชั้น routing ต้องส่งดิบเสมอ
- ช่องอัปโหลดไฟล์ — ฟอร์มที่มี input[type=file] ให้ข้ามทั้งฟอร์ม เพราะ multipart ประกอบใหม่ด้วย JS ไม่คุ้ม และไฟล์ไม่ใช่ข้อความที่ rule จับอยู่แล้ว
- ฟิลด์ยาวเกิน 2000 ตัวอักษร — base64 ทำให้ยาวขึ้นอีกราวหนึ่งในสาม ยัดรวมกันหลายช่องแล้วมีสิทธิ์ชน limit ขนาด body ของ nginx ปล่อยตัวยาวส่งปกติดีกว่า
- ฟอร์มที่ติด data-waf-skip — ทางหนีไฟไว้ให้ฟอร์มที่มีพฤติกรรมพิเศษ เช่นฟอร์มที่ JS ตัวอื่นไปยุ่งกับ field ต่อ
กับดักที่เจอหลัง deploy
อันแรกและเจ็บที่สุดคือฟอร์มที่ submit ด้วย form.submit() จาก JavaScript — เมธอดนั้นไม่ยิง event submit ตัวดักของเราเลยไม่ทำงาน ข้อมูลวิ่งดิบไปหา WAF ตามเดิม แล้วก็ 406 เหมือนเดิม แก้โดยเปลี่ยนไปเรียก form.requestSubmit() แทน (ตัวนี้ยิง event ให้) หรือไม่ก็ให้ปุ่มเป็น submit ปกติ
นี่คือการอ้อม ไม่ใช่การแก้ — WAF ยังตั้งผิดอยู่เหมือนเดิม แค่เรายื่นของที่มันจับไม่ได้ให้แทน อย่าคิดว่านี่คือมาตรการความปลอดภัยเพิ่ม เพราะผลจริงคือลดการตรวจลง input validation กับ prepared statement ฝั่งแอปต้องแน่นกว่าเดิม ไม่ใช่หย่อนลง และเมื่อไหร่ที่เจ้าของเครื่องยอม whitelist ให้แล้ว ให้ถอดออกทันที
อันที่สองคือเรื่องคนอื่นในทีม โค้ดนี้ทำงานเงียบ ๆ ทั้งเว็บ คนที่มาทีหลังเปิด DevTools แล้วเห็น request มีแค่ _token กับ __waf จะงงว่าฟอร์มส่งอะไรไป ผมเลยเขียนคอมเมนต์ยาว ๆ ไว้หัวไฟล์ partial ว่ามันคืออะไร เปิดเพราะอะไร และเงื่อนไขที่จะถอดออกคืออะไร
ส่วนตัวผมไม่ชอบ workaround แบบนี้เท่าไหร่ครับ มันเพิ่มของที่ต้องอธิบายให้คนรุ่นถัดไปฟัง แต่ถ้าชั่งกับการปล่อยให้ลูกค้าบันทึกงานไม่ได้เป็นวัน ๆ ระหว่างรอตั๋ว ก็ต้องเลือกทางนี้ไปก่อน ใครเจอเคสเดียวกันแล้วมีสิทธิ์ระดับ root อยู่ในมือ อย่าทำตามบทความนี้ครับ ไปเขียน exclusion ให้ตรง rule id ตรงกว่าเยอะ ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
อยากให้เราทำให้ไหม?
ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



ความคิดเห็น 0
ยังไม่มีความคิดเห็น — เป็นคนแรกเลย