ล่าบั๊ก Go ที่ Fuzzing หาไม่เจอด้วย Gemini และ Z3 Solver

· By: TanasakP

12 พ.ย. 2026

ผมเป็นผู้นำทีม agent skills program ของ Google ซึ่งมีทักษะเอเจนต์ (agent skills) 175 รายการในคอลเลกชัน go-skills เพื่อดูแลไฟล์เหล่านี้ให้สอดคล้องกัน ผมจึงพัฒนา skill-lint ซึ่งเป็น Go CLI สำหรับตรวจสอบไฟล์ SKILL.md โดยใช้ LLM ช่วยแก้ไขก่อนส่งมอบ แม้จะมียูนิตเทสต์และ fuzz test แต่ข้อผิดพลาดเล็กน้อยใน Markdown parsers เกี่ยวกับ slice หรือเลขบรรทัดอาจแฝงตัวอยู่จนกว่าจะเกิด runtime panic ในตัว linter

ในภาษา Go การดำเนินการกับ slice หรือดัชนี (index) ที่เสี่ยงต่อการเกิด panic จะถูกคอมไพล์เป็น bounds-check trap ในรหัสเครื่องหลังจาก optimizer ตัดส่วนที่ไม่จำเป็นออกไป เพื่อตามล่าหาจุดแครช ผมได้คอมไพล์ฟังก์ชันสำคัญเพื่อดู panic traps ที่เหลืออยู่ แล้วใช้ Gemini 4 Argon แปลเส้นทางไปยังแต่ละ trap ให้เป็นกฎสำหรับ Z3 constraint solver

แม้ระบบที่เขียนโดย LLM จะไม่ใช่เครื่องมือพิสูจน์ที่สมบูรณ์แบบ แต่มันช่วยให้เอเจนต์มีวิธีวิเคราะห์ control flow และสร้างตัวจำลองสถานการณ์แครชในภาษา Go ได้จริง ทุกการแครชที่พบคือของจริงเพราะ go test จะรันไบต์ที่มีปัญหาโดยตรง กระบวนการนี้ตรวจพบบั๊กทั้ง 3 จุดที่ผมวางไว้ในตัวแยกวิเคราะห์ทดสอบ และพบบั๊กที่แฝงอยู่จริงอีก 3 จุดใน skill-lint

ในโพสต์นี้ ผมจะพาไปดูการทำงานของไปป์ไลน์ Gemini และ Z3 รวมถึงสาเหตุที่ go test -fuzz ตรวจไม่พบการแครชบางจุด และแนวทางการนำวิธีนี้ไปปรับใช้กับกระบวนการทำงาน Go ในชีวิตประจำวัน

โพสต์ล่าสุดจาก Anthropic แสดงให้เห็นความสำเร็จของ formal verification ใน C และ C++ โดยใช้ AI เขียนบทพิสูจน์ Rocq ผ่านไลบรารี OSS-Fuzz แทนการสุ่มอินพุตแบบเดิม แต่การพิสูจน์เต็มรูปแบบอาจหนักเกินไปสำหรับโปรเจกต์ Go ทั่วไป ผมจึงอยากลองดูว่าเอเจนต์จะล่าบั๊กด้วย LLM และ constraint solver ได้มากแค่ไหนในเวลาเพียงหนึ่งนาทีต่อฟังก์ชัน

ในภาษา Go การเข้าถึงนอกขอบเขตจะถูกตรวจจับในตอนรันไทม์ โดยคอมไพล์การทำงานกับ slice ที่ไม่ปลอดภัยให้กลายเป็น branch ไปยัง runtime.panicBounds ซึ่งจะทำให้โปรเซสแครชทันทีเว้นแต่จะมีการกู้คืนข้อมูล

ก่อนสร้างรหัสเครื่อง ขั้นตอน prove ของ SSA ใน Go จะกำจัดการตรวจสอบขอบเขตที่พิสูจน์ได้ว่าปลอดภัยออกไปก่อน ทำให้เหลือเพียง traps ที่คอมไพเลอร์ไม่สามารถตัดออกได้เอง ช่วยให้ solver มุ่งเป้าไปยังจุดที่น่าจะเป็นปัญหาได้แม่นยำขึ้น

