<Homework>
1. Handray 폴더 안에 존재하는 HW 파일의 main 및 func를 어셈블리어만 보고 C 언어로 바꾸기
1.2 그 과정에서의 main의 스택 프레임 그리기
2. Pay1oad Welcome CTF에 출제되었던 cute 및 Little_shell 문제 풀이 및 라이트 업 작성하기
<Environment>
MAC Sequoia 15.1 Beta (24B5035e)
IDA Freeware 8.2
첫 번째로 Handray 폴더 안에 있는 HW file을 IDA로 열어본다.
맥에서 해당 file의 확장자가 보이지 않아 file 검색어로 확인해 보니 아래 사진과 같이 나오던데 assembly file로 생각되는데 이 부분은 추후 더 알아봐야겠다.
위에서 기술했다시피 IDA를 사용해 assembly code를 살펴보았다.
왼쪽의 Function list에서 main func를 찾아서 들어가 본다. call 함수를 확인해 보면 각각 _puts, _printf, _scanf, _func 라는 4개의 함수를 main function안에서 호출하고 있음을 확인할 수 있다. 부가적으로, 이 program이 대충 어떤 것을 출력하는지, 저장된 문자열이 어떤 것인지를 확인하고 싶으면 Strings subviews, 즉 프로그램 바이너리 내에 포함된 문자열을 나열해 주는 기능을 사용하면 된다. 이를 통해 코드 분석자는 프로그램 내에서 사용된 다양한 문자열을 볼 수 있고 문자열은 종종 프로그램의 기능이나 작동 방식에 대한 힌트를 제공하기 때문에 생각보다 유용하게 쓰이는 경우가 많다.
위 사진에서도 확인할 수 있듯이 Hello World, Input digit A: 등등 문자열의 입출력과 관련이 있음을 확인할 수 있다.
다시 assembly code로 돌아가서 control flow를 자세히 살펴보자.
push rbp
mov rbp, rsp
우선 rbp를 stack에 push 하고 rsp를 rbp의 위치로 이동시킨다. 이는 main 함수의 프롤로그 부분(rbp(base pointer)를 스택에 저장하고 현재 rsp(stack pointer)를 rbp에 저장하는 것)에 해당한다. 이는 특정 function terminate 후, 원래의 excute location으로 return 하기 위해 수행되는 일련의 작업이다.
sub rsp, 20h
mov [rbp+var_14], edi
mov [rbp+var_20], rsi
mov [rbp+var_C], 0
mov [rbp+var_10], 0
rsp에서 0x20(10진수로 32)를 substract 해준다. add가 아니고 sub 연산을 하는 이유는 memory stack의 경우, 높은 주소에서 낮은 주소로 스택이 쌓이기 때문에 0x20 만큼의 공간을 빼줌으로써 변수를 담을 공간을 할당한다.
추측하건대 변수 var_14, var_20을 선언하고, 변수 var_C, var_10 선언 후 0으로 초기화하는 것을 mov instruction을 통해 확인할 수 있다.
위 사진에 해당하는 assembly code를 살펴보자.
lea rax, s
lea rax, format
lea rax, aD
lea rax, aInuptDigitB
lea rax, aSumABD
*살펴보기 전, 위 instuctions를 확인해 보면 lea instruction으로 some variables("s", "format", "aD", "aInputDigitB", aSumABD")의 address를 rax로 이동하는데 여기서 이 some variables는 program에서의 variable name이 아닌 IDA에서 임의로 할당한 variable임을 알아두자.
여기서 확인할 부분은 assembly code 중간중간에 call instuction으로 무언가를 계속 호출함을 알 수 있는데, programming language를 조금이라도 배워본 사람은 눈에 익는 function(puts, printf, scanf etc.)이 보일 것이다. 그럼 이러한 fuction을 호출하기 위해서는 실행에 필요한 인자를 할당해야 한다(없는 경우도 있지만).
lea rax, s
mov rdi, rax
call put_
variable s의 address를 rax로 이동시키고 이 rax를 rdi로 이동시킨다. 이후 fuction puts를 call 해서 fuction을 excute 시키는 것을 확인할 수 있다. 여기서 rdi는 Destination Index로 함수 호출 시 첫 번째로 전달되는 register이다. 여담으로, 두 번째, 세 번째로 전달되는 register는 rsi, rdx이다.
--------------------
lea rax, format
mov rdi, rax
mov eax, 0
call _printf
---------------------
lea rax, aInputDigitB
mov rdi, rax
mov eax, 0
call _printf
---------------------
fuction printf execute 전까지의 assembly code를 살펴보자. 각각 format, aInputDigitB라는 variable의 address를 rax로 이동시킨 후, rax의 값을 rdi로 이동, 0을 eax로 이동시키는 것을 확인할 수 있다. 근데 여기서 fuction puts와 printf 실행 전의 부분에서의 difference를 확인할 수 있다. 바로 "mov eax, 0" 부분인데, 이는 puts와 printf의 인자 처리 부분에서 확인할 수 있다. eax는 fuction call 할 때, return value를 저장하는 register이기도 하지만, system call 규약에서는 fuction call 전에 특정 register를 준비해야 한다. printf는 "가변 인자 함수" 로써 function call 전에 mov eax, 0이라는 instruction이 필요하다. System V ABI(Application Binary Interface)에 따르면, 가변 인자 함수 호출 시, eax에는 called function의 vector registor(예: xmm 레지스터)를 몇 개나 사용하는지 알리는 정보가 담겨야 한다. 이 경우에는 가변 인자를 처리할 vector register를 사용하지 않기 때문에 mov eax, 0을 통해 0으로 초기화하는 것이다.
*System V ABI(Application Binary Interface)
: x86-64 아키텍처에서 사용되는 함수 호출 규약 중 하나로, 프로그램의 컴파일된 바이너리들이 운영체제에서 어떻게 상호작용할지를 정의하는 규칙 집합. ABI는 program의 function이 call 되고 return 될 때, 매개변수가 어떻게 전달되고, register와 stack이 어떻게 사용되는지를 규정하여 서로 다른 compiler나 library가 문제없이 동작할 수 있도록 한다.
System V ABI의 주요 요소
1. 함수 호출 규약 (Calling Convention):
• 함수가 호출될 때, 매개변수가 어떻게 전달되는지와 호출자와 피호출자 함수 사이에서 누가 어떤 레지스터를 보존해야 하는지 규정한다.
2. 매개변수 전달 방식:
• 함수 인자(매개변수)는 주로 레지스터를 통해 전달된다. 처음 6개의 인자는 레지스터로 전달되고, 그 이후의 인자들은 스택을 통해 전달된다.
• 첫 번째 인자: rdi
• 두 번째 인자: rsi
• 세 번째 인자: rdx
• 네 번째 인자: rcx
• 다섯 번째 인자: r8
• 여섯 번째 인자: r9
• 6개 이상의 인자가 필요한 경우에는 추가적인 인자는 스택을 통해 전달된다.
3. 함수 반환 값:
• 함수가 반환하는 값은 주로 rax 레지스터를 통해 반환된다. 부동소수점 값은 xmm0 레지스터를 사용해 반환된다.
4. 레지스터 사용 규칙:
• 콜러-세이브(Callee-save) 레지스터: 함수가 호출된 후 보존해야 하는 레지스터. 예를 들어, rbx, rbp, r12-r15는 함수가 종료된 후에도 호출자가 자신의 값을 보존해야 하므로 함수 내에서 변경 시 해당 레지스터 값을 보존해야 한다.
• 콜러-클로버(Caller-save) 레지스터: 함수 호출 전후에 호출자가 값을 보존해야 하는 레지스터. 예를 들어, rax, rdi, rsi, rdx, rcx, r8, r9는 호출자에 의해 덮어씌워질 수 있다. 따라서 호출자는 함수 호출 전에 필요한 값을 보존해야 한다.
5. 스택 프레임:
• 스택은 함수 호출 시 16바이트로 정렬되어 있어야 한다. 스택 포인터(rsp)는 함수가 호출되기 전에 16바이트 배수로 맞춰져야 한다.
• 스택 프레임은 함수가 호출될 때 스택에 생성되며, 지역 변수, 호출된 함수의 반환 주소, 그리고 함수 호출 전의 베이스 포인터(rbp)가 여기에 저장된다.
6. 가변 인자 함수:
• 가변 인자 함수(printf처럼 인자 수가 정해지지 않은 함수)를 호출할 때, eax 레지스터에 벡터 레지스터(xmm)의 사용 개수를 명시해야 합니다. eax가 0인 경우에는 벡터 레지스터가 사용되지 않는다는 의미이다.
-----------------------
lea rax, [rbp+var_C]
mov rsi, rax
lea rax, aD
mov rdi, rax
mov eax, 0
call ___isoc99_scanf
-----------------------
lea rax, [rbp+var_10]
mov rsi, rax
lea rax, aD
mov rdi, rax
mov eax, 0
call ___isoc99_scanf
-----------------------
다음으로, function scanf execute 전까지의 assembly code를 살펴보자. variable var_C, var_10의 address를 rax에 저장한다. rax의 값을 rsi로 이동하고 variable aD의 address를 rax로 이동시킨다. 여기서 aD는 int형 형식 지정자 "%d"를 의미한다. 이 rax를 다시 rdi로 이동시킨다. 위 function printf part에도 보았듯이 rdi를 인자로 function을 execute 함을 확인할 수 있다. scanf도 가변 인자 함수이기 때문에 eax를 0으로 set 하는 것을 확인할 수 있다. printf와의 차이점을 눈치 했는가? index register가 2개가 사용됨을 확인할 수 있다. 이는 scanf의 사용 방법에서 확인할 수 있는데 보통 scanf("%d", &var);의 형태로 사용이 되는 반면 printf("Hello World");의 용례와 같이 printf의 경우 현재 function main에서 1개만 받는다. 즉, 인자의 개수차이로 인해 위와 같은 차이가 발생한 것이다. 자세한 건 위 첨부된 내용을 확인하면 된다.
이후 variable var_10, var_C를 각각 edx, eax로 이동시킨 후 다시 esi, edi로 이동시킨다. 이후 func를 호출하는데 이 function func에 대해 알아보자.
function func 부분에 해당하는 assembly code이다.
우선 맨 윗줄에 "endbr64" 라는 instruction을 확인할 수 있는데 이는 intel의 CET(Control-flow Enforcement Technology) 기능 중 하나인 “간접 분기 보호”(Indirect Branch Tracking)를 지원하는 instruction이다. CET는 메모리에서 발생할 수 있는 보안 취약점, 특히 ROP(Return-Oriented Programming) 및 JOP(Jump-Oriented Programming)와 같은 공격을 방지하기 위해 도입된 기술이다. endbr64는 64비트 환경에서 이러한 간접 분기가 안전하게 수행될 수 있도록 하는 역할을 하는데 endbr64는 함수 시작 부분(call)이나 간접 분기(jmp)가 예상되는 곳에 반드시 삽입되며, 프로그램 실행 중 간접 분기(함수 포인터 호출 등)가 발생할 때 해당 명령어를 만나면 올바른 분기임을 확인한다. 만약 endbr64 없이 분기가 발생하면 CET가 이를 탐지하고 프로그램을 중단시켜 잠재적인 보안 위협을 방지한다.
function main과 같이 function의 prologue 부분을 push rbp / mov rbp, rsp 부분을 통해 확인할 수 있다.
edi과 esi를 variable var_4, var_8에 각각 이동시키는 것을 할 수 있다. 아까 edi와 esi에 variable var_10, var_C가 들어있었다는 점으로 미루어보아, function func가 해당 variable의 값을 인자로 받고 있음을 유추할 수 있다. 값이 저장된 var_4, var_8을 각각 edx, eax에 이동시킨 후, edx에 eax를 더한 값을 eax에 저장하고 rbp를 pop 하는 모습을 확인할 수 있다. 이러한 흐름으로 볼 때, function func는 인자 2개를 받아 덧셈한 값을 return 하는 함수임을 확인할 수 있다. 마지막 줄의 func endp는 func로 define 된 function이 여기서 끝난다는 것을 알려준다. 또한 그 윗줄의 주석은 0x401176 address에서 해당 function이 실행됨을 나타낸다.(친절한 IDA..)
다시 function main으로 돌아와서, eax, 즉 function func의 return valuer가 들어있는 eax를 다시 variable var_4로 옮긴 후 또 한 번 var_4에서 eax로 이동시킨 후 이 값을 esi로 이동시키는 것을 확인할 수 있다. 근데 여기서 좀 이상하지 않은가? eax에 func가 return 한 value가 저장되어 있다고 하면 그냥 바로 mov esi, eax 하면 될 거 같은데 왜 굳이 이러한 번거로움을 거쳐야 하는지 의문점이 생겼다. 이 이유에 대해 알아보니 이 경우에서의 가능성은 3가지가 있었다.
1. 레지스터 보존 및 호출 규약에 따른 동작
일반적으로 함수가 값을 반환하면, return value는 register eax에 저장된다. But, other register(esi etc.)를 사용해야 하는 경우, calling convention에 따라 register value을 보존해야 할 수 있다. 함수 호출 이후 바로 mov esi, eax로 값을 옮기지 않는 이유는:
• another function call이나 연산 중 register esi가 another purpose로 사용될 수 있기 때문이다.
• eax의 값을 stack에 저장해 두면 다른 연산 중에도 이 value을 안전하게 보존할 수 있다.
2. 디버깅이나 최적화 미흡
디버깅 모드에서는 컴파일러가 최적화를 최소화하며, 변수들의 중간 값을 저장하는 방식을 택한다. 이렇게 하면 디버깅 시 변수의 값들을 확인하기 쉽기 때문에:
• eax의 반환 값을 var_4에 저장함으로써 프로그램 실행 중에 이 값을 추적할 수 있게 된다.
• 최적화가 잘 되지 않았거나 일부러 최적화가 제한된 컴파일러 설정인 경우 이러한 중간 저장이 발생할 수 있다.
3. 가독성 또는 안정성을 위한 중간 저장
함수가 복잡한 경우, 반환 값을 바로 다른 레지스터로 옮기는 것보다 중간 변수(var_4 등)에 저장했다가 나중에 옮기는 것이 가독성이나 안정성 측면에서 유리할 수 있다. 만약 eax가 이후의 연산에서 변경될 가능성이 있으면, 중간 값을 스택에 백업해 두고 다시 사용할 때 그 값을 읽어와 활용하는 방식으로 안정적인 코드를 작성할 수 있다.
"결론적으로, eax 값을 var_4에 저장한 후 다시 eax로 옮기는 이유는 컴파일러의 호출 규약, 디버깅을 위한 안전성, 또는 레지스터 관리 정책에 따른 것일 가능성이 높다. 최적화된 컴파일러에서는 바로 mov esi, eax를 수행할 수 있지만, 일반적으로 이러한 중간 저장은 코드의 안정성이나 디버깅을 위해 의도적으로 추가되는 경우가 많다."라는 것으로 이해했다.
lea rax, aSumABD
mov rdi, rax
mov eax, 0
call _printf
"Sum a, b: %d\n"이라는 string이 저장되어 있는 aSumABD의 address를 rax로 이동시키고, rax를 rdi로 이동시킨 후 0을 eax로 이동시킨다. 위에서 살펴보았다시피, printf는 가변 인자 함수이고 여기서는 인자 1개만 받고 있으므로 eax에 0이 할당된다. function printf의 인자인 aSumABD를 받아 printf를 call 하는 것을 확인할 수 있다.
이후 이어질 부분이 난이도가 좀 있다.
자세히 보기 전, 우선 크게 한번 structure를 살펴보자. 중간중간 비교해서 분기하는 부분이 보이고 특정 value를 substract 하거나 addition 하는 것을 확인할 수 있다. 그리고, 특정 연산이 끝나고 나면 다시 이전 부분으로 분기하는 것을 보았을 때 loop라는 것을 눈치챌 수 있었다. 조금 더 자세히 살펴보자.
우선 integer 1을 variable var_8에 할당한다. 여기서 중요하게 생각할 부분은 이후 발생하는 여러 분기에서 해당 부분으로는 다시 넘어오지 않는다. 즉 var_8이라는 variable에 1을 지정했다는 말은 초기 값일 수 있다는 점을 시사한다. 또한, function main의 초기 부분에서 variable를 선언할 때 var_8이라는 variable은 선언되지 않았다는 점에서 for문의 초기값이지 않을까라는 것을 추론할 수 있었다.(ex. for(int i = 1; ; ))
이후부터는 loop 안의 내용임을 짐작할 수 있다. variable var_8을 eax로 이동시키고, var_4로부터 eax의 value를 substract 한 후 value를 var_4로 이동시킨다. 그리고 이 variable var_4를 0과 cmp 한다. 여담으로 이 cmp instruction은 operand끼리 묵시적으로 substract 하여 value를 compare 한다.
이후 instruction jns(jump if not sign)를 확인할 수 있는데, 이는 "Jump if sign flag not set"라는 의미이다. 즉, SF(Sign Flag)가 0이라면, loc_40127B 주소로 jmp 한다는 instruction이다. 여기서 SF에 대해 조금 더 살펴보면, 최상위 비트(MSB)가 1일 경우 즉, 음수일 때 1로 setting 되고 양수, 혹은 0일 때 0으로 setting 된다. 이는 2의 보수 표현에 근거한 것으로 연산 결과의 MSB가 1이면 SF = 1, MSB가 0이면 SF = 0으로 설정된다. 여기서 "sign flag set" 이 의미하는 것은 SF = 1이다. 즉, 음수라는 것이다. 다시 돌아가서, jns를 해석해 보면 not set, 즉 SF = 0일 때, 양수이거나 0일 때 jmp 한다는 것을 알 수 있다. (*주의. 필자는 jne와 헷갈려서 사고의 loop문을 한참 돌았다. Conditional Branch Instruction(조건 분기 명령어)와 Status Register(상태 레지스터)를 확실히 숙지하여 앞으로의 accident를 방지하자..)
*Address loc_401275로 jmp 했을 경우 (SF = 0, var_4 >= 0)
add [rbp+var_8], 1
jmp short loc_40124E
variable var_8에 1을 addition 한 후, 다시 시작 위치인 loc_40124E로 돌아간다. 즉, condition을 충족할 때까지 var_8에 1을 addition 함을 알 수 있다.
처음으로 돌아와서, 다시 var_8을 eax로 이동하고 var_4와 substract 하는 것을 확인할 수 있다. 처음에 var_8이 1이라는 점을 생각해 봤을 때 loop를 돌 때마다 1을 더해서 해당 variable과 substract 하여 cmp 하는 동작 과정을 확인할 수 있다.
*condition에 충족했을 경우 (SF = 1, var_4 < 0)
mov eax, [rbp+var_8]
mov esi, eax
lea rax, aRepeatD
mov rdi, rax
mov eax, 0
call _printf
jmp short loc_40127B
variable var_8을 eax로 이동하고 이를 esi로 이동 후, "repeat %d\n"라는 string이 담겨있는 aRepeatD의 address를 rax로 이동시킨다. 이 rax를 rdi로 이동시키고 위와 같이 printf를 call 하기 전, 0을 eax로 이동한다. function printf의 첫 번째 인자인 aRepeatD("repeat %d\n"), 두 번째 인자인 var_8을 받아 printf가 call 되는 것을 확인할 수 있다. 여기서 주의해야 할 부분은, repeat라는 string을 보았을 때는 마치 이 string이 repetation 하게 print 되는 것처럼 보이지만 우리는 자연어가 아닌 assembly에 주목해야 된다는 사실을 망각하면 안 된다. 위에서 살펴봤듯이 이는 condition에 충족해 loop에서 빠져나온 후의 code임을 명심하자. function printf를 call 하여 "repeat %d\n" print 후, loc_40127B location으로 jmp 한다.
cf) IDA Graph View
mov eax, 0
leave
retn
main endp
eax에 0을 저장한다. 이는 function main의 return 0; 부분으로 generally, main function의 경우 return value가 process의 terminate code를 나타내는데, 이는 성공적인 terminate를 의미한다. 이후, instruction leave를 통해 function main의 stack frame을 정리하는데 이는 2가지의 행동이 담겨있다.
- mov rsp, rbp: stack pointer(rsp)를 base pointer(rbp)로 복원하여 stack frame을 해제한다.
- pop rbp: rbp register를 stack에서 pop 하여 caller function의 stack frame pointer로 복원한다.
이후, retn을 통해 현재 function에서 caller function으로 제어를 반환하며 stack에 저장된 return address를 stack pointer(rsp)에서 pop 하고, 해당 address로 program flow를 이동시키며 종료한다.
이러한 execute flow를 살펴보았을 때, 이 assembly code를 통해 C language code로 변환한 내용은 아래와 같다.
#include <stdio.h>
int func(var1, var2){
int a, b;
a = var1;
b = var2;
return a+b;
}
int main() {
int var_C = 0;
int var_10 = 0;
puts("Hello World");
printf("Input digit A:");
scanf("%d", &var_C);
printf("Input digit B:");
scanf("%d", &var_10);
int var_4 = func(var_C, var_10);
printf("Sum a, b: %d\n", var_4);
for(int var_8=1; ;var_8++){
var_4-=var_8;
if(var_4<0){
printf("repeat %d\n", var_8);
break;
}
}
}
integer A, B를 입력받아 두 인자의 addition을 수행하는 function인 func를 수행 후, return값을 variable에 저장하고 for 문을 loop 하면서 1씩 증가되는 값을 빼는 동작을 반복하고 variable var_4의 value가 음수가 될 때 해당 value를 print 하는 program이라는 것을 확인할 수 있다.
*오류가 있을 수 있으니 학습 용도로 사용 혹은 공유할 경우 출처 표시 및 사실 확인이 필요함을 알립니다.
'Pay1oad > Pwnable Study' 카테고리의 다른 글
[Pay1oad] Pwnable Study Week 3 - 3week_HW: r2c (0) | 2024.11.11 |
---|---|
[Pay1oad] Pwnable Study Week 2 - bof_basic (0) | 2024.11.10 |
[Pay1oad] Pwnable Study Week 2 - mic_check (0) | 2024.11.09 |
[Pay1oad] Pwnable Study Week 1 - Pay1oad Welcome CTF Writeup (0) | 2024.10.01 |