Pokpong.net
HomeProfileProjectsAchievementsBlogContact
HomeProfileProjectsAchievementsBlogContact

Pokpong.net

Personal website of Pokpong Limpaphan (Ton).
IT Student, Developer, and Runner.

Links

HomeProfileProjectsBlogRSS Feed

Connect

Member of Webring

วงแหวนเว็บ
© 2026 Pokpong Limpaphan (ปกป้อง ลิมปพันธุ์). All rights reserved.
กลับไปหน้าบทความ

ตรวจจับ IoT attack ด้วย rule ล้วนๆ ไม่ใช้ ML เลย แล้วได้ Private F1 0.98149

บันทึกการลองผิดลองถูกจากโจทย์ SuperAI6 Hackathon ที่ X_train เป็น Normal ล้วน ไม่มีตัวอย่าง attack ให้ดูเลยสักแถว เล่าตั้งแต่ TCP กับ MQTT ทำงานยังไง ทำไมถึงเลือกไม่ใช้ ML การเอา AI มาเป็นผู้ช่วยวิเคราะห์ และข้อจำกัดที่ยังแก้ไม่ได้

Pokpong Limpaphan (ปกป้อง ลิมปพันธุ์)2026-05-26อ่าน 8 นาทีCybersecurityIoTMQTTAnomaly DetectionKaggle
สารบัญ
  • ข้อมูลมีแค่นี้ และมันจำกัดกว่าที่คิด
  • TCP ทำงานยังไง
  • MQTT ทำงานยังไง
  • ค่าว่างในไฟล์นี้ไม่ใช่ข้อมูลหายเสมอไปนะครับ อาจจะต้องเช็คดูดีๆ
  • ทำไมไม่ใช้ Random Forest
  • ด่านแรก ค่าที่ไม่เคยปรากฏใน train
  • ข้อมูลบอกว่า attack หน้าตายังไง
  • Fingerprint ของ TCP stack
  • จุดพลิกของงานนี้ normal session skeleton
  • ลำดับเงื่อนไข
  • Thresholds มาจากไหน
  • กว่าจะมาถึงตรงนี้ ก็ลองผิดลองถูกอยู่พอสมควร
  • ผลลัพธ์
  • ข้อจำกัดที่ต้องพูดตรงๆ
  • เรื่องการใช้ AI ก็เหมือนกัน
  • สิ่งที่ผมได้กลับมา

เรื่องมีอยู่ว่า SuperAI6 มีโจทย์ Hackathon ออนไลน์เรื่องการตรวจจับการโจมตีบนเครือข่าย IoT ที่ใช้ MQTT ให้ X_train มาหนึ่งแสน row และ X_test มาหนึ่งหมื่น row หน้าที่คือทายว่าแต่ละ row เป็น Normal หรือ attack ตอบเป็น 0 กับ 1 แล้ววัดด้วย F1

พอเปิดไฟล์มาผมเจอปัญหาแรกทันที X_train ทั้งหนึ่งแสน row เป็น Normal ล้วน ไม่มีตัวอย่าง attack ให้ดูแม้แต่ row เดียว

บทความนี้ผมจะเล่าตั้งแต่ข้อมูลหน้าตายังไง TCP และ MQTT ทำงานยังไง ทำไมผมถึงเลือกไม่ใช้ ML และวิธีที่ใช้จริงคืออะไร รวมถึงข้อจำกัดที่ผมยังแก้ไม่ได้ด้วย

ข้อมูลมีแค่นี้ และมันจำกัดกว่าที่คิด

แต่ละ row ในไฟล์คือ 1 packet ที่ถูกดักจับไว้ มี 23 column เป็น field ที่แกะมาจาก header ของ packet เช่น frame.len คือขนาดของ packet หน่วยเป็น byte tcp.stream คือหมายเลขการเชื่อมต่อว่า packet ใบนี้อยู่ในบทสนทนาไหน tcp.window_size tcp.len และ field ของ MQTT อีกกลุ่มหนึ่ง

ภาพรวมของ X_train และ X_test ที่ไม่มี label ราย row ให้เลย

จุดที่ต้องเข้าใจให้ตรงกันตั้งแต่ต้นคือ งานนี้ไม่มี label ราย row ให้เลย

X_train บอกแค่ว่าทั้งไฟล์เป็น Normal ส่วน X_test ไม่มี label อะไรเลย และ feedback เดียวที่ได้กลับมาต่อการ submit หนึ่งครั้งคือค่า F1 หนึ่งตัว

