aboutsummaryrefslogtreecommitdiff
path: root/articles/2022-09-25-ductf-file-magic.md
blob: f4b55c9b75ff015cc8e2fef21fb71cfdf7ee3f54 (plain)
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
# DownUnderCTF 2022: File Magic

A short writeup about my favorite challenge from DUCTF. It took me approximatly
12h to solve. I found it was the most creative and challenging one that i
solved.

## Task

The challenge consists of a python script and an ip-port pair which appears to
be running that script. Also the path of the flag is given: `./flag.txt`

```py
#!/usr/bin/env python3

from Crypto.Cipher import AES
from PIL import Image
from tempfile import NamedTemporaryFile
from io import BytesIO
import subprocess, os

KEY = b'downunderctf2022'

iv = bytes.fromhex(input('iv (hex): '))
assert len(iv) == 16 and b'DUCTF' in iv, 'Invalid IV'

data = bytes.fromhex(input('file (hex): '))
assert len(data) % 16 == 0, 'Misaligned file length'
assert len(data) < 1337, 'Oversized file length'

data_buf = BytesIO(data)
img = Image.open(data_buf, formats=['jpeg'])
assert img.width == 13 and img.height == 37, 'Invalid image size'
assert img.getpixel((7, 7)) == (7, 7, 7), 'Invalid image contents'

aes = AES.new(KEY, iv=iv, mode=AES.MODE_CBC)
dec = aes.decrypt(data)
assert dec.startswith(b'\x7fELF'), 'Not an ELF'

f = NamedTemporaryFile(delete=False)
f.write(dec)
f.close()

os.chmod(f.name, 0o777)
pipes = subprocess.Popen([f.name], stdout=subprocess.PIPE)
stdout, _ = pipes.communicate()
print(stdout.decode())

os.remove(f.name)
```

So, for anything to make it past these checks and be executed it must:

1. be a valid 13x37 JPEG image with the pixel at 7,7 set to `#070707`
2. be a valid ELF binary that reads `./flag.txt` after decrypting with AES CBC,
   fixed key (`downunderctf2022`) and the provided IV
3. The IV must contain `DUCTF`

During the competition I discovered the information in the next three headings
in parallel but internally in-order.

## 1. AES CBC "flaw"

We need to generate a file that is a sort-of polyglot with JPEG and ELF,
converted with AES CBC (Cipher block chaining).

AES itself operates on 16-byte (for 128-bit AES) blocks, so bigger files are
split and then encrypted separately. To ensure that identical blocks don't
result in identical blocks in ciphertext, each block is first xor'd with
something that won't be identical. In the case of CBC, the last ciphertext block
or the initialisation vector (IV) is used. Here is a diagram for encryption

```
  ___plaintext____|___plaintext____|___plaintext____|...
       v                 v                v              
IV--->XOR   ,---------->XOR   ,--------->XOR   ,---- ...
       v    |            v    |           v    |         
      AES   |           AES   |          AES   |         
       v---'             v---'            v---'          
  ___ciphertext___|___ciphertext___|___ciphertext___|...
```

For decryption we can just flip the diagram and replace AES with reverse AES.

```
  ___ciphertext___|___ciphertext___|___ciphertext___|...
       v---,             v---,            v---,         
      ∀EZ   |           ∀EZ   |          ∀EZ   |         
       v    |            v    |           v    |         
IV--->XOR   '---------->XOR   '--------->XOR   '---- ...
       v                 v                v              
  ___plaintext____|___plaintext____|___plaintext____|...
```

This does make sense, however i noticed that all but the first block do not
depend on IV. This turns out useful since we can turn any block into any other
block by applying a chosen value with XOR. So we can control the ciphertext with
the IV directly as follows:

- $m$: first plaintext block
- $c$: first ciphertext block

$$ c = AES(m \oplus IV) \\

AES^{-1}(c) = m \oplus IV \\

AES^{-1}(c) \oplus m = IV $$

All blocks in ciphertext after the first are now "uncontrollable" because IV and
plaintext are set.

## 2. JPEG

JPEG consists of a list of _segments_. Each starts with a marker byte (`ff`)
followed by a identifier and the length of the segment (if non-zero).

| Identifier | Name                                            |
| ---------- | ----------------------------------------------- |
| `d8`       | Start of Image                                  |
| `fe`       | Comment                                         |
| `d9`       | End of Image                                    |
| ...        | _a bunch more that you dont need to know about_ |

The comment segment is perfect for embedding our ELF binary into JPEG. We can
first generate a JPEG image, then insert a _comment_ somewhere containing any
data we want.

## 3. ELF Payload

