Resources

People often ask me "How did you learn how to hack?" The answer: by reading. This page is a collection of the blog posts and other articles that I have accumulated over the years of my journey. Enjoy!

Exploiting aToken liquidity addition in stableswap - post mortem- 1933

Jakub PanikPosted 8 Hours Ago
  • Hydration is a money market on Polkadot. It uses the EVM-based AAVE money market, which is directly integrated into its Substrate chain. This allows users to lend and borrow tokens across both ERC-20 and Substrate-native assets.
  • AAVE introduced the concept of aTokens, yield-bearing assets that are rebasing. Over time, this led to calculation errors in some of the Hydration code when converting between tokens and aTokens. Since ERC-20 includes existential deposits (ED), dust is never cleaned up. Over time, this led to higher gas usage, performance degradation, and confusing balance displays.
  • To fix this issue, the built in Currencies library transfer() function was added. This provided a generic solution for all aToken transfers. If the remaining balance after the transfer is less then ED, the Runtime would perform an AAVE withdraw all for the recipient. This would ensure that no dust remained in the origin account. All seems good in the world!
  • This change has a reasonable implementation with atoken_balance.saturating_sub(amount);. This uses saturating math to counter cases where things underflow. In the context of this one function, it makes perfect sense. However, this change was made at a much more general level, causing unintended side-effects on the rest of the system.
  • In particular, Stableswap::add_liquidity_share() mints liquidity shares for the usre in exchange for a user-provided asset. A user could call this function with an aToken amount greater than their actual account balance. Because the transfer logic no longer fails when the user has insufficient funds, this succeeds. This allowed for an infinite mint of shares on the protocol, effectively a game-over bug.
  • Once the bug was reported to the project (2 hours), they immediately paused at the affected pools. After pausing, an emergency "stealth" upgrade was performed, which landed on mainnet 7 hours after the initial bug report. They have some interesting takeaways... first, default to checked_* in Rust instead of saturated_* functions. Second, improve testing across the board to find more of these edge cases.
  • The change was very small but had wide-ranging consequences. So, in the future, they will tag PRs with their potential scope and subsystem integration requirements. It's interesting that such a small change in a function used by other sections of code was so catastrophic.
  • In the end, they paid the reporter the maximum payout on their program ($500K) for a relatively simple bug report that could have caused a $22M loss. This was a 50% split between stablecoins and $250K of their token, vested over 20 months. The origins of the vulnerability were super interesting to me. I found that a small shared function being changed can have huge consequences, particularly interesting. Good writeup!