แปลว่าผมไม่มีทางรู้เลยว่า row ไหนทายถูก row ไหนทายผิด รู้แค่ว่าภาพรวมดีขึ้นหรือแย่ลง เรื่องนี้สำคัญมากเพราะมันหมายความว่าทุกอย่างที่ผมยืนยันได้ในงานนี้ ยืนยันได้แค่ระดับกลุ่มของ rule ไม่สามารถยืนยัน label ราย row ได้ ผมจะพูดถึงเรื่องนี้อีกทีตอนท้าย

อีกเรื่องคือ row ใน X_test ถูกสลับลำดับมาแล้ว และไม่มี timestamp ให้ใช้ ข้อมูลเรื่องเวลาและลำดับจึงหมดสิทธิ์ตั้งแต่แรก

TCP ทำงานยังไง

ก่อนจะไปต่อ ขออธิบายพื้นฐานหน่อยเพราะทั้งงานนี้วางอยู่บนความเข้าใจตรงนี้

TCP three way handshake และความหมายของ tcp.window_size

TCP เป็น protocol ที่รับประกันว่าข้อมูลจะถึงปลายทางครบและเรียงลำดับถูก ก่อนส่งข้อมูลจริงต้องจับมือกัน 3 ครั้งเสมอ ฝั่งที่จะเชื่อมต่อส่ง SYN ไปก่อน ปลายทางตอบ SYN ACK กลับมา แล้วฝั่งแรกส่ง ACK ปิดท้าย หลังจากนั้นทุก packet ที่ได้รับก็ต้องตอบ ACK กลับไปเรื่อยๆ ว่าได้รับแล้ว ในไฟล์นี้ ACK จึงเป็น packet ชนิดที่เจอบ่อยที่สุด

ตัวที่กลายเป็นหัวใจของงานนี้จริงๆ คือ tcp.window_size มันคือตัวเลขที่ฝั่งรับบอกกลับไปว่าตอนนี้ buffer ของตัวเองยังว่างอีกกี่ byte ฝั่งส่งจะส่งเกินกว่านั้นไม่ได้ ถ้าฝั่งรับประมวลผลไม่ทัน ค่านี้ก็จะลดลง พอเคลียร์ buffer ได้ค่าก็ขยับขึ้น

ประเด็นสำคัญคือค่าตั้งต้นและวิธีขยับค่านี้ไม่ได้ถูกกำหนดตายตัวใน protocol แต่ขึ้นกับ OS และ TCP stack ของเครื่องนั้นๆ เครื่องคนละแบบจึงมี pattern ของ window ไม่เหมือนกัน กลายเป็น Fingerprint ที่ทิ้งไว้โดยไม่ตั้งใจ

MQTT ทำงานยังไง

MQTT วิ่งอยู่บน TCP อีกชั้นหนึ่ง เป็น protocol แบบ publish subscribe ที่ออกแบบมาให้อุปกรณ์เล็กๆ ใช้ อุปกรณ์ไม่ได้คุยกันตรงๆ แต่ส่งผ่านตัวกลางที่เรียกว่า broker ฝั่งที่มีข้อมูลจะ publish เข้า topic แล้วใครที่ subscribe topic นั้นไว้ก็จะได้รับต่อจาก broker อีกที

โครงสร้าง publish subscribe และตาราง msgtype ทั้ง 8 ชนิดที่เจอในไฟล์

แต่ละ packet ของ MQTT มี field ชื่อ mqtt.msgtype บอกว่าเป็นข้อความชนิดไหน ในข้อมูลชุดนี้เจอ 8 ชนิด

msgtype 1 คือ CONNECT อุปกรณ์ขอเข้าระบบ ส่งค่า keepalive และ credential มาด้วย msgtype 2 คือ CONNACK broker ตอบว่ารับเข้าระบบแล้ว msgtype 3 คือ PUBLISH ส่งข้อมูลจริงเข้า topic msgtype 8 กับ 9 คือ SUBSCRIBE และ SUBACK msgtype 12 กับ 13 คือ PINGREQ และ PINGRESP ซึ่งเป็นการเช็คว่ายังออนไลน์กันอยู่ไหม และ msgtype 14 คือ DISCONNECT

จุดที่น่าสนใจตั้งแต่ยังไม่เริ่มทำอะไรเลยคือ X_train มี msgtype แค่ 6 ชนิด ไม่มี SUBSCRIBE และ SUBACK อยู่เลยแม้แต่ row เดียว แต่ X_test มีทั้งสองชนิดโผล่มา ซึ่งกลายเป็นเบาะแสแรกที่ผมได้

ค่าว่างในไฟล์นี้ไม่ใช่ข้อมูลหายเสมอไปนะครับ อาจจะต้องเช็คดูดีๆ