ผมคอมไพล์เป้าหมายด้วยคำสั่ง GOOS=linux GOARCH=riscv64 go build -gcflags="-S -d=ssa/check_bce/debug=1" เพื่อพิมพ์รายการ assembly และบันทึก bounds check ที่เหลืออยู่ จากนั้นส่งซอร์สโค้ดและ disassembly ให้โมเดล การใช้ disassembly สำคัญมากเพราะจะเห็นการตรวจสอบที่คอมไพเลอร์เหลือไว้ รวมถึงการตัดค่า register ที่ในซอร์สโค้ดปกติมองไม่เห็น โดยผมเลือกใช้ RISC-V ตามแนวทางของ Anthropic

Argon จะวิเคราะห์ซอร์สโค้ดและ disassembly แล้วสร้างสคริปต์ Z3 เพื่อจำลองเส้นทางไปสู่ trap หาก Z3 พบอินพุตที่ทำให้เงื่อนไขเป็นจริง (sat) go test จะรันไบต์เหล่านั้นกับฟังก์ชันจริงทันที การใช้ LLM ช่วยลดภาระในการแปล assembly เป็นข้อกำหนดของ solver ด้วยมือ โดยใช้เวลาประมาณหนึ่งนาทีต่อฟังก์ชัน ในขณะที่ Z3 ประมวลผลเสร็จในระดับมิลลิวินาที

คอมไพเลอร์ SSA ของ Go จะตัดการตรวจสอบขอบเขตที่พิสูจน์ได้ว่าปลอดภัยออกก่อน จากนั้น Gemini 4 Argon จะจำลอง traps ที่เหลือเพื่อให้ Z3 ตรวจสอบ

ต่างจาก Anthropic ที่พึ่งพา interactive theorem proving กระบวนการของผมใช้ constraint solver แบบ satisfiability modulo theories (SMT) โดยให้ Argon แปล assembly เป็น Z3 constraints โดยตรง แม้จะไม่ได้ให้บทพิสูจน์ความถูกต้องสมบูรณ์แบบ แต่คำสั่ง check-sat ของ Z3 ก็มีประสิทธิภาพสูงในการหาบั๊ก

หากผลลัพธ์คือ sat หมายความว่าพบบั๊กแน่นอนพร้อมตัวอย่างอินพุตที่ทำให้เกิด panic จริง ซึ่งยืนยันผลผ่าน go test ได้ทันที ส่วนคำตอบแบบ unsat หมายถึงโค้ด "น่าจะถูกต้อง" การยอมแลกความสมบูรณ์ของบทพิสูจน์แลกกับความรวดเร็วทำให้แนวทางนี้ใช้งานได้จริงในทางปฏิบัติ

ในการทดสอบ ผมตรวจสอบฟังก์ชันบน slice ขนาด 64 ไบต์ที่สร้างใหม่โดยกำหนด cap(buf) == len(buf) เพื่อให้การทำ slice เกินขอบเขตเกิด panic ทันที (สำหรับโปรเจกต์ใหญ่ โครงการ theorem-labs/oss-verify มีรันไทม์ overlay ของ Go 1.27 ที่ช่วยให้การทำงานเป็นแบบ deterministic)

เมื่อเปรียบเทียบกับการทำ fuzzing โดยใช้ go test -fuzz บน Go 1.27.3 ผมเริ่มจากตัวแยกวิเคราะห์ที่ปลอดภัย 3 ตัวเพื่อยืนยันว่าระบบไม่แจ้งเตือนผิดพลาด (false positive) ซึ่ง Z3 ให้ผลเป็น unsat ตามคาด จากนั้นจึงทดสอบกับตัวแยกวิเคราะห์ที่มีบั๊กแฝง เช่น ParseTwoChunkFrame ดังนี้:

func ParseTwoChunkFrame(buf []byte) ([]byte, []byte) {
	if len(buf) < 4 || buf[0] != 0xA5 || buf[1] != 0x5A {
		return nil, nil
	}
	hdrLen, bodyLen := buf[2], buf[3]
	total := hdrLen + bodyLen
	if int(total)+4 > len(buf) {
		return nil, nil
	}
	endHdr := 4 + int(hdrLen)
	endBody := endHdr + int(bodyLen)
	return buf[4:endHdr], buf[endHdr:endBody]
}

ใน RISC-V disassembly บั๊กนี้เห็นชัดกว่า เพราะ total := hdrLen + bodyLen ของ uint8 จะ ห่อหุ้มผลรวมที่ 256 ก่อนตรวจสอบขอบเขต แต่ endBody กลับใช้การบวกแบบ 64 บิตเต็ม Argon จึงสร้างคำถาม Z3 เพื่อหาอินพุตที่ผ่านตัวป้องกันแต่ไปติดที่ endBody แทน

