개요(수업 복기)
오늘(5/7) 수업에서 기존 쉘 코드들이 왜 실행되지 않았는지 다뤘다.(Segmentation Fault)
처음에 만든 쉘 코드는 주소가 상수로 박혀있어서 실패했다.
복습하기
수업 중 shellcode 변수는 어디에 저장되는지에 대해 교수님이 질문했다.
그런데 순간 생각이 나지 않았고, “일단 문자열이니까 .rodata에 있겠지” 같은 생각을 하고 있었다.
1주일 후에 또 시험인데 X됐다고 볼 수 있다.
오케이 복습 완료.
char shellcode[]
는 전역 배열이기 때문에 data 영역에 초기화 되어 저장되는 것이 맞다.복습 차원에서 몇가지 예시를 들어보면:
char* shellcode
= “asdf” 였다면 asdf는 .rodata에 저장되었을 것이다.- 또한 포인터 변수 shellcode 자체는 .data에 저장되어 있을 것이다.
- char shellcode[] = “asdf”가 로컬 배열이었다면, stack에 저장되었을 것이다.
두번째는 실행은 되었지만 널로 인해 동작이 일관성이 없을 수 있어서 추가적인 정리가 필요했다.
코드 내의 null bytes를 어디 다른데서 얻을 수 있는 null로 대체하는 작업이었다.
이렇게 해서 쉘 코드 집어넣고 실행되었다.
이걸 잘 exploiting하면 특정 파일을 실행했을 때,
rm -rf ~
같은 명령어를 시스템콜로 호출 가능할것이다.rm -rf /
은 sudo 권한이 없어서 안될까?어쨌든 시스템 콜은 커널 영역에서 실행될텐데 말이다.
환경 구축하기
그동안 32비트 시스템이 없어서 못해보고, 맨날 고민만 했다.
대 AMD64, ARM64 시대에 어디서 x86 시스템을 구하나?
집에 걸어가다가 fail fast라는 개념이 생각났다.
복구 비용이 적을 때 빨리 실패하고 그 결과에서 빨리 피드백하라는 것이다.
잊고 있었는데 사실 테스트 코드, 테스트 코드를 넘어 ‘테스트 자체’의 목적 중 하나가 fail fast였다.코드를 최대한 프로덕션 환경과 가깝게 하고, 의도대로 잘 수행되는지 fail fast하게 검증하는 것이다.
그래서 나도 failfast 정신에 입각해 AMD64 시스템을 구해(AWS EC2), 일단 실행해보기로 했다.
생각해보면 x86_64 = AMD64 = “IA-32 Extended” 이며, 어쨌든 32비트 하위호환성을 가진다.
자세한 건 모르지만 어쨌든 호환되겠거니 싶었다.
환경 구축(처음부터)
일단 C 컴파일을 위한 gcc가 포함된 패키지, build-essential을 설치한다
sudo apt-get update; sudo apt-get install build-essential
시스템이 아키텍쳐가 AMD64이기 때문에 64비트 기반 gcc 및 라이브러리, 헤더들이 설치된다.
따라서 32비트 ELF 빌드를 위해선 해당 아키텍쳐의 헤더 및 라이브러리를 추가로 설치하고, 그것을 사용할 수 있게 해줘야 한다.
sudo apt-get install gcc-multilib
여기까지 하면 아래와 같은 명령어로 32비트 ELF를 만들 수 있게 된다.
gcc -o test test.c -m32
참고)
-m32
옵션 없이 컴파일하면 64비트 ELF가 빌드된다.ASML 끄기
sudo sysctl kernel.randomize_va_space=0
echo "kernel.randomize_va_space=0" | sudo tee /etc/sysctl.d/01-disable-aslr.conf;
PIC 끄기
정확하게는 모르겠지만, main 호출에 보호기법이 적용되는 것으로 보인다. (
get_pc_thunk.ax
부분)이건 PIC로 인한 것이라고 한다.
-fno-PIC 컴파일 옵션으로 꺼준다.
PIE 끄기
이것도 보호기법이다. 이것마저 꺼줘야 우리에게 익숙한 어셈블리가 나온다.
잘은 모르겠으나, 후자가 우리가 아는 (혹은 기대하는) 형태이다.
시간이 남으면 더 궁금해해보자.
최종 컴파일 옵션 alias
~위 GCC 컴파일 옵션들을 항상 자동으로 적어준다.
echo "alias gcc='gcc -m32 -fno-pic -no-pie'" >> ~/.bashrc
이후 쉘 재실행(ssh 재접속)
References
Dockerfile
build.sh
docker build -t ubuntu <dockerfile 위치>
컴파일도 되고 실행도 되는데, GDB가 잘 작동하지 않는 문제가 있다.
이렇게 하면 되긴 된다는데, 너무 비용이 크니까 그냥 EC2를 쓰도록 하자.
시스템 콜 호출 어셈 분석하기
먼저 execve 시스템콜을 호출하는 프로그램을 작성하고, 컴파일 한 후 어셈블리를 본다.
왜 정적 컴파일을 해야 하는가?
GDB 명령어를 참고해서 최대한 간단한 방법으로 접근했음에도, 다이나믹 링킹 때문에 분석이 까다로워진다. 물론 쭉 따라가면 되겠지만 시간이 아깝다.
그 외에, 정적 컴파일을 안한다고 해서 분석이라는 목적을 도달할 수 없는 것은 아닌것으로 보인다.
그도 그럴것이, 동적 링킹도 어쨌든 프로그램을 메모리에 로드하는 과정이니 결국 필요한 인스트럭션은 모두 메모리에 올라오게 된다.
인자를 넣고
execve
를 호출하면, 그 내부에서 스택으로 넘어온 인자들을 레지스터에 각각 저장하고, vdso를 통해 __kernel_vsyscall을 호출해 최종적으로 시스템콜 인터럽트를 날리고 있다.어셈블리로 재작성하기
우리는 여기서 필수적인 요소만 뽑아볼 수 있다.
- 인자 문자열
- 레지스터에 인자 문자열 주소 저장
- 시스템콜 인터럽트
위 요소들만 있으면 시스템콜을 호출해 실행중인 프로세스를 쉘로 바꿀 수 있다.
그래서 그 부분만 다시 적은 것이 위 어셈블리다. (읽기 편하라고 일부러 10진수로 쓴 것이 강의자료와 조금 다르다)
그리고 재미있게도 실행되지 않는다.
한 줄씩 실행하면서 원인을 알아보자.
여기서 바로 segmentation fault가 발생했다.
esi에 저장된 주소가 어느 영역인지를 고민해보면 쉽다. 바로 .rodata 영역이다.
쓰기 권한이 없는 영역에 쓰기를 수행했기 때문에 segfault가 발생했다.
해당 영역(.text) 영역에 쓰기 권한을 주면 실행된다.
그러나 우리가 원하는 건 이 쉘코드를 다른 프로그램에 집어넣는것. 다른 프로그램들은 보통 code 영역에 쓰기 권한이 없다.
따라서, 이 쉘 코드를 잘 추출해서 쓰기 가능한 영역 (스택 영역)에 넣고, 실행해보도록 하자.
그러면, 0x8049755+0x7(/bin/sh) 이 바로 쉘코드의 끝부분이라고 할 수 있다.
따라서, 프로그램의 시작부터, 끝까지 덤프를 뜨도록 하자.
dump memory shell_dump 0x08049728 0x8049755+0x7
xxd -p <덤프파일명> | tr -d '\n' | sed 's/../\\x&/g'
\xbe\x55\x97\x04\x08\xc6\x46\x07\x00\x89\x76\x08\xc6\x46\x0c\x00\xb8\x0b\x00\x00\x00\x89\xf3\x8d\x4e\x08\xba\x00\x00\x00\x00\xcd\x80\xb8\x01\x00\x00\x00\xbb\x00\x00\x00\x00\xcd\x80\x2f\x62\x69\x6e\x2f\x73\x68
이걸 이제 C 프로그램에 넣고 실행한다.
아까와 터진 부분이 다르다. 이번엔 실행 권한이 없는 것이 문제다.
해당 코드를 스택에 집어넣고, 스택 자체에 실행 권한을 줘본다.
0x8049755
위치는 .text
영역이다.
익숙한 주소일텐데, 아까 쉘 코드의 LC0 영역에 대한 주소다.
이것이 하드코딩 되어 있기 때문에 segfault가 발생한 것이다.
아무리 쉘코드를 스택 영역으로 옮기고, 스택 영역 자체에 실행 권한을 주었더라도, .text 영역에 write를 하고 있으니 즉시 segfault가 발생할 수 밖에 없다.
하드코딩된 주소를 동적으로 얻어올 수는 없을까?
Trampolining 기법
call과 pop을 잘 이용하면, 어느 시점의 eip를 저장했다가, 원하는 레지스터로 뽑아낼 수 있다.
문자열 바로 앞에 call 명령어를 두고, 프로그램 실행 부분에서 해당 부분으로 점프한다.
점프한 후 실행하려고 할 때, eip에는 call의 다음 주소가 담긴다. 그것이 바로 문자열의 시작 주소이다.
call = push eip + jmp 이기 때문에, 스택에 eip가 저장되며,
이것을 뽑는 명령어가 있는 곳(
pop %esi
)으로 점프하면 바로 스택에 저장되었던 eip가 esi에 저장된다.여기까지 하면, 문자열의 주소가 esi에 잘 들어온다.
정말 그럴까?
pop 하기전에 esp가 가리키는 부분의 내용을 살펴보자.
어떤 주소가 저장되어 있다.
그리고 그 주소는 실제로 문자열의 시작주소다!
그리고 실제로 다음 줄(
pop %esi
)를 실행하면 esi에 그 주소가 들어온다.위 기법을 적용해 컴파일한 프로그램에서 쉘 코드를 뽑는다.
시작은
0x8049728
, 끝은 (esi에 저장된 문자열의 주소 + 문자열 길이(7)) = 0x8049758+7 이다.\xeb\x29\x5e\xc6\x46\x07\x00\x89\x76\x08\xc6\x46\x0c\x00\xb8\x0b\x00\x00\x00\x89\xf3\x8d\x4e\x08\xba\x00\x00\x00\x00\xcd\x80\xb8\x01\x00\x00\x00\xbb\x00\x00\x00\x00\xcd\x80\xe8\xd2\xff\xff\xff\x2f\x62\x69\x6e\x2f\x73\x68
이제 이걸 아까 shelltest.c 에 넣고 실행해보자. 구분을 위해 shelltest2.c로 복사해서 수행하겠다.
마침내 쉘코드를 실행할 수 있었다.
여기서 끝이 아니다.
쉘코드를 정말로 어디다 투척하려면, 트리밍이 필요하다.
실행 환경(공격 대상)에 따라 null로 인해 읽기&실행이 끊겨버릴 수 있기 때문이다.
null이 존재하는 경우
strcpy는 스택버퍼오버플로우 취약점이 존재한다.
그러나 이 함수는 널을 만날 때까지 버퍼를 읽어 복사하기 때문에 쉘 코드에 널이 존재하는 경우 온전히 삽입되지 않는다.
그래서 몇가지 기법을 통해 쉘코드에서 널을 모두 제거해줄 수 있다.
어떻게든
00
을 동적으로 생성하고, 위 널들을 그것으로 대체해주면 되는것이 아니겠는가?예를 들면, xor을 통해 동적으로 0을 레지스터에 저장할 수 있다.
아직도 null이 있는 이유는 인스트럭션 때문이다.
mov $11, %eax
인스트럭션의 경우 좌변에 32비트를 그대로 받는다.거기에 11(0xb)를 집어넣으니,
0x0000000b
가 되어버린다.그래서 잘 살펴보면 첫째줄 끝과 둘째줄 시작에서 b0000000을 발견할 수 있다. (little-endian)
이건 더 작은 단위를 다루는 instruction,
movb
및 %al
을 사용해 제거할 수 있다.또 다른 널은 두번째 시스템콜
exit
에서 사용하는 mov $1, %eax
인스트럭션에서 발생한다.위와 같은 이유이다.
여기서는 조금 다른 방식으로, eax가 이미 0이기 때문에,
inc
인스트럭션을 사용해보자.이제 실행해보자. 구분을 위해
shelltest3.c
로 복사해서 사용한다.실행이 안된건 바로
movb $11, %al
이후에 문제가 있기 때문이다. 그 이후의 eax에는 0이 아니라, 11이 들어있다.
따라서
mov %eax, %edx
가 의도대로 일어나지 않았고, 시스템콜이 정상 호출되지 않았다.우리는 저 부분 이후를 변경해야 한다.
구분을 위해
shell3.c
로 복사해 진행한다.이제 이 쉘코드를 뽑아서, 다른 프로그램에 집어넣고 실행하면?
목적 달성.
*%gs:0x10
?
*도 있고 %도 있는데 뭐지? 라는 생각이 든다.
이 방식은 세그먼트 레지스터 간접 주소 지정(Segment Register Indirect Addressing)이라 한다.
%gs:0x10
부터 보자.AT&T-flavored Instruction에서
%
는, 레지스터를 가리킨다.그냥 gs:0x10이라고 하면 gs 레지스터+오프셋을 가리키는 것인지, gs라는 심볼+0x10을 가리키는 것인지 특정하기 어렵지 않겠는가?
그리고 나서 우리가 아는
*
, 즉 Indirect Addressing(=메모리 역참조)를 수행하면 되겠다.최종적으로 위 예시는 [ gs 레지스터에 담긴 값(GDT 일부)+0x10 이 가리키는 주소 ]을 호출(call)하라는 인스트럭션이라고 해석할 수 있겠다.
GDB 명령어와 어셈블리는 구분해야 한다
GDB를 쓰다보면
x/x $esp
같은 명령어로 레지스터가 담는 내용을 확인할 때가 있다.위에서는
%
를 사용했는데 좀 이상하다는 생각이 들었다.그러나 그건 AT&T 어셈블리 상의 문법이다.
GDB 명령어의 문법은 또 별도이고, 여기서는
$
가 레지스터를 가리킨다.더 헷갈리게도, AT&T 어셈블리 상에서
$
는 또 상수를 나타낸다.헷갈리지 않게 둘을 구분할 필요가 있다.
GS는 왜 GDT의 TLS를 가리키고 있는가?
Parsing process stack to find out AT_SYSINFO's value can be a cumbersome task.
pthread_getspecific(key); 이렇게 시스템 콜로 TLS를 찾아가야 하는데, 이렇게 costly한 작업을 매번 할 수 없다.
So, when libc.so (C library) is loaded, it copies the value of AT_SYSINFO from the process stack to the TCB
따라서 libc.so가 로드될 때 프로세스 스택의 AT_SYSINFO를 미리 로드하고, gs가 TLS를 가리키도록 한다.
결국 PCB가 TCB를 가지며, TCB가 TLS를 가지는 구조이다.
GS는
- GS는 GDT Entry를 가리키는 것인가? 아니면 TLS를 가리키는 것인가?
세그먼트 자체가 아니라 해당 세그먼트에 대한 GDT 엔트리를 가리키고 있으며, 세그먼트 어드레싱에 의해 GDT를 참조해서 base address를 찾고, offset(index)를 더해서 목표 주소를 찾아내게 된다.
lea
Instruction
이 부분은 필요할때 참고하기로 하고 넘어가서 잘 모른다.
수업 때 다루긴 했지만 정확히 이해하지 못했다.
의미는 load effective address. 유효한 주소를 로드한다는 뜻이다.
lea의 operand 1은 주소 자리이며, operand 2는 저장될 곳이다.
상수만 아니면 주소로 다룰 수 있기 때문에, 메모리 주소도 들어갈 수 있다.
다만 메모리 주소를 넣는 경우에는 Register Indirect Addressing 이 안되므로 쓰는 의미가 좀 퇴색되는 것 같다.
- (주목적) 주소를 연산해서 레지스터에 로드한다.
이 동작을 mov로도 구현가능하지 않을까?
물론 되긴 하는데, 좀 길다.
게다가,
add
나 mul
같은 산술 명령어를 사용하는 도중 플래그가 변하기 때문에, 둘은 결과는 같지만 과정이 다르게 된다.특히나, 뭔가 복잡한 일을 수행하고 있을 때, 플래그가 수정되어 버리면 언제 이걸 돌리고 있겠는가?
- (다른 목적으로 활용) 플래그(eflags)를 건드리지 않고 덧셈 연산을 하는데 쓰인다.
그래서 이런 연산에 활용할 수 있는 것이다. 닭 잡는데 소잡는 칼 쓰는 느낌이긴 하다.
flag에 의존하는 다른 동작이 있다면 오류가 발생할 가능성도 있다. (ZF가 안바뀌는 등…)
좀 더 제대로 사용한다면, 주소 연산에 사용할 수도 있겠다.
결론
그 목적에 알맞게—
lea
는 주소 연산후 로드에, mov
, add
, mul
등은 산술 연산에—사용하도록 하자.Addressing 방법들 (x-based 제외)
- Register Addressing -
%
레지스터 그 자체를 표현한다.
메모리 0x80ee320 번지에 저장된 내용은?
- Immediate Addressing -
$
그냥 상수. 이걸 어드레스라고 해야하나? 싶다. 주소가 아니라 그냥 상수다.
주소 operand 자리에 $0xffffffff ← 이런식으로 사용 불가하다.
주소를 상수로 임베딩시키고 싶은거라면, 아래 나오는 Direct Addressing을 사용해야 한다.
위 2개 인스트럭션을 수행하면 16진수 0xfffff001과 0xfffff001를 비교한 결과가 ZF 플래그에 반영된다.
- Direct Addressing - 없음
메모리 주소값을 직접 표현한다.
- Register Indirect Addressing - %
레지스터 간접 어드레싱이라는 것을 기억하자. 상수에는 이런식의 연산이 불가하다.
상수를 더하고 싶다면 add instruction을 사용해 별도로 더해줘야 한다.
포인터에 익숙하다면 마지막 줄을
edx = *(esp+0x10)
라고 이해해도 좋다.또한 괄호를 치고 안치고는 레지스터 값에 접근하느냐, 레지스터에 담긴 메모리 주소에 접근하느냐의 차이가 있다.
즉, Addressing이 아닌 것과 Register Indirect Addressing인 것의 차이가 있는 것이다.
괄호를 친 인스트럭션의 경우 메모리 값(=10)을 edx에 잘 불러온다.
그러나 그 다음 인스트럭션의 경우 레지스터에 담긴 내용(변수 a의 주소)를 edx에 불러온다.
일부 비울수도 있다.
이 경우 base를 넣지 않고,
sys_call_table+%eax*4
를 가리킨 것이다.mov 계열 Instructions
mov 뒤에 붙은 문자는 사이즈에 관한 것.
- mov
기본적으로는 movl과 동일하며, 대상 레지스터 크기에 맞춰 동작한다. (크기에 좀 유연하다는 장점이 있는듯?)
사이즈를 명시하지 않는다는 특성 때문에 불명확하게 동작할 수도 있다.
그래서 레지스터가 명시되지 않아 크기를 알 수 없으면, 컴파일러에 의해 movl로 대체된다.
- movl
32비트(dword) 만큼 복사
- movw
16비트를 옮긴다. 32비트인 경우, 상위 16비트는 사라진다.
- movb
8비트를 옮긴다. 32비트인 경우 상위 24비트는 사라진다.
위에서 사용했던
movb $11, %al
이 11로 정확히 인식될 수 있는 이유도 이것 때문이다.