มาลองแกะ code Oyente กันเถอะ (Part 2 construct static edges)
มาเริ่มจากการเข้าใจการทำงานของ evm เมื่อรันแต่ละ opcode กันก่อน
EVM ทำงานยังไง ?
evm แบ่งพื้นที่เป็น 3 ส่วนคือ
- stack
- memory
- storage
stack ?
อันนี้ simple สุดเลย คือเป็นพื้นที่ไว้เก็บ data โดยการนำ data เข้าออกจะเป็นแบบ LIFO
เช่น ถ้าเราเริ่มต้นด้วย stack และ opcode แบบนี้
เมื่อรัน PUSH1 ก็จะนำข้อมูลเข้าไปใน stack
PUSH2 จะคล้ายกัน แต่จะนำเข้าไปทีละ 2 bytes
นอกจากการนำข้อมูลเข้า stack OPCODE ยังสามารถเปลี่ยนค่าใน stack ได้ด้วยเช่น SWAP2 ที่จะสลับค่าบนสุดของ stack กับ data ที่อยู่ต่ำลงไป 2 ตำแหน่ง
เช่น ก่อนรัน SWAP2 0x09 อยู่บนสุด และ 0x03 อยู่ตำ่ลงไป 2 ตำแหน่ง
เมื่อรันแล้ว ทั้ง 2 ก็จะสลับกัน
สิ่งที่น่าสนใจเกี่ยวกับ SWAP คือ มีแค่ SWAP1 ถึง SWAP16 เท่านั้น เป็นที่มาของ compile error stack too depp ซึ่งเกิดขึ้นเมื่อเราพยายาม SWAP data ที่อยู่ไกลกันเกินไป (เช่นใน case ของ function ที่มี ตัวแปร หรือ parameter มากเกินไป ซึ่งเวลาคำนวณด้วย Opcode ทั้งหลาย ตัวอย่างเช่น ADD จะทำการบวก 2 ค่าบนสุดของ stack เข้าด้วยกัน ซึ่งการที่เราต้องไป SWAP เอาค่าที่เราอยาก ADD มาไว้บน stack ก่อน แล้วค่านั้นอยู่ลึกลงไปเกิน 16 ชั้น นั่นเอง)
โดย ซึ่งแต่ละชั้นของ stack มีความจุแค่ 32 byte เท่านั้น
ซึ่งแปลว่า ถ้าต้องคำนวณค่าที่มีขนาดใหญ่กว่านั้น เช่น uint256 จะซับซ้อนมากๆ ต้อง SWAP กันรัวๆ
ซึ่ง memory จะเข้ามาช่วยในส่วนนี้
memory ?
เป็นพื้นที่เอาไว้เก็บข้อมูลจาก stack
เช่น เมื่อเราต้องการเก็บค่า 0x03 ลง stack จะใช้ opcode MSTORE
โดยต้องระบุตำแหน่งของ memory ที่จะเขียนด้วย
โดย MSTORE จะใช้ค่าบนสุดของ stack เป็นตำแหน่งต่อท้ายของ memory ที่เราต้องการเขียน เช่นต่อท้าย 0x20 คือ 0x40
และใช้ค่าถัดมาเป็น data ที่ต้องการเขียน นั่นคือ 0x03
โดยเอาค่าไปเก็บที่ memory แล้ว stack ก็จะว่างขึ้น
เมื่อต้องการอ่านค่าจาก memory มาเก็บไว้ใน stack ก็ให้ระบุตำแหน่งที่อยากอ่าน แล้วใช้คำสั่ง MLOAD
โดยข้อมูลใน memory นั้นจะ access ได้เฉพาะใน tx เดียวกัน
แต่ถ้าเราต้องใช้ค่านั้นอีกใน tx อื่นๆที่ตามมาหลังจากนี้ละ
ใน case นี้เราจะต้องใช้พื้นที่อีกประเภทของ evm นั่นคือ storage
storage?
เป็นพื้นที่คล้ายกับ memory แต่ไว้เก็บ data ที่ต้อง persistance หรือก็คือ ค่าที่ไม่ถูกล้างออกไปเมื่อสิ้นสุด tx โดยจะใช้พวก SSTORE SLOAD แทน MSTORE MLOAD
การแปลง solidity เป็น opcode
ก่อนอื่น เนื่องจากการอ่านคำสั่งบน stack ต้องคำนวณตำแหน่งที่จุกจิกเอาเรื่อง เช่น
คืออยากเขียน memory ตำแหน่งถัดจาก 0x20 ด้วยค่า 0x03
ซึ่งพอ stack ยาวขึ้น จะมึนเอาได้ว่าแต่ละคำสั่ง ใช้ค่าอะไรบ้าง
ดังนั้น ตอนนี้เราจะขอcแปลง opcode เป็นภาษา trime เพื่อให้อ่าน opcode ง่ายขึ้น ตามภาพ
โอเค ก่อนอื่น มาเริ่มกันจาก solidity syntax ที่มี opcode ตายตัว ตามภาพ
ต่อมา เนื่องจากใน solidity แต่ละ function ต้องใส่ payable เพื่อให้รับ native token เข้า contract ได้
แปลว่า ต้องมีการเช็คในแต่ละ function ว่ามีคำว่า payable มั้ย ถ้าไม่มี payable แล้ว ต้องเช็คต่อว่าส่ง native token มามั้ย ถ้าส่งมาก็ revert
ซึ่งสามารถเขียนเป็น TRIM ได้ดังนี้
โอเค มาลองแปลง TRIM กลับเป็น opcode กัน น่าจะได้ประมาณนี้
โอเค มาลองรันทีละบรรทัดกัน
เอาค่า CALLVALUE เข้า stack
เอาค่า 0x00 เข้า stack
POP ทั้ง 2 ค่าออกมา เทียบว่าเท่ากันมั้ย แล้ว PUSH ผลลัพธ์การเทียบกลับไปใน stack
PUSH ตำแหน่งของ opcode ที่อยากให้รัน เมื่อเงื่อนไขเป็นจริงเข้าไปใน stack เช่นถ้าอยากให้ revert ก็ push ตำแหน่ง opcode ของ revert ไปเก็บไว้ก่อน
จากนั้น POP 2 ค่าออกมาจาก stack แล้วเช็คดูว่าควรไปรัน opcode ตำแหน่งไหนต่อเช่นใน case นี้คือ ถ้า CALLVALUE == 0x00 ให้ไปกระโดดไปรัน opcode ตำแหน่ง 0x48 ต่อ แต่ถ้า CALLVALUE != 0x00 ให้รัน opcode ตำแหน่งถัดไป นั่นคือ 0x07
โอเค เอาจริงๆแล้ว ด้วยพื้นฐานประมาณนี้ เราจะสามารถเชื่อมแต่ละ basic block ให้กลายเป็น cfg ได้ในระดับนึงแล้ว ถ้าตำแหน่ง block ต่อไปถูก push เข้ามาใน stack แล้วเรียกพวก JUMP ทันที
โดยเทคนิคนี้เรียกว่า construct static edges
จากรูป เราสามารถลากเส้นเชื่อม basic block จากบทความ part ที่แล้ว ให้กลายเป็น cfg ได้ในระดับนึงด้วยเทคนิค construct static edges
แต่จะมีบาง block ที่เรายังลากเส้น cfg ไม่ได้อยู่
ซึ่งเกิดจากตำแหน่ง JUMP ไม่ได้ hardcode ลงมาใน opcode แต่ต้องคำนวณ และทดค่าบน stack เพื่อหาว่าต้อง JUMP มั้ย และต้อง JUMP ไปที่ไหน
โอเค part นี้จะจบที่การ construct static edges จาก basic block ก่อน ไว้ part ต่อไปเรามาดูการลากเส้น cfg เพิ่มด้วยเทคนิค dynamic analysis กัน