EST. 2026 — บันทึกงานเซิร์ฟเวอร์จริง SYSADMIN · SECURITY · AI · DIRECTADMIN เล่มที่ 01
THE SYSADMIN ALMANAC
หน้าหลัก / Security / WAF ตอบ 406 เพราะข้อความไทยใน POST — แก้ยังไงเมื่อไม่ใช่ root
Security

WAF ตอบ 406 เพราะข้อความไทยใน POST — แก้ยังไงเมื่อไม่ใช่ root

โดย Ton-Or ·15 ส.ค. 2026·อ่าน 9 นาที
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

request ที่โดน 406BrowserPOST + ข้อความไทย→nginx + ModSecurityตรวจ body→rule matchตัดจบตรงนี้→406 กลับ browserPHP-FPM ไม่เคยเห็น

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

ทางเลือกที่ผู้เช่าโฮสต์มีจริง ๆ

ถ้ามี root

แก้ที่ต้นเหตุ

เขียนไฟล์ 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 เข้ามาทีหลัง เลยต้องเลียนแบบพฤติกรรมนั้นเองด้วยมือ

เบราว์เซอร์ดัก submit ทุกฟอร์มencode เป็น __wafnginx + WAFเห็นแค่ __waf base64ไม่มีข้อความไทยดิบmiddlewarebase64 ถอดกลับทำก่อนทุกอย่างcontrollerเห็นฟิลด์ปกติไม่รู้ว่าเคยซ่อน_token และ _method ส่งดิบ ไม่ซ่อนใน __waf
ฟอร์มถูกดักตอน submit ให้ WAF เห็นแค่ก้อน base64 ชื่อ __waf แล้ว middleware ถอดกลับก่อนถึง controller ส่วน _token กับ _method ส่งดิบเพราะ Laravel อ่านก่อนถึง middleware
<?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 รอบแรก

ฟิลด์ที่ต้องยกเว้น และเหตุผลของแต่ละตัว

  1. _token — Laravel อ่าน CSRF token จาก request ก่อนจะถึง middleware ของเรา ถ้าเอาไปซ่อนใน __waf ด้วยจะโดน 419 ทุกครั้ง
  2. _method — ตัวบอก verb ปลอม (PUT/PATCH/DELETE) ถูกอ่านตั้งแต่ชั้น routing ต้องส่งดิบเสมอ
  3. ช่องอัปโหลดไฟล์ — ฟอร์มที่มี input[type=file] ให้ข้ามทั้งฟอร์ม เพราะ multipart ประกอบใหม่ด้วย JS ไม่คุ้ม และไฟล์ไม่ใช่ข้อความที่ rule จับอยู่แล้ว
  4. ฟิลด์ยาวเกิน 2000 ตัวอักษร — base64 ทำให้ยาวขึ้นอีกราวหนึ่งในสาม ยัดรวมกันหลายช่องแล้วมีสิทธิ์ชน limit ขนาด body ของ nginx ปล่อยตัวยาวส่งปกติดีกว่า
  5. ฟอร์มที่ติด 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 ว่ามันคืออะไร เปิดเพราะอะไร และเงื่อนไขที่จะถอดออกคืออะไร

406ตอบก่อนถึง PHP
2 ไฟล์JS + middleware
5 ตัวฟิลด์ยกเว้น
2000ความยาวที่ปล่อย

ส่วนตัวผมไม่ชอบ workaround แบบนี้เท่าไหร่ครับ มันเพิ่มของที่ต้องอธิบายให้คนรุ่นถัดไปฟัง แต่ถ้าชั่งกับการปล่อยให้ลูกค้าบันทึกงานไม่ได้เป็นวัน ๆ ระหว่างรอตั๋ว ก็ต้องเลือกทางนี้ไปก่อน ใครเจอเคสเดียวกันแล้วมีสิทธิ์ระดับ root อยู่ในมือ อย่าทำตามบทความนี้ครับ ไปเขียน exclusion ให้ตรง rule id ตรงกว่าเยอะ ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ

อยากให้เราทำให้ไหม?

ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า

คุยเรื่องงานกับเรา

เคยเจองานแบบนี้ไหม? ติดตรงไหน?

มาแลกวิธีกันต่อในบอร์ด LinuxThai

→ คุยต่อในบอร์ด

ความคิดเห็น 0

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