Ethernaut part 13 (DexTwo & PuzzleWallet)

Nattawat Songsom
5 min readFeb 26, 2023

--

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 ได้ดังนี้

  1. deploy contrat PuzzleWallet
  2. deploy contract PuzzleProxy โดย set logic contract เป็น PuzzleWallet ที่เพิ่ง deploy ไป
  3. ในการ 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 ดังนี้

  1. add ตัวเองเป็น whitelist เพื่อให้เราเรียก multicall ได้ โดยเนื่องจากเราเป็น admin ทำให้เรา add whitelist ได้นั่นเอง
  2. multicall ด้วย [deposit 0.001 ETH,multicall([deposit 0.001 ETH])]
  3. execute 0.002 ETH
  4. setMaxBalance เป็น address ของเราเองเพื่อตั้งตัวเองเป็น admin

จะได้ code ตามนี้

โอเค ข้อนี้ประมาณนี้ครับ

Recommended reading

--

--

No responses yet