NetKubeLab EN

การตรวจจับข้อผิดพลาด — checksum ที่จงใจให้อ่อน

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

บทความก่อนหน้าพูดถึง checksum มาแล้วสี่ครั้ง — Ethernet มีช่องตรวจท้ายเฟรม IP มี checksum ที่คุ้มเฉพาะส่วนหัว TCP และ UDP มี checksum ที่คุ้มทั้งเซกเมนต์ และ บทเรื่องชั้น ใช้ checksum ของ TCP เป็นหลักฐาน ว่าชั้นรั่ว

แต่ยังไม่มีบทไหนตอบคำถามที่ควรถามก่อน — มันจับอะไรได้จริง และจับอะไรไม่ได้

คำตอบสั้น ๆ คือ checksum ของอินเทอร์เน็ตอ่อนกว่าที่คนคิดมาก มันจับความ เสียหายบางแบบไม่ได้เลย ไม่ใช่บางครั้ง แต่ไม่ได้เลย ทุกครั้ง และนั่นไม่ใช่ บั๊ก มันคือผลของคุณสมบัติที่เอกสารออกแบบเมื่อปี 1978 ลิสต์ไว้เป็นข้อดี

บทความนี้จะพิสูจน์ด้วยการรันจริงสองแสนครั้ง

ถ้ายังไม่เคยดู เริ่มตรงนี้

checksum ของอินเทอร์เน็ตคำนวณเองได้ในไม่กี่บรรทัด ไม่ต้องมีไลบรารีอะไร

