Shellcode

dmswl·2025년 11월 2일

System Security

목록 보기
9/15
  • Ubuntu 20.04
  • vscode, gcc, gdb
void main()
{
	int c; 
    c = function(1, 2);
}

int function(int a, int b)
{ 
	char buffer[10]; 
    a = a + b;
    return a; 
}
  • ATT: 앞에서 뒤로 대입

1. Assembly basic analysis

<1> push %ebp

  • 최초의 ebp 값을 스택에 저장
  • ebp 바로 전에 ret 저장, 함수 종료 시 점프할 주소인 이전 eip 저장
  • ebp는 함수 시작 전의 기준점

현재 ebp 레지스터 값을 스택에 4 byte만큼 저장하고, ebp 레지스터가 4 byte 더 작은 주소로 이동한다.

main 함수도 호출되는 함수이기 때문에, main 함수의 시작에서도 똑같이 push %ebp가 수행된다.

<2> mov %esp, %ebp

  • 현재의 esp 값을 ebp 레지스터에 저장 (앞에서 뒤로)
  • push %ebp와 mov %esp, %ebp는 새로운 함수를 시작할 때마다, 항상 수행하는 명령으로 prologue라고 부른다.

<3> sub $0x4, %esp

  • 스택에 4 byte 할당, 지역변수 c (4 byte)

지역변수 c를 위한 4 byte 공간이 스택에 할당되는 상황이다.

<4> push $0x2

<5> push $0x1

  • 4 ~ 6은 function(1, 2) 호출 루틴

16진수로 2와 1 값을 저장하고, push 하기 때문에 esp가 4 byte씩 내려간다. 아직 지역변수 c에 값이 저장되지 않았기에 미사용으로 표시된다.

<6> call 0x565561b5 <function>

  • push %eip와 jmp 0x565561b5를 의미
  • (즉, 현재 %eip값(0x565561bac)을 push하고, %eip을 0x565561b5로 변경)
    • 돌아와서 갈 곳(0x565561ac)을 저장하고, 현재 실행할 곳(0x565561b5)로 eip를 변경한다.

<7> push %ebp: 현재 레지스터의 ebp 값을 스택에 저장

  • 함수 호출이 끝나고, 다시 main으로 복귀했을 때, main 함수의 스택 프레임 정보를 정확히 복구하기 위해서이다.

함수의 시작점을 왜 기억해야 하는가?

  1. 함수 호출이 될 때, 이전 프레임 복구
  2. 안전한 지역 변수/인자 접근
    • 항상 일정한 오프셋으로 접근 가능

<8> mov %esp, %ebp: function(1, 2)의 prologue

<9> sub $0xc, %esp: char buffer[10] 할당

  • 실질적으로 10byte만을 필요
  • 4-byte alignment에 의해 12 byte 필요

0xc는 16진수로 12 byte이다. 메모리 관리 상 12 byte를 할당한다.

<10> mov 0xc(%ebp), %eax: ebp에 12 byte를 더한 주소 값의 내용을(=2) eax 값에 복사

  • 0xc(%ebp): ebp 레지스터 값 + 0xc(12 byte)

<11> add %eax, 0x80(%ebp): ebp에 8 byte를 더한 주소 값의 내용(=1)에 eax(=2) 값을 더한다.

  • ADD: 연산 결과를 des에 저장

<12> mov 0x8(%ebp), %eax: ebp + 8 byte 더한 주소 값의 내용(=3)을 eax에 저장

<13> leave: main 함수의 원래 ebp 값으로 변경하고 함수 끝

  • mov %ebp, %esp와 pop %ebp를 의미
    • 함수 진입 당시로 되돌리고, 스택에 저장된 이전 ebp 값을 복구한다.
  • pop %ebp
    • 스택에서 꼭대기에 있는 데이터를 ebp 레지스터에 넣는다.
    • 스택에서 main 함수의 ebp 값은 '최초 ebp 값'이므로 ebp가 저 위치를 가리키게 된다.