ก่อนจะเข้าเรื่องวิธี ขอพูดถึงสิ่งที่ผมตั้งใจไม่ทำก่อน

ตาราง data handling ที่ไม่ทำ preprocessing เลยนอกจากเก็บ NaN ไว้

ปกติเวลาเจอ dataset เราจะเติมค่าว่าง ตัด outlier ทำ scale ทำ one hot กันเป็นนิสัย แต่งานนี้ผมไม่ทำเลยสักอย่าง เพราะร่องรอยของ attack มันซ่อนอยู่ในของแปลกพอดี ถ้าเราเติมค่าว่างหรือตัด outlier ทิ้ง เท่ากับลบหลักฐานทิ้งด้วยมือตัวเอง

แล้ว NaN ในไฟล์นี้ก็ไม่ใช่ข้อมูลหายด้วย ถ้าไปดูสัดส่วนจะเห็นว่า mqtt.msgtype ว่างอยู่ 62.9% ซึ่งไม่ได้แปลว่าเก็บข้อมูลไม่ครบ แต่แปลว่า packet นั้นเป็น TCP เปล่าที่ไม่มีชั้น MQTT อยู่เลย เช่น ACK ที่ตอบรับกันไปมา ส่วน field กลุ่ม mqtt.conflag ว่าง 96.0% เพราะ field พวกนี้มีเฉพาะใน packet ชนิด CONNECT และ mqtt.conack.val ว่าง 96.3% เพราะมีเฉพาะใน CONNACK

พูดง่ายๆ คือค่าว่างบอกเราว่า packet ใบนั้นเป็นชนิดอะไร ซึ่งเป็นข้อมูลที่มีความหมายในตัวมันเอง ผมเลยเก็บ NaN ไว้ทั้งหมด

baseline ที่กรรมการให้มาใช้ Autoencoder แล้วทำ impute scale one hot ครบสูตร สุดท้ายได้ Private F1 0.65030 ซึ่งผมคิดว่าส่วนหนึ่งเป็นเพราะขั้นตอนทำความสะอาดนี่แหละที่กลบร่องรอยไป

ทำไมไม่ใช้ Random Forest

คำตอบคือ supervised classifier ต้องเห็นทั้งสอง class ถึงจะเรียนเส้นแบ่งได้ พอ train มี class เดียวก็ไม่มีอะไรให้เรียน และอีกอย่างคือผมยังคิดว่าตัวเองยังไม่เข้าใจความสามารถมันมากพอที่จะนำมาประยุกต์ใช้ในงานครั้งนี้ได้

ผมเลยเปลี่ยนมุมจากการแยก attack ออกจาก Normal มาเป็นการจำ pattern ของ Normal ให้แม่นที่สุด แล้วอะไรที่ไม่เข้าพวกก็ถือว่าผิดปกติ วิธีนี้เรียกว่า one class anomaly detection และผมเลือกทำแบบ rule based ที่ตรวจสอบย้อนกลับได้ทุกบรรทัด แทนการใช้โมเดลที่บอกไม่ได้ว่าตัดสินจากอะไร

ด่านแรก ค่าที่ไม่เคยปรากฏใน train

field หลายตัวในข้อมูลชุดนี้เป็นค่าไม่ต่อเนื่อง มันวนใช้ค่าเดิมซ้ำๆ ตาม protocol และตามอุปกรณ์ที่ใช้ ผมเลยเก็บ set ของค่าที่เคยปรากฏใน X_train ของแต่ละ field ไว้ รวม 10 field แล้วเอาแต่ละ row ใน X_test มาเทียบ ถ้ามี field ไหนที่ค่าไม่ว่างและไม่เคยอยู่ใน set นั้นเลยแม้แต่ field เดียว ผม flag row นั้นเป็น attack

จำนวน row ที่มีค่าไม่เคยปรากฏใน train แยกตาม field

ที่ผมจงใจไม่เอาเข้ามาคือ tcp.analysis.initial_rtt เพราะเป็นค่าต่อเนื่องที่มี noise การเทียบว่าเคยเห็นเป๊ะๆ กับตัวเลขแบบนี้ไม่มีความหมาย

ด่านเดียวนี้ flag ได้ 2,644 row จากหมื่น row และเมื่อเอาไป submit ได้ Public F1 0.93269 ตั้งแต่ยังไม่ทำอะไรซับซ้อนเลย กลายเป็นฐานที่มั่นให้ผมต่อยอด

ข้อมูลบอกว่า attack หน้าตายังไง