$ python3 -c "
import struct
def ck(b):
    if len(b)%2: b += b'\0'
    s = sum(struct.unpack('!%dH' % (len(b)//2), b))
    while s >> 16: s = (s & 0xffff) + (s >> 16)
    return (~s) & 0xffff
print(hex(ck(b'HELLO WORLD!')))"

ได้ 0x2e91 — จับกลุ่มไบต์ทีละสองไบต์ บวกกันทั้งหมด เอาตัวทดวนกลับมาบวก ซ้ำ แล้วกลับบิตทั้งหมด จบ

ความเรียบง่ายนี้คือทั้งจุดแข็งและจุดอ่อนของมัน

ความเสียหายเกิดได้หลายแบบ ไม่ใช่แบบเดียว

เวลาพูดถึงข้อมูลเสีย คนมักนึกถึงบิตเดียวที่พลิก แต่บนสายจริงความเสียหาย มีหลายรูปแบบ และแต่ละแบบท้าทายวิธีตรวจต่างกัน

  single-bit    บิตเดียวพลิก
  burst         บิตติดกันหลายตัวเสียพร้อมกัน เพราะสัญญาณรบกวนชั่วขณะ
  reorder       ข้อมูลมาถึงสลับลำดับ
  duplicate     ข้อมูลชิ้นเดิมมาถึงสองครั้ง
  truncate      ข้อมูลขาดหาย

วิธีตรวจที่ดีกับแบบหนึ่งอาจใช้ไม่ได้เลยกับอีกแบบ ซึ่งเป็นประเด็นทั้งหมด ของบทความนี้

parity — ง่ายที่สุด และพังง่ายที่สุด

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

บิตเดียวพลิกทำให้จำนวนบิตหนึ่งเปลี่ยนจากคู่เป็นคี่ parity จึงจับได้ แต่สองบิตพลิกทำให้กลับมาเป็นคู่เหมือนเดิม parity จึงจับไม่ได้

ข้อจำกัดของมันตามมาจากนิยามตรง ๆ — มันจับได้เฉพาะเมื่อจำนวนบิตที่พลิก เป็นเลขคี่ พลิกสองบิต จำนวนรวมกลับมาเป็นคู่ และ parity บอกว่าปกติดี

ผมรันจริงสองแสนครั้ง

  flip 1 bit    จับได้ทุกครั้ง
  flip 2 bits   จับไม่ได้เลยสักครั้ง
  flip 3 bits   จับได้ทุกครั้ง
  flip 4 bits   จับไม่ได้เลยสักครั้ง

ไม่ใช่ 99% หรือ 1% แต่เป็น 0% กับ 100% สลับกันไปตามคู่หรือคี่ ซึ่งเป็น พฤติกรรมที่ทำนายได้สมบูรณ์ และแย่มากถ้าความเสียหายจริงมักมาเป็นชุด

checksum ของอินเทอร์เน็ต

RFC 1071 เดือนกันยายน 1988 ชื่อ Computing the Internet Checksum อธิบายวิธี คิดและเหตุผลเบื้องหลัง มันใช้การบวกแบบคอมพลีเมนต์หนึ่ง ซึ่งต่างจากการบวก ธรรมดาตรงที่ตัวทดถูกวนกลับมาบวกที่บิตต่ำสุด

เอกสารอธิบายว่าทำไมถึงเลือกแบบนี้แทน XOR

"One's complement arithmetic is better than two's complement because it is equally sensitive to errors in all bit positions."

"It is just this property that makes some sort of addition preferable to a simple exclusive-OR which is frequently used but permits an even number of drops (pick ups) in any bit channel."

XOR มีปัญหาเดียวกับ parity คือความเสียหายที่เป็นเลขคู่หักล้างกันเอง การบวก แก้ปัญหานั้นได้ แต่แลกมาด้วยปัญหาใหม่

คุณสมบัติสี่ข้อ ที่ข้อแรกคือจุดอ่อน

ภาคผนวกของ RFC 1071 คือเอกสารเก่ากว่านั้นชื่อ IEN 45 ลงวันที่ 5 มิถุนายน 1978 เขียนโดย William W. Plummer ซึ่งลิสต์คุณสมบัติที่ตัวดำเนินการบวกต้องมี ไว้สี่ข้อ ข้อแรกคือ

"(P1) + is commutative. Thus, the order in which the 16-bit bytes are 'added' together is unimportant."

อ่านอีกรอบช้า ๆ ลำดับของเวิร์ดไม่สำคัญ — เขียนไว้ในฐานะข้อดี เพราะทำให้ ผู้ผลิตเลือกอ่านหน่วยความจำทางไหนก็ได้ ทำให้คำนวณได้เร็วบนเครื่องหลายแบบ

แต่ประโยคเดียวกันนั้นมีความหมายอีกอย่าง — ถ้าลำดับไม่สำคัญต่อการคำนวณ การสลับลำดับก็ตรวจไม่ได้

ข้ออื่นตามมาด้วยเหตุผลแบบเดียวกัน

  P2   มีสมาชิกเอกลักษณ์ ผู้ส่งใส่ศูนย์ในช่องแล้วคำนวณได้เลย
  P3   มีตัวผกผัน ผู้รับคำนวณแล้วคาดหวังผลเป็นศูนย์ได้
  P4   มีสมบัติการเปลี่ยนหมู่ ช่อง checksum วางตรงไหนก็ได้

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

ทดลอง — อะไรจับได้ อะไรจับไม่ได้

ผมเอาข้อความหนึ่งประโยคมาทำให้เสียสี่แบบ แล้ววัดว่าทั้งสามวิธีจับได้ไหม ok คือจับได้ ส่วน MISS คือจับไม่ได้

ตารางเทียบ parity checksum และ CRC-32 กับความเสียหายสี่แบบ แสดงว่าการสลับเวิร์ดสิบหกบิตมีเพียง CRC เท่านั้นที่จับได้

  corruption                 parity  checksum  CRC-32
  flip 1 bit                 ok      ok        ok
  flip 2 bits in one byte    MISS    ok        ok
  swap two 16-bit words      MISS    MISS      ok
  swap word 1 and word 5     MISS    MISS      ok

สองแถวล่างคือผลของ P1 โดยตรง

ลองนึกถึงความหมายในทางปฏิบัติ ถ้าข้อความคือคำสั่งโอนเงินที่มีเลขบัญชี และจำนวนเงินอยู่คนละเวิร์ด การสลับสองเวิร์ดนั้นเปลี่ยนความหมายทั้งหมด แต่ checksum ยังบอกว่าปกติดีทุกครั้ง

3.1% ที่ไม่ใช่ความบังเอิญ

รันจริงสองแสนครั้งบนข้อความสุ่มยาว 64 ไบต์ นับเฉพาะครั้งที่ตรวจไม่พบ

  corruption       parity            checksum         CRC-32
  flip 1 bit             0 (  0.0%)       0 (  0.0%)        0
  flip 2 bits      200,000 (100.0%)   6,282 (  3.1%)        0
  flip 3 bits            0 (  0.0%)     590 (  0.3%)        0
  flip 4 bits      200,000 (100.0%)     651 (  0.3%)        0
  swap two words   199,995 (100.0%) 199,995 (100.0%)        0

ตัวเลข 3.1% ตรงแถวพลิกสองบิตน่าสนใจที่สุด เพราะถ้าความเสียหายกระจายแบบสุ่ม ล้วน checksum 16 บิตควรพลาดราว 1 ใน 65,536 หรือ 0.0015% สิ่งที่วัดได้ แย่กว่าค่านั้นสองพันเท่า

ผมไล่หากลไก แล้วพบว่ามันอธิบายได้หมด

บิตที่ตำแหน่งเดียวกันของสองเวิร์ด ตัวหนึ่งพลิกจากศูนย์เป็นหนึ่งเพิ่มค่า อีกตัวพลิกจากหนึ่งเป็นศูนย์ลดค่าเท่ากัน ผลรวมจึงไม่เปลี่ยน

ถ้าบิตที่พลิกทั้งสองอยู่ตำแหน่งเดียวกันภายในเวิร์ด 16 บิต และพลิก คนละทิศ ตัวหนึ่งจะเพิ่มค่า 2^k อีกตัวจะลดค่า 2^k เท่ากันพอดี ผลรวมไม่เปลี่ยน checksum จึงไม่เปลี่ยน

โอกาสที่จะเป็นแบบนั้นคำนวณได้ตรง ๆ

  1 in 16    ตำแหน่งบิตตรงกัน
  1 in 2     พลิกคนละทิศ
  1 in 32    รวมแล้วเท่ากับ 3.1%

และเมื่อนับจากการทดลอง จำนวนครั้งที่เข้าเงื่อนไขนี้คือ 6,117 ครั้ง ส่วน จำนวนครั้งที่ checksum จับไม่ได้คือ 6,117 ครั้งเท่ากันพอดี ไม่ใช่ใกล้เคียง แต่เท่ากันทุกกรณี

CRC — วิธีที่แข็งกว่า และอยู่ชั้นล่าง

อีเทอร์เน็ตไม่ได้ใช้ checksum แบบบวก มันใช้ CRC-32 ซึ่งคิดจากการหารพหุนาม ในเลขฐานสอง ไม่ใช่การบวก จึงไม่มีคุณสมบัติสลับที่

จากตารางเดียวกัน CRC-32 จับได้ทุกกรณีที่ทดลอง — หนึ่งล้านครั้ง ไม่พลาด สักครั้ง

แผนภาพแสดงว่า CRC-32 อยู่ท้ายเฟรมอีเทอร์เน็ตและคุ้มทั้งเฟรม ส่วน checksum ของ IP คุ้มเฉพาะส่วนหัว และของ TCP คุ้มส่วนหัวกับข้อมูล

การที่แต่ละชั้นเลือกวิธีต่างกันไม่ใช่ความไม่ลงรอย แต่เป็นการแลกเปลี่ยนที่ ต่างกัน

  Ethernet   CRC-32       whole frame   ฮาร์ดแวร์คิดให้ ไม่กินซีพียู
  IP         16-bit sum   header only   เราเตอร์ต้องคิดใหม่ทุกฮอป
  TCP        16-bit sum   header+data   ซอฟต์แวร์คิด จึงต้องเร็ว

ทำไมถึงยอมใช้ของอ่อนที่ชั้นบน

คำตอบอยู่ในคุณสมบัติ P2 กับ P4 ที่ยกมาข้างบน — เพราะ เราเตอร์ต้องแก้ ส่วนหัวทุกฮอป

ทุกครั้งที่แพ็กเก็ตผ่านเราเตอร์ ค่า TTL ลดลงหนึ่ง ซึ่งแปลว่า checksum ของ IP ต้องคำนวณใหม่ ถ้าใช้ CRC-32 เราเตอร์จะต้องอ่านทั้งแพ็กเก็ตแล้วคิดใหม่ ทุกครั้ง ในขณะที่การบวกแบบนี้แก้เฉพาะส่วนที่เปลี่ยนได้ โดยไม่ต้องแตะไบต์อื่น

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

RFC 1071 ยังระบุคุณสมบัติอีกข้อที่อธิบายว่าทำไมต้องเป็นแบบนี้

"(P6) Adding the checksum to a packet does not change the information bytes."

"This property allows intermediate computers such as gateway machines to act on fields (i.e., the Internet Destination Address) without having to first decode the packet."

เราเตอร์อ่านที่อยู่ปลายทางได้เลยโดยไม่ต้องถอดรหัสอะไรก่อน ซึ่งเป็นสิ่งที่ รหัสแก้ไขข้อผิดพลาดบางชนิดทำไม่ได้

ศูนย์สองตัว และบั๊กที่อยู่ใน RFC สี่ปี

เลขคอมพลีเมนต์หนึ่งมีเรื่องแปลกอยู่อย่างหนึ่ง — มันมีศูนย์สองตัว

  +0   0x0000
  -0   0xFFFF

ทั้งคู่แทนค่าศูนย์ และความแตกต่างนี้ทำให้เกิดบั๊กที่อยู่ในเอกสารมาตรฐาน นานสี่ปี

RFC 1141 ปี 1990 เสนอสูตรลัดให้เราเตอร์แก้ checksum แบบไม่ต้องคำนวณใหม่ ทั้งหมด สูตรนั้นใช้ได้เกือบทุกกรณี ยกเว้นตอนผลลัพธ์เป็นศูนย์ RFC 1624 เดือนพฤษภาคม 1994 จึงออกมาแก้ และอธิบายสาเหตุไว้ว่า

"RFC 1141 yields an updated header checksum of -0 when it should be +0. This is because it assumed that one's complement has a distributive property, which does not hold when the result is 0"

สูตรที่ดูถูกต้องผิดเฉพาะกรณีที่ผลเป็นศูนย์ และไม่มีใครสังเกตอยู่สี่ปี เพราะกรณีนั้นเกิดไม่บ่อย นี่คือลักษณะเฉพาะของบั๊กในเลขคณิต — มันไม่ได้พัง ตลอดเวลา มันรอ

เมื่อมันโกหก

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

"checksum 16 บิตพลาด 1 ใน 65,536" จริงเฉพาะเมื่อความเสียหายสุ่มล้วน ความเสียหายจริงมีโครงสร้าง และตัวเลขที่วัดได้ข้างบนคือ 3.1% ในบางกรณี

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

"CRC คือการเข้ารหัส" ไม่ใช่เลย ใครก็คำนวณ CRC ใหม่ได้ มันตรวจอุบัติเหตุ ไม่ได้ตรวจเจตนา ถ้าต้องกันคนที่ตั้งใจแก้ข้อมูล ต้องใช้ลายเซ็นตามที่ TLS อธิบาย

"checksum ของ UDP ปิดได้ ไม่เป็นไร" บน IPv4 ปิดได้จริง แต่บน IPv6 ปิดไม่ได้ เพราะ IPv6 ตัด checksum ของส่วนหัวออกไปแล้ว ถ้าชั้นขนส่งไม่ตรวจ ก็ไม่เหลือใครตรวจเลย

ตัวอย่างจริงจากงานจริง

กรณีที่ 1 — ข้อมูลเสียแต่ทุกอย่างบอกว่าปกติ

สถานการณ์ ไฟล์ที่คัดลอกผ่านเครือข่ายเสียเป็นครั้งคราว แต่ไม่มีตัวนับ ข้อผิดพลาดที่ไหนขึ้นเลย

อ่านอย่างไร ตัวนับที่สวิตช์นับเฉพาะเฟรมที่ CRC ไม่ผ่าน ถ้าความเสียหาย เกิดในหน่วยความจำของอุปกรณ์กลางทาง ไม่ใช่บนสาย เฟรมจะถูกคำนวณ CRC ใหม่ หลังเสียแล้ว ตัวนับจึงสะอาด และ checksum ของ TCP ก็มีโอกาสพลาดตามตัวเลข ข้างบน วิธีเดียวที่พิสูจน์ได้คือเทียบค่าแฮชของไฟล์ทั้งสองฝั่ง

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

กรณีที่ 2 — เลือกวิธีตรวจให้งานของตัวเอง

สถานการณ์ ต้องออกแบบโพรโทคอลของตัวเองสำหรับอุปกรณ์ฝังตัว

อ่านอย่างไร ถามสองคำถามก่อนเลือก

  1  ความเสียหายที่คาดว่าจะเจอเป็นแบบไหน
  2  มีใครต้องแก้ข้อมูลระหว่างทางไหม

ถ้าไม่มีใครแก้ระหว่างทาง ให้เลือก CRC เพราะแข็งกว่ามากและฮาร์ดแวร์ส่วนใหญ่ คิดให้ได้ ถ้ามีอุปกรณ์กลางทางต้องแก้บางฟิลด์ ผลบวกแบบ 16 บิตอาจคุ้มกว่า ด้วยเหตุผลเดียวกับที่ IP เลือกมัน

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

กรณีที่ 3 — พิสูจน์ว่า checksum ตาบอดจริง

สถานการณ์ ต้องอธิบายให้ทีมเข้าใจว่าทำไมต้องมีแฮชอีกชั้น

คำสั่ง ให้เขารันเอง สิบวินาทีจบ

$ python3 -c "
import struct
def ck(b):
    s = sum(struct.unpack('!%dH' % (len(b)//2), b))
    while s >> 16: s = (s & 0xffff) + (s >> 16)
    return (~s) & 0xffff
a = b'AABB'
b = b'BBAA'
print(a, hex(ck(a)))
print(b, hex(ck(b)))"

เอาต์พุตจริง

  b'AABB' 0x7c7c
  b'BBAA' 0x7c7c

อ่านอย่างไร ข้อมูลคนละชุด ได้ checksum ค่าเดียวกัน เพราะ P1 บอกไว้แล้ว ว่าลำดับไม่สำคัญ การเห็นด้วยตาตัวเองจบการถกเถียงได้เร็วกว่าการอธิบาย

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

เมื่อการตรวจจับไม่พอ

การตรวจจับบอกได้แค่ว่าเสีย ไม่ได้บอกว่าเสียตรงไหนและซ่อมให้ไม่ได้ ทางเลือก มีสองทาง — ขอส่งใหม่ ซึ่งเป็นสิ่งที่ TCP ทำ หรือใส่ข้อมูลซ้ำซ้อนมากพอให้ ปลายทางซ่อมเองได้ ซึ่งเป็นสิ่งที่การสื่อสารดาวเทียมและหน่วยความจำ ECC ทำ

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

อ้างอิง

มาตรฐาน

  • RFC 1071 กันยายน 1988 Computing the Internet Checksum — วิธีคิด และภาคผนวกที่เป็น IEN 45 ปี 1978 ซึ่งลิสต์คุณสมบัติ P1 ถึง P6 ที่ยกมาอ้างในบทความนี้
  • RFC 1141 มกราคม 1990 สูตรลัด สำหรับแก้ checksum ที่มีข้อผิดพลาด
  • RFC 1624 พฤษภาคม 1994 ที่แก้ ข้อผิดพลาดนั้น พร้อมคำอธิบายเรื่องศูนย์สองตัว
  • RFC 9293 ข้อกำหนดที่ระบุว่า checksum ของ TCP ปิดไม่ได้

อ่านหน้านี้เป็นภาษาอังกฤษ

← กลับไปหน้าความรู้พื้นฐาน