<14> ret: function 함수를 마치고 스택 정리

  • pop %eip를 의미
    • 스택 꼭대기에 있는 return address를 eip에 넣어 해당 주소로 실행 흐름 전환
    • 위 그림에서 function ret(0x565561ac)가 pop되어 eip에 들어가고, esp는 4 byte 위로 올라간다.

<15> add $0x8, %esp: esp에 8 byte 더하기

  • function 함수에서 사용된 인자가 스택에 남아있어, 이 공간을 esp로 밀어내어 정리한다.

<16> mov %eax, -0x4(%ebp): ebp에서 4 byte 뺀 주소 값(int c)에 eax 값을 복사

  • eax에 저장된 function의 반환 값을 main 함수의 지역변수 c에 저장
  • '미사용 공간'으로 남아 있던 c 변수의 스택 공간에 실제 값이 저장되어, 값이 담긴 상태가 된다.

<17> leave: main 함수 끝

  • main 함수 전체가 끝나므로, ebp/esp를 복구해서 스택 프레임 정리
  • 위처럼 mov %ebp, %esp와 pop %ebp와 같은 의미
  • 최초의 ebp 값이 ebp 레지스터 안에 들어가게 되고, esp는 ret를 가리키게 된다.

<18> ret: main 함수를 마치고 스택 정리

  • pop %eip
    • 스택의 꼭대기 값을 eip에 저장, 따라서 ret가 eip에 들어간다.

2. System call Execution

  • 표준 C 라이브러리를 통해 실제 수행하는 함수를 호출
  • User space에서 kernel space로 진입하는 표준 경로

2.1 Procedure

  • 사용자 프로세스에서 system call 사용
  • lib.c
    • Argument stack에 넣음
    • System call 번호 저장
    • Trap(=S/W Interrupt) 발생을 통해 IDT 호출
  • system_call()
    • IDT에 의해 Interrupt 시작
    • sys_call_table 사용
  • System call handler 함수
    • 약 440개까지 가능(커널 5.15)

2.2 Interrupt Descriptor Table

  • i386에서는 IDT를 통해 모든 interrupt가 관리
  • System call은 0x80번의 interrupt

2.3 Sort of system call

  • /usr/include/asm/unistd_32.h (ubuntu 20.04 기준)
  • 각각의 system call을 구별하기 위해 고유 번호 할당

3. Shellcode

  • In code injection attack: need to inject binary code
  • Shellcode is a common choice
  • Its goal: get a shell
    • After that, we can run arbitrary commands
  • Written using assembly code

Shell을 띄우는 기계어 코드로, 공격자가 shell을 얻으면 OS에서 거의 모든 명령을 실행할 수 있다.

Writing shellcode using C

execve는 명령어를 실행시켜주는 system call로 unistd.h에 함수 원형이 정의되어 있다.

Getting the binary code

  • gcc로 shellcode.c를 컴파일해서 32 bit 실행파일 생성
  • 'objdump -Mintel --disassemble a.out'으로 바이너리 파일을 disassembling하여 실제 실행되는 기계어 확인
  1. 핵심 바이너리 코드만 얻어야 하는데, 파일 전체를 disassembling하면 부가적인 함수 호출이 들어간다.
  2. NULL 포함 X
    • shellcode를 메모리에 복사할 때 'strcpy' 함수가 사용되는데, 문자열 끝을 표시하는 NULL을 만나면 복사를 멈추기 때문에 중간에 잘린다.

Writing shellcode using Assembly

Invoking execve("/bin/sh", argv, 0)

  • eax = 0x0b: execve() system call number
  • ebx = address of the command string "/bin/sh"
  • ecx = address of the argument array argv
  • edx = address of environment variables (set to 0)

Cannot have zero in the code, why?

  • strcpy, memcpy 등 문자열 복사 함수는 NULL을 만나면 복사를 멈춘다.

Setting ebx

  • mov ebx, esp: ebx가 /bin/sh의 시작 주소와 같아진다.

Setting ecx

  • mov ecx, esp: ecx가 argv 배열의 시작 주소를 가리킨다.