import z3
s = z3.Solver()
length, capacity = z3.BitVecs("length capacity", 64)
b0, b1, hdrLen8, bodyLen8 = z3.BitVecs("b0 b1 hdrLen8 bodyLen8", 8)
wrapped_sum64 = z3.ZeroExt(56, hdrLen8 + bodyLen8) + z3.BitVecVal(4, 64)
end_hdr64 = z3.BitVecVal(4, 64) + z3.ZeroExt(56, hdrLen8)
end_body64 = end_hdr64 + z3.ZeroExt(56, bodyLen8)
s.add(z3.UGE(length, 4), z3.ULE(length, 64), capacity == length)
s.add(b0 == 0xA5, b1 == 0x5A)
s.add(z3.UGE(length, wrapped_sum64), z3.ULE(end_hdr64, capacity))
s.add(z3.UGT(end_body64, capacity)) # panic at 0x0092
assert s.check() == z3.sat
print(s.model())

Z3 พบคู่อินพุตที่ทำให้ผลรวมวนกลับจนผ่านตัวป้องกันและไป panic ในภายหลัง ซึ่งน่าสนใจว่าการรัน fuzz นานถึง 60 วินาทีกว่า 3 รอบกลับตรวจไม่พบ เนื่องจาก fuzzer ของ Go มักส่ง slice ที่มีความจุเหลือเฟือ ทำให้การเข้าถึงที่ควรจะ panic กลายเป็นการอ่านข้อมูลเกินจุดสิ้นสุดแบบเงียบๆ แทน

การแก้ปัญหาคือใช้ full slice expression เพื่อจำกัดความจุให้เท่ากับความยาว:

f.Fuzz(func(t *testing.T, data []byte) {
	data = data[:len(data):len(data)] // cap == len: slicing past the end now panics
	ParseTwoChunkFrame(data)
})

หลังจากปรับเปลี่ยน fuzzer ก็พบบั๊กได้ในเวลาไม่ถึงวินาที การทำ Fuzzing จึงยังสำคัญ แต่ solver ให้ประโยชน์เพิ่มเติมในการยืนยันความปลอดภัยในเส้นทางที่เหลือผ่านสัญญาณ unsat ได้แม่นยำกว่า

เมื่อนำไปใช้กับ skill-lint ซึ่งเป็นโปรเจกต์ขนาดใหญ่ ผมใช้หลักการ Functional Core, Imperative Shell โดยแยก pure functions ออกมาให้ solver ตรวจสอบ และปล่อยให้การจัดการไฟล์หรือเครือข่ายเป็นหน้าที่ของ go test -fuzz และ go test -race ตามปกติ

จากการทดสอบ pure functions ใน skill-lint พบว่าบั๊กใน ApplyReplacementChunks เกิดจากการที่การจัดเรียง chunk ไม่ได้ป้องกันการซ้อนทับกัน ทำให้เมื่อ slice หดตัวลงจากการลบข้อมูล การเข้าถึงดัชนีถัดไปจึงเกิด panic ทันที Z3 ตรวจพบปัญหานี้ได้รวดเร็วและให้ค่า sat ที่ยืนยันได้จริงผ่าน go test

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

สรุปได้ว่า การทำ fuzzing ร่วมกับการจำกัดความจุ slice ช่วยหาบั๊กขอบเขตได้เร็วในระดับวินาที แต่สำหรับเคสที่ซับซ้อนกว่านั้น การเพิ่มเวลาอีกหนึ่งนาทีให้ solver ทำงานร่วมกับ LLM จะช่วยปิดช่องว่างที่การตรวจสอบทั่วไปเข้าไม่ถึงได้เป็นอย่างดี

อย่างไรก็ตาม solver แบบ Single-threaded จะไม่เห็น data races หรือ deadlocks ดังนั้นยังต้องใช้ race detector และเครื่องมือวัดผลอื่นๆ ใน Go 1.27 ต่อไป

หากคุณสนใจเรื่อง solvers หรือ coding agents ใน Go สามารถพูดคุยกันได้ที่ X หรือ LinkedIn

Source: Google Developers Blog
ดูแลงานแปลและเรียบเรียงโดย TanasakP
ล่าบั๊ก Go ที่ Fuzzing หาไม่เจอด้วย Gemini และ Z3 Solver

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

เข้าสู่ระบบเพื่อร่วมแสดงความเห็น

สมัครสมาชิก

มาเป็นคนแรกที่แสดงความเห็นกันเลยโบร