พอไล่ดูว่าค่าที่ไม่เคยเห็นมันคือค่าอะไรบ้าง ภาพของการโจมตีก็เริ่มชัดขึ้น ผมขอเล่าเป็นตัวอย่างเพราะมันสนุกกว่าบอกแค่ตัวเลข

เรื่องแรกคือ mqtt.kalive ซึ่งเป็นค่า keepalive ที่อุปกรณ์บอก broker ตอน CONNECT ว่าจะทักกันทุกกี่วินาที ใน X_train ทั้ง 2,485 CONNECT มีค่าเดียวคือ 15 แต่ใน X_test มี 15 อยู่ 223 row มี 60 อยู่ 121 row และมี 3600 อยู่ 54 row ค่า 60 เป็น default ของ MQTT client library หลายตัว ส่วน 3600 คือหนึ่งชั่วโมงซึ่งไม่ใช่ค่าที่อุปกรณ์ในระบบนี้ใช้ แปลว่ามีคนเอา client คนละตัวมาเชื่อมต่อเข้ามา

เรื่องที่สองคือ mqtt.username_len และ mqtt.passwd_len ใน train ความยาว username เกาะอยู่ที่ 29 เป็นหลัก แต่ใน test มีความยาว 4 กับ 5 โผล่มา 54 row ซึ่งเป็น pattern ที่เข้ากับการลองเดา credential มากกว่าอุปกรณ์จริงที่ตั้งค่าไว้แล้ว

เรื่องที่สามคือ mqtt.topic_len ใน train ยาวสุดที่ 42 แต่ใน test มีตั้งแต่ 76 ถึง 97 อยู่ 111 row ซึ่งเป็นชื่อ topic ที่ยาวผิดปกติ

เรื่องที่สี่คือ msgtype 8 กับ 9 คือ SUBSCRIBE และ SUBACK ไม่มีอยู่ใน train เลยแม้แต่ row เดียว ทุก row ใน test ที่เป็นสองชนิดนี้จึงโดน flag อัตโนมัติ

ตรงนี้ผมขอพูดตรงๆ ว่านี่คือจุดอ่อนของวิธีด้วย เพราะการที่ train ไม่มี SUBSCRIBE อาจจะแปลว่าตอนเก็บข้อมูลไม่มีใคร subscribe พอดี ไม่ได้แปลว่า subscribe เป็นเรื่องผิดเสมอไป ถ้าเอา rule นี้ไปใช้กับ capture ชุดอื่นก็มีสิทธิ์ flag ผิดได้

Fingerprint ของ TCP stack

อย่างที่บอกไปว่า window_size เป็นค่าที่ขึ้นกับเครื่อง พอเอามาไล่ดูก็เห็นเส้นแบ่งชัดมาก

ค่า window ที่พบใน train เทียบกับค่าที่โผล่เฉพาะใน test และเคส PINGRESP window 253

ใน X_train ค่า window วนอยู่ 3 กลุ่มคือ 250 ถึง 256 ซึ่งเป็น receive window เล็กของ broker กลุ่ม 5717 ถึง 5760 ซึ่งเป็นฝั่ง IoT device และกลุ่ม 63188 ขึ้นไปซึ่งเป็นฝั่ง broker ตอนรับข้อมูลปกติ จุดสังเกตคือค่าพวกนี้ขยับขึ้นลงทีละนิดตามการไหลของข้อมูล แต่วนอยู่ในกลุ่มเดิมเสมอ

ส่วนค่าที่โผล่เฉพาะใน X_test คือ 0, 63, 512, 64240, 64256, 64512, 64768, 65024, 65280, 65392, 65535 และ 65536 สังเกตว่ามันเป็นเลขกลมๆ แบบ 512 หรือ 65535 ซึ่งเป็นค่าตั้งต้นของ stack คนละตัว ไม่ใช่ค่าที่ไต่ขึ้นมาจากการคุยกันจริง

เคสที่ตัดสินยากที่สุดในงานนี้คือ PINGRESP ที่ window 253 PINGRESP คือ msgtype 13 ที่ broker ตอบกลับว่ายังออนไลน์อยู่ เป็นพฤติกรรม keepalive ปกติ ไม่ใช่ attack โดยตัวมันเอง แต่ค่า 253 ไม่เคยปรากฏใน train มันเลยโดน flag ไปตั้งแต่ด่านแรก

พอกลับไปดู train จริงๆ PINGRESP ในกลุ่ม window เล็กมีแค่ 3 ค่าคือ 254 อยู่ 84 row 255 อยู่ 128 row และ 256 อยู่ 41 row การที่ test มี 253 จึงอธิบายเป็น receive window ที่ขยับลงมา 1 ได้สมเหตุสมผลกว่าอธิบายว่าเป็น attack ผมเลยถอน 5 row นี้ออกจากคำตอบ