ebx와 ecx 모두 "/bin/sh" 주소 값을 담지만, ebx는 execve의 첫 번째 인자(실행 경로), ecx는 두 번째 인자(인자 배열 시작 포인터)로 사용된다.

Setting edx

  • Setting edx = 0

Invoking execve()

  • Let eax = 0x0000000b
    • execve의 system call number: 11
    • eax \rightarrow ax \rightarrow ah & al

eax, ebx, ecx, edx 등에 필요한 값들을 채워놓고, int 0x80을 실행하면 커널이 해당 system call을 처리한다.

Putting everything together

Compilation and testing

shellcode 실행 결과 새로운 shell 프로세스가 생성되기 때문에 PID가 바뀐다.

기존의 shell은 종료되는가?

Shellcode2

  • gcc -Wl, -z, execstack -m32 shelltest.c
  • char 배열을 전역변수로 선언과 동시에 초기화
  • shell 배열의 시작 주소를 함수 포인터로 type casting하여(void(*)) func에 할당
    • 4 byte 변수이므로 stack이 아래로 4 byte 내려감
  • func()를 호출하면, shell 배열에 저장된 기계어 코드가 실제로 실행된다.

shellcode를 담아둔 메모리가 data segment(stack...)에 존재하지만, EIP가 해당 위치(shellcode 시작 주소)로 jump하게 만들면 실제로 프로세서가 기계어 명령으로 인식하고 수행한다.


4. Getting rid of zeros from shellcode

How to avoid zeros

Using xor

  • mov eax, 0: not good, it has a zero in the machine code
  • xor eax, eax: no zero in the machine code

Using instruction with one-byte operand

  • How to save 0x00000099 to eax?
  • mov eax, 0x99: not good, 0x99 is actually 0x00000099
  • xor eax, eax; mov al, 0x99: al represents the last byte of eax (8 bit = 1 byte)

앞서 설명했듯이 NULL byte가 포함되면 이후 injection에서 문자열의 끝으로 인식되어 shellcode가 제대로 동작하지 않으므로, NULL byte는 피해야 한다.

Using shift operator

  • How to assign 0x00112233 to ebx?
    • shl ebx, 8; 0x11223300
    • shr ebx, 8; 0x00112233

Pushing the "/bin/bash" string into stack

  • Without using the // technique
  • /bin//sh; /을 추가하며 8 byte alignment
  • /bin/bash; 9 byte로 8 byte 이후에 NULL값을 추가해야 하므로, 문자열 내에 미리 자리만 확보한 뒤 실행 중에 해당 위치를 NULL로 치환하는 과정을 거친다.

5. Another approach

Getting the address of string and argv[]

  1. jmp short two
    • lable "two"로 JMP
  2. call one
    • "two"에서 "one"으로 CALL 실행
    • CALL 명령은 현재의 다음 명령어 주소('/bin/sh*')를 스택에 push하고 eip를 변경
  3. pop ebx
    • CALL때문에 push된 문자열의 주소가 ebx에 저장
    • ebx가 "/bin/sh"을 문자열이 위치한 메모리 주소를 가리킴

전체 코드가 code segment 영역에 위치하게 된다.
앞선 예제는 문자열과 인자 배열을 직접 stack에 push하고 push/mov만으로 주소를 처리하였는데, 위 예제는 CALL이나 JMP 등 흐름 제어 명령을 이용해 code segment에 있는 주소를 동적으로 얻는 점이 다르다.

Data preparation

  • Putting a zero at the end of the shell string
    • ebx가 "/bin/sh*" 문자열의 시작 주소를 가리키고 있다고 가정할 때, 7번째 byte에 NULL을 쓴다.
  • Constructing the argument array

Compilation and testing

  • Error (code region cannot be modified)

(1) assembly를 object로, (2) object를 binary file로의 변화를 거친다.

  • Make code region writable
    • default가 segmentation fault를 유발하기 때문에, --omagic을 추가해야 쓸 수 있다.

0개의 댓글