Ethernaut part 13 (DexTwo & PuzzleWallet)
A chain is only as strong as its weakest link
Dex Two
โจทย์คือ จาก code
จง drain ทั้ง 2 token ออกจาก pool
โดยใน pool มี 100 token1, 100 token2
และเรามี 10 token1, 10 token2
โอเค มาเริ่มกัน
จากสูตรการคำนวณอัตราการแลกเหรียญนั่นคือ
ถ้าเราต้องการ 100 token2 แสดงว่าต้องใช้ token1 = ?
100 = amount * 100 / 100
ดังนั้น ต้องนำ 100 token1 ไปแลก 100 token2 ซึ่งเรามีแค่ 10 token1 เอง
แล้วทำยังไงได้อีกบ้าง?
จาก function swap พอมาลองสังเกตดีๆ จะเห็นว่าไม่มีการเช็คว่าจะต้องนำ token1 ไปแลก token2 เสมอไป
ดังนั้นเราสามารถนำ token อะไรก็ได้ไปแลก token2 นั่นเอง
โอเค หลักๆก็ mint fakeToken มา 100 เหรียญแล้วนำไปแลก token2 ตาม code
ตอนนี้เรา drain token2 ออกมาได้หมด pool ละ ส่วนการ drain token1 ก็ใช้ท่าเดียวกันนั่นเอง
PuzzleWallet
surpise อย่างแรกเลยคือข้อนี้ใช้ gas deploy สูงมาก
เอาละ งั้นมาทำใน local กัน โดยจะขอใช้ template foundry ของ StErMi ละนะ
โอเค โจทย์คือ จาก code proxy
และ code logic
จงกลายเป็น admin ของ contract Proxy
UpgradeableProxy ?
โอเค ต้องอธิบายก่อนว่า contract ทั้ง 2 ทำงานร่วมกันยังไง
โจทย์ประกอบด้วย 2 contract คือ PuzzleProxy และ PuzzleWallet
โดย pattern นี้เรียกว่า UpgradeableProxy ซึ่ง pattern นี้ทำให้เราสามารถแก้ไข code ของ contract ในภายหลังได้
เช่น ในการ deploy ครั้งแรก function deposit อาจจะทำงานแบบนี้
แต่เราสามารถแก้การทำงานของ function deposit แล้ว deploy ไปทับที่ contract address เดิมได้ และ state ต่างๆก็จะยังคงมีค่าเดิม (เพราะ contract ใหม่อยู่ที่ address เดิมนั่นเอง)
ซึ่งในการ exploit ข้อนี้นั้น เราต้องเข้าใจก่อนว่า PuzzleProxy และ PuzzleWallet ทำงานร่วมกันยังไง
เพื่อให้เห็นภาพ มาลองดูการ deploy ทั้ง 2 contract กัน
จาก code อธิบายเป็น step ได้ดังนี้
- deploy contrat PuzzleWallet
- deploy contract PuzzleProxy โดย set logic contract เป็น PuzzleWallet ที่เพิ่ง deploy ไป
- ในการ interact กับ PuzzleProxy จะใช้ abi ของ PuzzleWallet แทน ซึ่งแน่นอนว่าจะหา function ของ PuzzleWallet ที่ต้องการเรียกไม่เจอ
ซึ่งธรรมชาติของ solidity เมื่อหา function ใน PuzzleProxy ไม่เจอ จะไปทำการเรียก fallback ของ PuzzleProxy ตาม code
ซึ่งใน fallback ของ PuzzleProxy จะ delegatecall ไปยัง PuzzleWallet
จากที่อธิบายมาสรุปได้ว่า
เมื่อเราทำการเรียก function ใด function หนึ่งของ PuzzleProxy เช่น ทำการเรียก setMaxBalance ไปที่ PuzzleProxy
PuzzleProxy จะทำการใช้ logic setMaxBalance จาก PuzzleWallet และทำการ update state ลงบนตัว PuzzleProxy เอง (เนื่องจากเป็น delegatecall)
แล้วปัญหาคืออะไรละ?
จริงๆแล้ว pattern ดังกล่าวถูกออกแบบมาโดยอิงจากที่ทั้ง 2 contract มี state layout เหมือนกัน นะสิ
ดังนั้น เมื่อเรา update ค่า pendingAdmin บน PuzzleProxy แล้วเราไปเรียก function ของ PuzzleWallet ผ่าน PuzzleProxy … จะได้ว่า owner ถูกเปลี่ยนไปเป็นค่าตาม pendingAdmin เพราะ pendingAdmin และ owner ใช้ตำแหน่ง state เดียวกันนั่นเอง
จาก bug นี้ทำให้เราสามารถ override owner ได้ด้วยการ proposeNewAdmin
โอเค เราเป็น owner ได้ละ แต่เป้าหมายเราคือเป็น admin … ไปลุยต่อกัน
จากรูป admin อยู่ในตำแหน่งที่ 1 (เริ่มนับจาก 0) ซึ่งตรงกับ maxBalance
แสดงว่าการเปลี่ยน maxBalance จะเป็นการเปลี่ยน admin เช่นกัน
ซึ่งการจะเปลี่ยน maxBalance ทำได้ผ่าน setMaxBalance
แต่ setMaxBalance มีเช็ค address(this).balance == 0 ซึ่งปัจจุบันเป็นเท็จ (มีการเติม ether ไปตอน deploy contract แล้ว ตามภาพการ deploy ด้านบน)
ดังนั้นเราต้องทำให้ address(this).balance เป็น 0
ซึ่งการทำให้ address(this).balance ลดลง ทำได้ผ่านการ execute เพื่อให้ contract ส่ง ETH กลับมาผ่าน call
ปัญหาคือ เราจะติด require(balances[msg.sender] >= value, “Insufficient balance”); เพราะเรามี balances เป็น 0 และเราต้องใช้ value มากกว่า 0 เพื่อดึง ETH ออกจาก contract
แล้วเราทำยังไงให้ balances[msg.sender] มีค่าเพิ่มขึ้นมาได้บ้าง ? โดยเป้าหมายเราคือมีค่าเท่ากับ address(this).balance
เราจะพบว่าการเพิ่ม balance เนี่ย ทำได้ผ่านทาง deposit
ปัญหาคือ address(this).balance เท่ากับ 0.001 ETH อยู่แล้ว
ดังนั้นถ้าเรา deposit ไป 0.002 ETH ก็จะได้ผลลัพธ์คือ
balances[msg.sender] = 0.002 ETH
address(this).balance = 0.003 ETH
ซึ่งเราถอนที่ deposit ไปออกมา address(this).balance ก็จะเหลือ 0.001 ETH อยู่ดี
โอเค เราไปทางไหนได้อีกบ้าง?
เราจะพบว่ามี function multicall ที่อนุญาติให้เราเรียก function ได้ทีละหลายๆ function
ซึ่งถ้าเรา multicall การ deposit 0.001 ETH 2 ครั้งพร้อมกัน function deposit จะเกิดการ double spending เพราะเราใช้ msg.value เดิมซ้ำกัน ซึ่งทำให้ state เปลี่ยนไปดังนี้
balances[msg.sender] = 0.002 ETH
address(this).balance = 0.002 ETH
โอเค จริงๆเท่านี้ควรจะเพียงพอในการ exploit แล้ว ปัญหาคือใน multicall มีการเช็คห้ามไม่ให้มี deposit 2 ครั้งใน array
แต่เราสามารถ bypass การเช็คนี้ได้ ด้วยการซ้อน deposit ลงไปใน multicall อีกชั้นแบบนี้
multicall([deposit,multicall([deposit])])
ซึ่งเมื่อ PuzzleProxy เจอ multicall ชั้นที่ซ้อนข้างในก็จะทำการแกะแล้วเรียก deposit ปกตินั่นเอง
โอเค งั้นมา exploit กัน โดยมี step ดังนี้
- add ตัวเองเป็น whitelist เพื่อให้เราเรียก multicall ได้ โดยเนื่องจากเราเป็น admin ทำให้เรา add whitelist ได้นั่นเอง
- multicall ด้วย [deposit 0.001 ETH,multicall([deposit 0.001 ETH])]
- execute 0.002 ETH
- setMaxBalance เป็น address ของเราเองเพื่อตั้งตัวเองเป็น admin
จะได้ code ตามนี้
โอเค ข้อนี้ประมาณนี้ครับ