The binary needs to be super small so creating it "manually" was required. I
followed the guide
[Creating a minimal ELF-64 file by tchajed](https://github.com/tchajed/minimal-elf/)
and recreated it for my needs. Like in the guide i also wrote the assembly with
a rust EDSL.

```rs
let mut str_end = a.create_label();
let mut filename = a.create_label();
a.jmp(str_end)?; // jump over the string
a.set_label(&mut filename)?;
a.db(b"flag.txt\0")?;
a.set_label(&mut str_end)?;

// open("flag.txt", O_RDONLY)
a.mov(eax, 2)?;
a.lea(rdi, ptr(filename))?;
a.mov(rsi, 0u64)?;
a.syscall()?; // fd -> rax

// sendfile(1, rax, 0, 0xff)
a.mov(rsi, rax)?;
a.mov(eax, 40)?;
a.mov(rdi, 1u64)?;
a.mov(rdx, 0u64)?;
a.mov(r10, 0xffu64)?;
a.syscall()?;
```

I was able to produce a 207 byte long executable.

## 4. _magic!_

Here is how we align the files now (single quotes `'` indicate ASCII
representation for clarity, question marks `?` represent data that is implicitly
defined):

```
plaintext:  7f 'E 'L 'F 02 01 01 00 00 00 00 00 00 00 00 00 
iv:         ?? ?? ?? ?? ?? ?? 'D 'U 'C 'T 'F 00 00 00 00 00
ciphertext: ff d8 ff fe {len} ?? ?? ?? ?? ?? ?? ?? ?? ?? ??
```

This scenario is a little more complicated because in some places we define
ciphertext and plaintext and in some we define ciphertext and IV. This is not a
problem because XOR operates on every byte individually.

All-in-all, the file looks like this now:

- First block:
  - overlapping headers (6 bytes)
  - `DUCTF` string (5 bytes)
  - padding (5 bytes)
- Rest of the ELF binary
- _end of the jpeg comment_
- Rest of the JPEG image

All this information should be enough to solve this challenge!

I here attach the implementation that i created _during_ the CTF here. I kept it
as messy as it was, just removed not-so-interesting code and added comments.

```rs
let mut i = image::RgbImage::new(13, 37);
// jpeg is lossy so filling guarantees the pixel actually has the *exact* color
i.pixels_mut().for_each(|p| *p = Rgb([7, 7, 7]));

let mut imgbuf = Cursor::new(vec![]);
image::codecs::jpeg::JpegEncoder::new(&mut imgbuf)
    .encode_image(&i)
    .unwrap();

let key = Aes128::new(&GenericArray::from(*b"downunderctf2022"));
let mut payload = create_elf_payload();
let mut buf = BytesMut::new();

// pad payload so AES works
while payload.len() % 16 != 0 {
    payload.put_u8(0);
}
assert!(payload.len() % 16 == 0);

let prefix_len = 6;
// write the JPEG headers to start a comment
buf.put_u16(0xffd8);
buf.put_u16(0xfffe);
buf.put_u16(payload.len() as u16 + 2 /*seg len*/ - prefix_len as u16);

// find a good IV
let iv = {
    let mut fblock_target = vec![0u8; 16];
    fblock_target[0..prefix_len].copy_from_slice(&buf[0..prefix_len]);
    key.decrypt_block(GenericArray::from_mut_slice(&mut fblock_target));
    let mut iv = xor_slice(&fblock_target, &payload[0..16]);
    iv[prefix_len..prefix_len + 5].copy_from_slice(b"DUCTF");
    iv
};

// fill the first block up with zeroes
for _ in prefix_len..16 {
    buf.put_u8(0);
}

// encrypt starting with the 2nd block
buf.put(&payload[16..]);
let block_range = |n: usize| (n) * 16..(n + 1) * 16;
for i in 1..buf.len() / 16 {
    let tb = Vec::from(&buf[block_range(i - 1)]);
    xor_assign(&mut buf[block_range(i)], &tb);
    key.encrypt_block(GenericArray::from_mut_slice(&mut buf[block_range(1)]));
}

// append the rest of the image, stripping the first segment
buf.put(&imgbuf.into_inner()[2..]);

// pad the final buffer again because the image might not be aligned
while buf.len() % 16 != 0 {
    buf.put_u8(0);
}
assert!(buf.len() < 1337);

File::create("image").unwrap().write_all(&buf).unwrap();
File::create("iv").unwrap().write_all(&iv).unwrap();
```

I am also still looking for team mates for upcoming CTF events and would be
happy to hack together with you! Just [contact](https://metamuffin.org/contact) me.