จุดพลิกของงานนี้ normal session skeleton

พอมองข้อมูลนานเข้า ผมเริ่มเห็น pattern ที่ทำให้ขนลุกนิดหน่อย

ทุก TCP stream ที่มี attack ปนอยู่ เมื่อผมลอก packet ที่ flag ไว้ออกให้หมด สิ่งที่เหลือเป็นชุดเดิมเป๊ะทุกครั้ง คือ 9 packet ที่เรียงกันเป็น session ปกติของอุปกรณ์ตัวหนึ่งคุยกับ broker

โครง 9 packet ของ session ปกติ และ stream ที่มี attack ปนอยู่

3 packet แรกคือ TCP handshake ต่อด้วย CONNECT และ CONNACK ของ MQTT ที่เหลือเป็น ACK ตอบรับ pattern นี้ซ้ำอยู่ใน 214 stream และ window ของสองฝั่งก็แยกกลุ่มกันชัดเจน ฝั่ง device เกาะอยู่แถว 5755 ถึง 5760 ฝั่ง broker เกาะอยู่แถว 64523 กับ 64620

ถ้าเขียนออกมาเป็นลำดับการคุยกันจริงๆ จะได้หน้าตาแบบนี้

sequenceDiagram
    participant D as IoT device
    participant B as MQTT broker
    D->>B: 1  SYN  win 5760
    B->>D: 2  SYN ACK  win 64620
    D->>B: 3  ACK  win 5760
    D->>B: 4  CONNECT  len 151
    B->>D: 5  CONNACK  win 64523
    D->>B: 6  ACK  win 5755
    B->>D: 7  ACK  win 64523
    D->>B: 8  ACK  win 5755
    B->>D: 9  ACK  win 64523

พอเจอแบบนี้ มันเปลี่ยนวิธีคิดของผมไปเลย จากเดิมที่ตามหาว่า packet ไหนแปลก กลายเป็นว่าผมรู้แล้วว่า session ปกติหน้าตายังไง เพราะฉะนั้นอะไรที่เกินออกมาจาก skeleton นี้ใน stream ที่มี attack หนาแน่น ก็คือส่วนเกินที่ต้อง flag

ข้อดีอีกอย่างที่ผมชอบมากคือ skeleton เป็นตัวตรวจสอบตัวเองไปในตัว ถ้าผมตั้งเงื่อนไขผิด พอลอกส่วนเกินออกแล้วมันจะเหลือไม่ครบ 9 packet หรือเหลือเกิน เห็นปุ๊บก็รู้ทันทีว่ายังไม่ถูก ไม่ต้องรอคะแนนจาก leaderboard มาบอก

ลำดับเงื่อนไข

พอรวมทุกอย่างเข้าด้วยกัน ตรรกะสุดท้ายเรียงเป็นด่านตรวจที่ packet แต่ละใบต้องเดินผ่านตามลำดับ

ด่านตรวจ 5 ชั้นที่ packet ต้องเดินผ่านตามลำดับ

ด่าน 1 เช็ค 10 field พร้อมกันเทียบกับค่าที่พบใน Normal train field เดียวแปลกก็ flag

ด่าน 2 ชุดค่ารวมทั้ง row ไม่เคยเห็น และอยู่ใน stream ที่ attack ตั้งแต่ 10% ขึ้นไป

ด่าน 3 packet สั้น 54 หรือ 56 และมีชุดค่าแปลก

ด่าน 4 อยู่ใน stream ที่ attack ตั้งแต่ 50% ขึ้นไป และไม่ใช่ 1 ใน 9 ขั้นของ session ปกติ

ด่าน 5 เป็น broker ACK ที่ยาว 60 ไม่มี payload window 250 ถึง 256 แต่ไปโผล่ผิด stream

หลุดครบทุกด่านถึงจะตัดสินว่าเป็น Normal

flowchart LR
    P["packet เข้ามา 1 ใบ"] --> G1{"ด่าน 1<br/>ค่าที่ไม่เคยเห็นใน train"}
    G1 -- ใช่ --> A["attack  1"]
    G1 -- ไม่ --> G2{"ด่าน 2<br/>ชุดค่ารวมใหม่<br/>ใน stream ที่ attack หนาแน่น"}
    G2 -- ใช่ --> A
    G2 -- ไม่ --> G3{"ด่าน 3<br/>packet สั้น 54 หรือ 56<br/>และชุดค่าแปลก"}
    G3 -- ใช่ --> A
    G3 -- ไม่ --> G4{"ด่าน 4<br/>ส่วนเกินจาก session ปกติ<br/>ใน stream ที่ attack เกินครึ่ง"}
    G4 -- ใช่ --> A
    G4 -- ไม่ --> G5{"ด่าน 5<br/>broker ACK ที่อยู่ผิด stream"}
    G5 -- ใช่ --> A
    G5 -- ไม่ --> N["Normal  0"]

