summaryrefslogtreecommitdiff
path: root/trees/blog/tfc-2024.typ
blob: cf4047967406efe0cc68814c502b5572994893c7 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
#import "./html_elements.typ": post

#show: post

// ---
// title: "license - tfc 2024"
// date: 2024-08-12T15:26:51-03:00
// draft: false
// description: ""
// tags: ["rev", "ctf"]
// ---

This is a writeup for the [TFC CTF 2024](https://ctftime.org/event/2423/).

For the challenge we are provided with a single binary called `license`.

Let's check the file:

```bash
$ file license
license: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=336b3d35e851f9b302e938e557e766e57ed406b7, for GNU/Linux 3.2.0, stripped
```

What I expected, a regular linux ELF binary, so let's see the security measures:

```bash
$ checksec license
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
```

Ok, everything looking normal so far.

When the binary is executed, it asks for a license, this should be the flag we
have to find.

I open the ELF in _binary ninja_ and go to the decompilation of the `main`
function.

```c
+0x14ae  int32_t main(int32_t argc, char** argv, char** envp)
+0x14ae  {
+0x14c4      puts("Please enter your license key to…");
+0x14e2      fgets(&buffer_start, 18, stdin);
+0x14f1      uint64_t rax = strlen(&buffer_start);
+0x14f1
+0x14ff      if (rax != 0x11)
+0x14ff      {
+0x1506          exit(0);
+0x14ff      }
+0x14ff
+0x1527      if ((rax != 0 && *(uint8_t*)(rax + 0x405f) == 0xa))
+0x1538          *(uint8_t*)(rax + 0x405f) = 0;
+0x1538
+0x1555      strncpy(&first_8, &buffer_start, 8);
+0x155a      data_4088 = 0;
+0x155a
+0x1573      if (check_1st_half(&first_8) == 1)
+0x1573      {
+0x157f          puts("Nope");
+0x1589          exit(0);
+0x1573      }
+0x1573
+0x1597      if (_9th_byte != '-')
+0x1597      {
+0x159e          exit(0);
+0x1597      }
+0x1597
+0x15bc      strncpy(&last_8, &buffer_10th, 8);
+0x15bc
+0x15d3      if (check_2nd_half(&last_8) != 1)
+0x15d3      {
+0x15f8          puts("Congrats! Get the flag on remote…");
+0x1603          return 0;
+0x15d3      }
+0x15d3
+0x15df      puts("Nope");
+0x15e9      exit(0);
+0x14ae  }
```

Due to the call to `strlen` and the conditional below it, we know that *the
flag is 17 characters long*.

Then there is:

- function that checks the first 8 chars of the flag.
- a conditional to check that the 9th char is `-`.
- a function to analyze the last 8 chars of the flag.

So we already know that the 9th byte has to be `-`, so the flag format is
`XXXXXXXX-XXXXXXXX`.

This is the decompilation of the `check_1st_half` function

```c
+0x1209  int64_t check_1st_half(void* input_buffer)
+0x1209  {
+0x1219      void* fsbase;
+0x1219      int64_t rax = *(uint64_t*)((char*)fsbase + 0x28);
+0x12e6      void to_compare;
+0x12e6
+0x12e6      for (int32_t i = 0; i <= 7; i += 1) // copy input_buffer to to_compare with a few modifications
+0x12e6      {
+0x1254          int32_t rax_7 = (i % 3);
+0x1254
+0x1259          if (rax_7 == 2)
+0x12c1              *(uint8_t*)(&to_compare + ((int64_t)i)) = (*(uint8_t*)((char*)input_buffer + ((int64_t)i)) - 0x25);
+0x1259          else if (rax_7 == 0)
+0x1285              *(uint8_t*)(&to_compare + ((int64_t)i)) = (*(uint8_t*)((char*)input_buffer + ((int64_t)i)) ^ 0x5a);
+0x1262          else if (rax_7 == 1)
+0x12a3              *(uint8_t*)(&to_compare + ((int64_t)i)) = (*(uint8_t*)((char*)input_buffer + ((int64_t)i)) + 0x10);
+0x12a3
+0x12da          *(uint8_t*)(&to_compare + ((int64_t)i)) ^= 0x33;
+0x12e6      }
+0x12e6
+0x12ec      int32_t iter = 0;
+0x1328      int64_t result;
+0x1328
+0x1328      while (true) // this checks if to_compose == global_flag_buffer_0
+0x1328      {
+0x1328          if (iter > 7)
+0x1328          {
+0x132a              result = 0;
+0x132a              break;
+0x1328          }
+0x1328
+0x1317          if (((uint32_t)*(uint8_t*)(&to_compare + ((int64_t)iter))) != ((int32_t)global_flag_buffer_0[((int64_t)iter)]))
+0x1317          {
+0x1319              result = 1;
+0x131e              break;
+0x1317          }
+0x1317
+0x1320          iter += 1;
+0x1328      }
+0x1328
+0x1333      *(uint64_t*)((char*)fsbase + 0x28);
+0x1333
+0x133c      if (rax == *(uint64_t*)((char*)fsbase + 0x28))
+0x1344          return result;
+0x1344
+0x133e      __stack_chk_fail();
+0x1209  }
```

Reverse Engineering is the art of understanding how things work. So, reading the code we know that the function:

- Copies the input buffer to a new buffer, doing a few operations on each character.
- Compares the new buffer against a global buffer, which is `"Xsl3BDxP"`

So, to solve this part of the challenge we just need to provide 8 characters
that after they're copied to that new buffer they are `"Xsl3BDxP"`.

There are multiple ways to do this - even just bruteforcing char by char - in
this chal I used [angr](https://angr.io/) to do this. This kind of problem is a classical application of angr - it's a symbolic execution engine -


```python
base = 0x400000

def first_check(project):

  check0_start = base + 0x1211
  check0_end = base + 0x12f5

  initial_state = project.factory.entry_state(
    addr = check0_start,
    add_options = { angr.options.SYMBOL_FILL_UNCONSTRAINED_MEMORY,
                    angr.options.SYMBOL_FILL_UNCONSTRAINED_REGISTERS })
  simulation = project.factory.simgr(initial_state)


  p0 = claripy.BVS('p0', 8 * 8)
  input0_addr = initial_state.regs.rdi
  initial_state.memory.store(input0_addr, p0)

  simulation.explore(find=check0_end)

  if simulation.found:
    solution_state = simulation.found[0]
    print('found!')

    to_compare_addr = solution_state.regs.rbp - 0x10
    constraint_sym = solution_state.memory.load(to_compare_addr, 8)
    constraint_value = 'Xsl3BDxP'.encode()
    solution_state.add_constraints(constraint_sym == constraint_value)

    print(solution_state.solver.eval(constraint_sym,cast_to=bytes))
    solution = solution_state.solver.eval(p0,cast_to=bytes)
    return solution
  else:
    raise Exception('Could not find the solution')

```

I don't mean to explain how do use angr here, but a quick summary of what this
script does is:


- Create a symbolic state where the execution will start (`0x1211` the function's start).
- Create a symbolic variable with 8 bytes.
- Mark where the symbolic variable will be during the initial stage (`RDI`), which is the register for the 1st argument in the [x64 linux call convention](https://www.ired.team/miscellaneous-reversing-forensics/windows-kernel-internals/linux-x64-calling-convention-stack-frame).
- Mark where the execution will stop (`0x12f5` right before comparing the strings).
- Add a constraint that the new buffer (stored at `RBP-0x10`) should be equal to the string that's going to check against.


With this script we have the solution for the first half, mazal tov!

Due to some limitations of angr, the differences in the second function make it
harder to solve using this approach, so for the second function the approach
was different.

```c
+0x1345  int64_t check_2nd_half(void* input_buffer)
+0x1345  {
+0x1463      for (int32_t i = 0; i <= 7; i += 1)
+0x1463      {
+0x1366          uint16_t* rdx_1 = *(uint64_t*)__ctype_b_loc();
+0x1366
+0x1390          if ((((uint32_t)rdx_1[((int64_t)*(uint8_t*)((char*)input_buffer + ((int64_t)i)))]) & 0x200) == 0)
+0x1390          {
+0x13e4              uint16_t* rdx_11 = *(uint64_t*)__ctype_b_loc();
+0x13e4
+0x140e              if ((((uint32_t)rdx_11[((int64_t)*(uint8_t*)((char*)input_buffer + ((int64_t)i)))]) & 0x100) != 0)
+0x1459                  *(uint8_t*)((char*)input_buffer + ((int64_t)i)) = (((int8_t)((((int32_t)*(uint8_t*)((char*)input_buffer + ((int64_t)i))) - 0x30) % 0x1a)) + 0x41);
+0x1390          }
+0x1390          else
+0x13db              *(uint8_t*)((char*)input_buffer + ((int64_t)i)) = (((int8_t)((((int32_t)*(uint8_t*)((char*)input_buffer + ((int64_t)i))) - 0x5c) % 0x1a)) + 0x61);
+0x1463      }
+0x1463
+0x1469      int32_t iter = 0;
+0x1469
+0x14a5      while (true) // just checking the equality
+0x14a5      {
+0x14a5          if (iter > 7)
+0x14a7              return 0;
+0x14a7
+0x1494          if (*(uint8_t*)((char*)input_buffer + ((int64_t)iter)) != global_flag_buffer_1[((int64_t)iter)])
+0x1494              break;
+0x1494
+0x149d          iter += 1;
+0x14a5      }
+0x14a5
+0x1496      return 1;
+0x1345  }
```


This function is only slightly different:

- The buffer gets modified in place.
- The modification uses `__ctype_b_loc`.

This is enough to break a simple angr approach here.

I wrote a bash script and `gdb` script to run the binary with every possible value for a byte, and print the value of the modified input. I ran this manually for all the bytes until I had the flag at the end.

```bash
for i in {1..255}
do
    I=$(printf '%02x' $i)
    printf "${I}\x30\x84\x5a\x61\x9c\x11\x53\x2d\x68\x75\x47\x76\x59\x55\x75\x41" > input_x
    Y=$(gdb -q -x ./table.gdb --batch --args ./license | grep '0x555555558090')
    echo "$I = $Y"
done
```

```bash
break *0x55555555547c
run < input_x
x/8xb $rax
kill
quit
```