RewardDistribution bug (Popsicle finance postmortem)
มาดูกันว่า popsicle finance คืออะไร และถูก hack ได้ยังไง
จริงๆเรียบเรียงไม่ดีเท่าไหร่ ออกแนวเป็น note มากกว่า ไว้กลับมาเรียบเรียงใหม่น่าจะย่อยได้ดีขึ้น
Popsicle finance ?
เป็น platform ที่เปิดให้ user ฝากเหรียญไว้ เพื่อทำกำไรจากกลไกต่างๆของ defi เช่น ทำกำไรจากกลไกของ uniswapV3 เป็นต้น
จริงๆแล้วค่อนข้างกว้างเลย เพราะ popsicle finance จะมีหลาย product รวมกัน ซึ่งหนึ่งในนั้นคือ Serbetto Fragola ซึ่งถูก hack เมื่อ Aug-03–2021 10:53:42 PM +UTC
แล้ว Serbetto Fragola คืออะไร ?
Sorbetto Fragola ?
ก่อนอื่น ต้องพูดถึงกลไลของ Uniswap V3 ก่อน
Uniswap V3 มี feature นึงที่เรียกว่า Concentrated Liquidity
ก่อนหน้านี้ใน Uniswap V2 เมื่อเราวาง pool เหรียญของเรา จะถูกแบ่งไปยังแต่ละช่วงราคาอย่างเท่าๆกัน ตามภาพ
ทำให้ไม่ว่าจะเกิดการ swap ที่ price เท่าไหร่ คนวาง pool ก็จะได้ค่า fee
แต่จริงๆแล้ว แต่ละคู่เหรียญมักจะมีช่วงราคาที่ถูก swap เป็นประจำอยู่ เช่น USDC/DAI มี volume การ swap ถึง 99.5% ในช่วงราคา 0.99–1.00 เท่านั้น
นั่นแปลว่า เหรียญที่ถูกแบ่งไปยังช่วงราคาอื่นๆ ไม่ได้ถูก swap เลย
พอเหรียญถูก swap อยู่แค่ช่วงแคบๆ คนวาง pool ก็ได้ค่า fee น้อยตาม
… ดังนั้น Uniswap V3 เลยเปิดให้คนวาง pool สามารถเลือกช่วงราคาที่จะกระจายเหรียญเองได้ ซึ่งก็คือ Concentrated Liquidity นั่นเอง
แต่
แต่เราควรวางในช่วงราคาเท่าไหร่ดี ?
ถ้าช่วงราคาที่เกิด volume เยอะ เปลี่ยนไป เราต้องมา monitor ย้ายตามมั้ย ?
นี่คือปัญหาที่ Serbetto Fragola เข้ามาแก้
โอเค เรามาดูกันว่า Serbetto Fragola contract เขียนยังไง
Serbetto Fragola contract
endpoint หลักๆ ในการใช้งาน contract นี้มี 3 endpoint คือ
- deposit ใช้ในการฝากคู่เหรียญกับ contract โดยจะได้เหรียญ PLP ของ contract กลับไป โดย contract จะนำเหรียญไปวาง pool ให้ตาม strategy เพื่อให้ได้ค่า fee เมื่อมีคนมา swap สูงสุด
- withdraw ใช้ในการถอนคู่เหรียญที่ deposit ไว้ โดยต้องนำ PLP มาแลก
- collectFees ใช้ในการดึงค่า fee ที่ได้จากการไปวาง pool uniswap
โอเค เรามาดูการเรียก contract ในแบบปกติกันก่อน
เริ่มจาก user เรียก deposit
โดยจะเป็นการ mint PLP token ให้ user
แต่มี modifier นึงถูกเรียกนั่นคือ updateVault
ซึ่งใช้ในการ update reward (fee ที่ user สามารถมา collect ได้)
โดย updateVault มีการทำงานดังนี้
- หาค่า fee ทั้งหมดในส่วนของ user โดยใช้ _earnFees (ปนๆกันทุก user โดยคำนวณจาก fee ที่ได้จาก uniswap หักส่วนของ platform ออก)
- อัพเดทอัตราส่วนระหว่างเหรียญและ LP โดยใช้ _tokenPerShare ซึ่งอัตราส่วนจะเก็บในตัวแปร token0PerShareStored สำหรับเหรียญแรก และ token1PerShareStored สำหรับเหรียญที่สอง
- อัพเดท reward ที่ user สามารถ collect ได้ โดยการอัพเดทใช้ _fee0Earned และ _fee1Earned
ซึ่งประเด็นอยู่ที่ _fee0Earned และ _fee1Earned นี่แหละ มาดู code กัน
โดยการคำนวณ fee หรือ reward ของ user ใช้ logic ดังนี้
user.token0Reward = balance PLP ของ user * (อัตราส่วน reward ของเหรียญ 0 แบ่งตาม share —อัตราส่วนที่ collect ไปแล้ว โดย user อื่นๆ ณ ตอนที่ user deposit) / 10¹⁸
โดยสรุปสิ่งที่เกิดขึ้นคือ
- คำนวณ reward ที่ user สามารถ collect ได้
- user ฝากคู่เหรียญเข้ามา
- mint PLP ให้ user
โอเค จบการ deposit ละ
จากนั้น user สามารถมาเรียก function collectFee เพื่อเอา reward
โดยมีการทำงานดังนี้
- เรียก updateVault เพื่ออัพเดท reward ของ user อีกรอบ
- โอน reward ให้กับ user
เอาละ นั่นเป็นการทำงานปกติของ contract
แล้ว attacker โจมตีได้ยังไง ?
โอเค มาดูแผนภาพใหญ่กันก่อน
เริ่มจากการ flashloan ทุนมา แล้ว deposit ตามปกติ
แต่หลังจากนั้นก็โอน PLP ที่ได้มาไปให้ contract B
ซึ่ง contract B ทำการอัพเดท reward ให้ตัวเองผ่าน collectFees ได้ เนื่องจากตอนนี้ contract B ถือ PLP อยู่แล้ว
จริงๆ การ อัพเดท reward ของ updateVault ตอนเรียก collectFees ออกแบบมาให้อัพเดทค่า reward ครั้งสุดท้าย ก่อนส่ง fee ให้คน deposit
แต่ตอนนี้ถูกใช้ในการ double calculate reward โดยคนที่ไม่ได้ deposit
โดย contract B เรียก collectFees ด้วย 0,0 ซึ่งก็จะไม่มีการถอน reward ออกมา เป็นแค่การ trigger update reward เฉยๆ
จากนั้น contract B ทำการโอน PLP ให้ contract C ต่อ
ซึ่งก็เป็นการ triple calculating reward
จากนั้นโอน PLP กลับให้ contract A เพื่อ withdraw คู่เหรียญคืน
โอเค ถึงตอนนี้ attacker ยังไม่ได้ขโมยไปจาก platform แต่การขโมยจะเริ่มตอนนี้แหละ
contract B ถอน reward (ซึ่งจริงๆแล้วมาจากการที่ A deposit)
contract C ถอน reward (ซึ่งจริงๆแล้วมาจากการที่ A deposit)
สุดท้าย attacker ก็วนไป pool อื่นเรื่อยๆ จนหมด
โอเค จากที่เขียนมา จริงๆแล้วปัญหาอยู่ที่การคำนวณ reward นี่แหละ กลับมาดู code ส่วนนั้นกัน
คือการคำนวณ reward เนี่ย เราเขียนสูตรไปรอบนึงละ มาดู อีกรอบกัน
user.token0Reward = balance PLP ของ user * (อัตราส่วน reward ของเหรียญ 0 แบ่งตาม share — อัตราส่วนที่ collect ไปแล้ว โดย user อื่นๆ ณ ตอนที่ user deposit) / 10¹⁸
ซึ่ง ตัวแปร token0PerSharePaid = อัตราส่วนที่ collect ไปแล้ว โดย user อื่นๆ ณ ตอนที่ user deposit
โดยปกติแล้ว token0PerSharePaid จะทำให้คำนวณ reward ได้อย่างถูกต้อง
เพราะ
คน deposit คนแรกๆ = provide liquidity นาน = ควรได้ reward เยอะ
คน deposit คนหลังๆ = provide liquidity ไม่นาน = ควรได้ reward น้อย
แต่ปัญหาคือ
token0PerSharePaid ไม่ได้อัพเดทไปตาม user ใหม่ เวลา PLP โดนโอนไปหา user ใหม่
ทำให้ user ใหม่ที่ไม่ได้ PLP จากการ deposit จะมีค่านี้เป็น 0
…. ทำให้ collectReward ได้เหมือนเป็นคน deposit คนแรก … ซึ่งเท่ากับว่าไปแย่งส่วน reward คนก่อนหน้าทั้งหมด
สรุปคือ ปัญหาของ feeEarn()
- การคำนวณสัดส่วน fee ไม่ได้รองรับการ transfer PLP
แล้วมีปัญหาไหนอีกบ้าง ?
ตอนที่เราคำนวณ reward เราจะเก็บ reward ไว้ใน token0Rewards ตามรูป
ปัญหาคือพอ B โอน PLP ให้ C แล้ว B.token0Rewards ยังมีค่าอยู่เลย
ดังนั้นปัญหาคือ
2. จาก PLP ก้อนเดียว สามารถ claim ได้หลายครั้ง โดยสลับ address คนถือ PLP ไปเรื่อยๆ
โอเค น่าจะประมาณนี้ก่อน บทความต่อไปลองมาเขียน contract เล็กๆ เพื่อดูกันว่าเราจะแก้ช่องโหว่นี้ยังไงได้บ้าง