จุดที่ต้องเน้นคือ ด่าน 1 ดูพร้อมกันทีเดียว 10 field แต่ด่าน 2 ถึง 5 ต้องเรียงตามลำดับ เพราะแต่ละด่านใช้ผลของด่านก่อนหน้า เช่นการจะรู้ว่า stream นี้ attack เกิน 50% ไหม ต้องรู้ก่อนว่าด่าน 1 ถึง 3 flag อะไรไปแล้วบ้าง จึงสลับลำดับไม่ได้

อีกจุดคือยิ่งลึกเงื่อนไขยิ่ง strict มี and หลายตัวขึ้นเรื่อยๆ เพื่อลดโอกาสการ flag ผิด ด่านแรกยอมกว้างได้เพราะหลักฐานตรง ส่วนด่านหลังเป็นการเก็บของที่เหลือซึ่งเสี่ยงกว่า จึงต้องเข้าเงื่อนไขพร้อมกันหลายข้อ

หลังจากด่านพวกนี้ผมยังมีขั้นเก็บงานเล็กๆ อีกชั้นที่เพิ่มกับถอน row รวมกันไม่ถึง 10 row ซึ่งรวมถึงการถอน PINGRESP 5 row ที่เล่าไปข้างต้น

Thresholds มาจากไหน

ค่าคงที่ทั้งหมดในโค้ด ทั้ง MIN_DENSE_STREAM_RATIO ที่ 0.10, MIN_SESSION_ATTACK_RATIO ที่ 0.50, SHORT_FRAME_LENGTHS ที่ 54 กับ 56, SMALL_WINDOW ที่ 250 ถึง 256 และ SKELETON_ACK_WINDOWS ผมขอบอกตรงๆ ว่าไม่ใช่ค่ามาตรฐานของ TCP หรือ MQTT และผมไม่ได้พิสูจน์ว่าเป็นค่าที่ optimal

ที่มาของ threshold ทั้งสามทาง และช่วงค่าที่ให้ผลเหมือนกันหมด

มันมาจาก 3 ทางร่วมกันคือ distribution ของ Normal train, การทำ EDA บน test ที่ไม่มี label และการทดลองบน public leaderboard โดยใช้ protocol semantics ช่วยจำกัดเงื่อนไขว่าเงื่อนไขไหนพออธิบายได้บ้าง

สิ่งที่พอช่วยยืนยันได้คือแต่ละค่าตกอยู่ในช่องว่างที่กว้างมากของข้อมูล ค่า 10% ใช้ได้ทั้งช่วง 4.4% ถึง 10.5% และค่า 50% ใช้ได้ทั้งช่วง 11% ถึง 67% เปลี่ยนไปมาในช่วงนั้นผลออกมาเหมือนกันหมด มันเลยเป็นเลขกลมที่อธิบายได้ ไม่ใช่เลขที่จูนเป๊ะให้คะแนนขึ้น

กว่าจะมาถึงตรงนี้ ก็ลองผิดลองถูกอยู่พอสมควร

ผมส่งไปทั้งหมด 10 ครั้ง และไม่ได้ดีขึ้นทุกครั้ง

รอบแรกๆ ผม flag จากชุดค่ารวมทั้ง row เลยว่าถ้า combination ไม่เคยเห็นก็เป็น attack ผลคือกว้างเกินไป ทราฟฟิกปกติเองก็สร้าง combination ใหม่ได้เรื่อยๆ

อีกรอบผมคิดว่าถ้า stream ไหนมี attack อยู่ ก็น่าจะเหมาทั้ง stream เป็น attack ไปเลย รอบนั้นคะแนนร่วงหนักที่สุด แต่ได้ความรู้ชิ้นใหญ่ว่า TCP stream ไม่ใช่ label ผู้โจมตีเข้ามาปะปนกับ packet ปกติใน stream เดียวกันได้

แล้วก็มีอีกสองรอบที่ผมส่งไปทั้งที่รู้ว่าคะแนนอาจตก เพราะตอนนั้นมีกลุ่ม row ที่สงสัยอยู่แต่ไม่มีทางรู้คำตอบ ผมเลยลองถอนกลุ่มหนึ่งออก และลองเพิ่มอีกกลุ่มเข้าไป ถ้าคะแนนตกก็แปลว่าสมมติฐานผิดทาง ทั้งสองรอบคะแนนตกจริง แต่ผมได้คำตอบที่ทำให้เดินต่อถูกทาง

บทเรียนที่ผมชอบที่สุดของงานนี้คือ การทดลองที่คะแนนตกไม่ใช่การเสียเวลา ขอแค่รู้ล่วงหน้าว่ากำลังทดสอบสมมติฐานอะไรอยู่ และเปลี่ยนทีละอย่างเท่านั้น

ต้องบอกด้วยว่าวิธีนี้ใช้ได้เพราะเป็นสนามแข่งที่มี leaderboard ให้ยิงถาม ในงานจริงเราไม่มีอะไรแบบนี้ ผมเลยไม่กล้าเคลมว่าเป็นวิธีที่ถูกต้องสำหรับทุกสถานการณ์

ผลลัพธ์

เวอร์ชันสุดท้ายทายว่าเป็น attack 2,871 row จาก 10,000 row ได้ Public F1 0.97434 และ Private F1 0.98149 ขึ้นอันดับ 1 บน Private leaderboard ตอนที่ประกาศคะแนน เทียบกับ baseline Autoencoder ของกรรมการที่ได้ Private 0.65030

จุดที่ผมสบายใจที่สุดไม่ใช่เลขสุดท้าย แต่เป็นเรื่องที่ว่าคะแนน Private สูงกว่า Public ทั้ง 10 ครั้งที่ส่ง ไม่มียกเว้นเลยสักครั้ง ถ้าผมจูน rule ให้เข้ากับ public split จนเกินพอดี Private ควรจะแย่กว่า แต่มันไม่เป็นแบบนั้น

อีกอย่างที่ผมให้ความสำคัญคือ reproducibility หลังแข่งจบผมรวมทุกอย่างเป็นไฟล์เดียวที่ไม่อ่าน submission เก่า ไม่ใช้ Id ในการทาย และไม่ fix หมายเลข stream ไว้ stream ทั้งหมดหาใหม่จากข้อมูลเอง รันแล้วได้ผลตรงกับไฟล์ที่ส่งจริงทุก row ต่างกัน 0 จาก 10,000 row และไม่มีการสุ่มเลยสักจุด รันกี่ครั้งก็ได้ผลเดิม

ข้อจำกัดที่ต้องพูดตรงๆ

ผมไม่อยากให้บทความนี้อ่านแล้วดูสวยเกินจริง เลยขอเขียนข้อจำกัดไว้ด้วย

ข้อแรก ยังไม่พิสูจน์ข้าม capture ข้อมูลมาจากการเก็บชุดเดียว skeleton 9 packet และค่า window ที่ผมใช้เป็น Fingerprint อาจจะเป็นของ capture ชุดนี้โดยเฉพาะ ถ้าเอาไปใช้กับอุปกรณ์รุ่นอื่นหรือเครือข่ายอื่นต้องทดสอบใหม่ก่อนเสมอ

ข้อสอง อุปกรณ์ใหม่ที่มีค่าปกติแปลกไปจากเดิมมีสิทธิ์โดน flag ผิดกลายเป็น false positive เพราะ rule ทั้งหมดตัดสินจากสิ่งที่ไม่เคยเห็น ของใหม่ที่ไม่เคยเห็นก็เข้าเงื่อนไขเดียวกัน

ข้อสาม และข้อนี้สำคัญที่สุด คะแนน Private พิสูจน์ผลได้แค่ระดับกลุ่มของ rule ไม่สามารถยืนยัน label จริงราย row ได้ เพราะตั้งแต่ต้นจนจบผมไม่เคยเห็น label ราย row เลยสักครั้ง สิ่งที่ผมพูดได้คือกลุ่มของ rule นี้ทำให้ F1 สูงขึ้น ไม่ใช่ว่า row ที่ 1234 เป็น attack จริงหรือไม่

เรื่องการใช้ AI ก็เหมือนกัน

งานนี้ผมใช้ AI เป็นผู้ช่วยวิเคราะห์และช่วยเขียนโค้ดค่อนข้างเยอะ ผมเป็นคนป้อน dataset ป้อนกติกา ป้อนผล leaderboard ของแต่ละการทดลอง และอธิบายว่าเราจะไม่ใช้ Random Forest รวมถึงการอธิบายเรื่อง rule based เพราะมันมี MQTT, TCP และ pattern attack ที่โจทย์บอกมา

ผมจึงปรึกษาว่ามันอาจจะไม่ต้องให้มันเรียนรู้อะไรมากเพราะไม่มี label และพวก correlation ของข้อมูลผมมองว่าเราสามารถใช้ข้อมูลของ packet ได้เลยเพราะเราพอจะมีพื้นฐานด้าน network มาบ้างแล้ว เลยคิดว่าน่าจะเป็นทางเลือกที่ดีกว่า ML รายละเอียดพวกนี้ผมเป็นคนเล่าให้ตัว AI ฟังว่าผมมีความคิดแบบนี้ๆ คุณคิดว่ายังไงหรือมันทำแบบไหนได้บ้าง และผมเป็นคนตัดสินใจเองว่าจะส่งเวอร์ชันไหน

หลังแข่งจบผมถึงมานั่งทำ single file audit แล้วไล่อ่านทีละบรรทัดเอง เพื่อให้ตัวเองรับผิดชอบทุกอย่างที่นำเสนอได้จริง ผมไม่เคลมว่าเขียนเองทุกบรรทัดเพราะการ coding ผมให้ AI จัดการหมด ผมแค่ดูว่าสิ่งที่มันพูดสอดคล้องกับโค้ดรึป่าว และผมไม่ได้ใช้ label ของ private หรือข้อมูลนอกกติกา

สิ่งที่ผมได้กลับมา

ข้อแรก เมื่อ train มี class เดียว อย่าฝืนใช้ supervised model ให้กลับไปเรียน pattern ของสิ่งปกติแทน แล้วปล่อยให้ของแปลกโผล่มาเอง

ข้อสอง ความรู้เรื่อง protocol มีค่ามากกว่าที่คิด ผมไม่ได้ชนะเพราะโมเดลดี แต่ชนะเพราะยอมนั่งอ่านว่า MQTT session ปกติหน้าตายังไง window_size มันคืออะไร แล้วเอาความรู้นั้นมาตั้งเป็น rule ถ้าผมโยนทุกอย่างเข้าโมเดลแล้วรอผลอย่างเดียว ผมจะไม่มีวันเจอ skeleton 9 packet นั้นเลย

ข้อสาม ก่อนจะไว้ใจคะแนนตัวเอง ต้องรู้ก่อนว่าข้อมูลที่มีอยู่พิสูจน์อะไรได้บ้างและพิสูจน์อะไรไม่ได้ งานนี้ผมได้คะแนนดี แต่ผมยืนยัน label ราย row ไม่ได้เลย ซึ่งเป็นคนละเรื่องกัน

ตอนเริ่มผมก็ไม่ได้มั่นใจเลยว่าวิธีที่ไม่ใช้ ML จะไปได้ไกลขนาดนี้ ที่ทำไปเรื่อยๆ เพราะสงสัยว่าข้อมูลมันบอกอะไรเราอยู่มากกว่าจะคิดถึงอันดับ พอผลออกมาแบบนี้ก็ดีใจมากครับ

ขอบคุณทุกคนที่อ่านจนจบครับ

แชร์บทความนี้

สารบัญ

  • ข้อมูลมีแค่นี้ และมันจำกัดกว่าที่คิด
  • TCP ทำงานยังไง
  • MQTT ทำงานยังไง
  • ค่าว่างในไฟล์นี้ไม่ใช่ข้อมูลหายเสมอไปนะครับ อาจจะต้องเช็คดูดีๆ
  • ทำไมไม่ใช้ Random Forest
  • ด่านแรก ค่าที่ไม่เคยปรากฏใน train
  • ข้อมูลบอกว่า attack หน้าตายังไง
  • Fingerprint ของ TCP stack
  • จุดพลิกของงานนี้ normal session skeleton
  • ลำดับเงื่อนไข
  • Thresholds มาจากไหน
  • กว่าจะมาถึงตรงนี้ ก็ลองผิดลองถูกอยู่พอสมควร
  • ผลลัพธ์
  • ข้อจำกัดที่ต้องพูดตรงๆ
  • เรื่องการใช้ AI ก็เหมือนกัน
  • สิ่งที่ผมได้กลับมา

บทความแนะนำ

Game Interface - BadDesign Simulator

เกมจำลองการเอาชีวิตรอดจากหน้าจอ UI/UX ที่ออกแบบมาอย่างเลวร้าย

อ่านต่อ

Heuristic Evaluation ของแอป Fitness iOS

การประเมินแอปพลิเคชันด้วยหลักการ NEILSEN'S 10 HEURISTICS

อ่านต่อ