[Week2] Reversing Study - x86 어셈블리
리버싱 스터디 2주차 🖥️
x86 Assembly: Essential Part(1)
어셈블리어와 x86-64
어셈블리어
컴퓨터의 기계어와 치환되는 언어
- 기계어가 여러 종류라면, 어셈블리어도 여러 종류
어셈블리어의 기본 규칙
Intel: NASM에서 사용 AT&T: GAS에서 사용
x86-64 어셈블리어
기본 구조
- 명령어(Operation Code, Opcode)와 피연산자(Operand)로 구성
- 피연산자는 연산의 입력값이 된다.
명령어 - 후술
피연산자
- 상수, 레지스터, 메모리가 위치할 수 있다.
- 메모리는
[]에 둘러싸인 것으로 표현되며, 크기 지정자TYPEPTR이 위치할 수 있다.mov [rdi], eax ; (X) 오류 발생 가능: 몇 바이트를 가져올 것인지 크기 지정이 명확하지 않음 mov DWORD PTR [rdi], eax ; (O) 명확한 크기 지정
- 메모리는
- 운영체제가 발전함에 따라 다룰 수 있는 메모리의 영역이 확장 되어, 과거 명령어와의 호환을 위해 바이트를 구분하여 피연산자를 정의한다.
산술 연산과 논리 연산
add, sub, mul, div
어셈블리어에서 사칙 연산에 해당하는 명령어들이다.
- add: 덧셈
add <destination>, <source> // destination + source의 값을 destination에 대입- 두 피연산자를 더하는 명령어이다.
- sub: 뺄셈
sub <destination>, <source> // destination - source의 값을 destination에 대입- 두 피연산자를 빼는 명령어이다.
- mul: 곱셉 (imul: 부호 있음)
mul <source>- mul 명령어는 암시적으로 RAX를 첫 번째 피연산자로 사용, source를 두 번째 피연산자로 사용한다.
- 두 레지스터의 곱셉 결과는 32bit의 경우 최대 64bit, 16bit의 경우 최대 32bit 이므로, 레지스터 2개를 사용하여 상위 bit와 하위 bit을 관리한다.
imul <source> ; 피연산자가 1개 imul <destination>, <source> ; 피연산자가 2개 imul <destination>, <source>, <immediate> ; 피연산자가 3개- imul 명령어는 최대 3개의 피연산자가 올 수 있다.
- 피연산자가 1개일 경우: mul과 같은 양상으로 동작
- 피연산자가 2개일 경우: destionation * source의 결과를 destination에 대입
- 피연산자가 3개일 경우: source * immediate의 결과를 destination에 대입
- div: 곱셉 (idiv: 부호 있음)
and, or ,xor, not
컴퓨터가 비트 단위로 데이터를 처리할 때 사용하는 논리연산에 사용하는 명령어이다.
- and: 두 개의 비트 모두 1일 때만 결과가 1
AND destination, source - or: 하나 이상의 비트가 1인 경우 결과가 1
OR destination, source - xor: 두 개 비트가 다를 경우 결과가 1
XOR destination, source - not: 비트 반전
NOT destination
inc, dec
레지스터나 메모리에 저장된 값을 1씩 증가/감소시키는 명령어
- inc: 1 증가
inc <레지스터/메모리> - dec: 1 감소
dec <레지스터/메모리>
데이터 저장과 이동
mov
데이터를 복사/이동하는 명령어. 레지스터 간 혹은 레지스터와 메모리 간 데이터를 이동시킬 수 있다. source의 값을 destination에 대입한다고 해석하면 된다.
mov <destination>, <source>
- destination: 값을 저장할 목적지 (레지스터나 메모리)
- source: 이동할 원본 (레지스터, 메모리, 즉시값)
lea
메모리에 실제 접근이 아닌, 유효 주소(EA)를 저장하기 위해 사용하는 명령어. 포인터와 유사하다.
lea <destination>, <source>
- lea는 메모리 주소를 계산하고, mov는 메모리에 직접 접근하여 값을 가져온다.
비교 연산과 분기문
비교
비교 명령어는 두 피연산자의 값을 비교하여 플래그를 설정하는 명령어이다.
- cmp
cmp <destination>, <source>- destination과 source를 빼는 방식으로 플래그를 갱신
- 값이 0일 경우 ZF가 1로 활성화
- destination과 source를 빼는 방식으로 플래그를 갱신
- test
test <destination>, <source>- destination과 source를 AND 연산한 뒤, 결과는 버리고 플래그 레지스터만 갱신
- 특정 비트가 0인지 아닌지 확인/레지스터가 0인지 확인
- 둘 중에 하나라도 0이라면 ZF가 1로 활성화
분기
프로그램의 실행 흐름을 바꾸는 명령어. 명령어 포인터를 변경한다.
- jmp
jmp foo ; foo로 이동 foo: ...- 특정 주소로 rip(다음에 실행할 명령어를 가리키는 레지스터)를 바꾸는 명령어
- je / jz
mov rax, 10 mov rbx, 10 cmp rax, rbx ; (rax - rbx) = 0 -> ZF=1 je label ; ZF=1이면 label로 점프- ZF가 1이면 jump를 수행하는 명령어. 두 피연산자의 값이 값을 경우 특정 동작을 수행하고 싶을 때 사용
- jg / jge
mov eax, 5 mov ebx, 2 cmp eax, ebx ; (5 - 2) = 3 > 0 -> ZF=0, SF=0 jg label1 ; 크면 점프 jge label2 ; 크거나 같으면 점프- 두 피연산자를 비교하여 둘 중 특정 값이 더 클 경우 jump를 수행하도록 함.
- jl / jle
mov eax, -10 mov ebx, 0 cmp eax, ebx ; (-10 - 0) = -10 < 0 -> SF=1, ZF=0 jl label1 ; 작으면 점프 jle label2 ; 작거나 같으면 점프- 두 피연산자를 비교하여 둘 중 특정 값이 더 작을 경우 jump를 수행하도록 함.
- jne / jnz
cmp rax, rbx ; rax - rbx != 0 => ZF = 0 jnz label ; rax != rbx 이면 점프- 두 피연산자가 다른 값일 경우 jump문을 수행하도록 함.
반복문
특정 조건이 만족될 때까지 주어진 동작을 반복해서 수행하는 문법. 크게 3가지 형식으로 구현이 가능하다.
- 카운터 기반 반복문 (for)
mov rcx, 10 ; 반복 횟수 설정 loop_start: ; 반복할 코드 loop loop_start ; RCX를 감소시키며 0이 아닐 때까지 반복 - 조건 기반 반복문 (while)
check_condition: cmp rax, 50 ; 특정 조건 확인 jge loop_exit ; 조건이 만족되면 종료 ; 반복할 코드 jmp check_condition ; 다시 비교 연산으로 이동 loop_exit: - 후조건 검사 반복문 (do-while)
do_loop: ; 반복할 코드 실행 cmp rax, 10 ; 조건 검사 jl do_loop ; 조건이 만족하면 다시 실행반복문에서 주의할 점
x86 Assembly: Essential Part(2)
함수/프로시저
함수
어셈블리어에서 프로그램이 처리해야 할 명령어들을 한 덩어리로 모아 놓은 코드 블록. 라벨로 특정 구역을 표시하고 call을 활용하여 함수로 사용 가능.
Caller는 call 명령어로 함수를 불러서 사용, Callee는 ret 명령어를 사용하여 이전 함수에서 실행 중이던 코드로 돌아감.
스택 관련 명령어
rsp: 스택에서 가장 중요한 요소로, 스택 포인터 레지스터이다. 항상 스택의 가장 위를 가리킨다.
- push: 스택에 값을 저장하는 명령어
- 실행 시, rsp가 감소하면서 스택에 값이 저장된다.
- rsp가 감소하는 까닭은 스택은 위에서 아래로 자라기 때문이다.
- 실행 시, rsp가 감소하면서 스택에 값이 저장된다.
- pop: 스택에서 값을 꺼내는 명령어
- 실행 시, rsp가 증가하면서 스택에 저장된 값을 꺼낼 수 있다.
- 보통, rax에 저장된다.
- 실행 시, rsp가 증가하면서 스택에 저장된 값을 꺼낼 수 있다.
함수 호출 및 반환 관련 명령어
- call: 함수를 부르는 행위
- return: 함수에서 원래 흐름으로 돌아오는 것
- 따라서, call 다음의 명령어 주소를 스택에 저장한 뒤, rip를 이동시켜 return이 가능토록 한다.
- leave: 프로시저가 반환되기 전, 스택 프레임을 정리하는 leave 명령어
mov rsp, rbp pop rbp시스템 보안(1) - 프롤로그/에필로그, 호출 규약, BOF, 보호 기법
추가적인 내용은 이전에 정리해둔 블로그를 첨부합니다.
어셈블리에서의 함수 선언 함수 선언은 다음과 같은 단계를 거친다.
- 함수 시작 위치를 나타낼 라벨을 정의한다.
- 스택 프레임이 필요한 경우, 함수 프롤로그를 통해 스택 프레임을 구성한다.
- 함수 내부에서 실제 동작을 구현한다.
- 함수 마지막에 함수 에필로그를 통해 스택 프레임을 해제하고,
ret명령어로 종료한다.
- x86
- x86에서는 cdecl 함수 호출 규약을 통해 함수를 선언한다.
add: push ebp mov ebp, esp mov eax, [ebp + 8] add eax, [ebp + 12] leave ret
- x86에서는 cdecl 함수 호출 규약을 통해 함수를 선언한다.
- x64
- x64에서는 SYSV 함수 호출 규약을 통해 함수를 선언한다.
add: push rbp mov rbp, rsp mov rax, rdi add rax, rsi pop rbp ret - x64에서는 주로 레지스터로 주고받는다.
- x64에서는 SYSV 함수 호출 규약을 통해 함수를 선언한다.
함수 호출 과정
- x86
- 32bit 환경에서 cdecl 함수 호출 규약을 사용할 경우, 함수에 전달할 인자를 스택에
push한 뒤call명령어로 함수를 호출한다. 함수가 끝난 뒤에는 Caller가 스택 정리를 한다.
section .text global _start add: push ebp mov ebp, esp mov eax, [ebp + 8] add eax, [ebp + 12] leave ret _start: push dword 20 push dword 10 call add add esp, 8 mov ebx, eax mov eax, 1 int 0x80 - 32bit 환경에서 cdecl 함수 호출 규약을 사용할 경우, 함수에 전달할 인자를 스택에
- x64
- 대부분의 정수 인자가 레지스터를 통해 전달된다. 2개의 인자를 가진 경우라면,
rdi,rsi순으로 인자라 들어간다. 함수 호출 전 Caller에서 설정한 뒤, 함수를 호출한다.
section .text global _start add: push rbp mov rbp, rsp mov rax, rdi add rax, rsi pop rbp ret _start: mov rdi, 10 mov rsi, 20 call add mov rdi, rax mov rax, 60 syscall - 대부분의 정수 인자가 레지스터를 통해 전달된다. 2개의 인자를 가진 경우라면,
시스템 콜
Opcode: 시스템 콜
- 커널 모드
- 운영체제가 전체 시스템을 제어하기 위해 시스템 소프트웨어에 부여하는 권한
- 모든 메모리 영역에 접근 가능
- 하드웨어에 직접 접근 가능
- 모든 저수준의 작업은 사용자 모르게 커널 모드에서 진행됨
- 운영체제가 전체 시스템을 제어하기 위해 시스템 소프트웨어에 부여하는 권한
- 사용자 모드
- 운영체제가 사용자에게 부여하는 권한
- 접근할 수 있는 메모리 영역과 권한이 매우 한정됨
- 운영체제가 사용자에게 부여하는 권한
- 시스템 콜
- 유저 모드에서 커널 모드의 시스템 소프트웨어에게 어떤 동작을 요청하기 위해 사용
- 시스템 콜 덕분에 사용자는 저수준 작업에 접근이 가능하여 핵심 기능을 사용할 수 있게 됨
- 유저 모드에서 커널 모드의 시스템 소프트웨어에게 어떤 동작을 요청하기 위해 사용
시스템 콜 사용
- x86
int 0x80명령어를 사용해 시스템 콜 호출- eax 레지스터에 호출하고자 하는 시스템 콜의 번호를 넣음
- 반환값은 eax에, 남은 인자는 다음과 같은 순서로 전달됨
- 인자 순서:
ebx->ecx->edx->esi->edi->ebp-> …
section .data filename db "dreamhack.txt", 0 buffer times 100 db 0 section .text global _start _start: mov eax, 5 mov ebx, filename mov ecx, 0 int 0x80 - x64
syscall명령어를 사용해 시스템 콜 호출- rax 레지스터에 호출하고자 하는 시스템 콜의 번호를 넣음
- 반환값은 rax에, 남은 인자는 다음과 같은 순서로 전달됨
- 인자 순서:
rdi->rsi->rdx->r10->r8->r9-> 스택
section .data filename db "dreamhack.txt", 0 buffer times 100 db 0 section .text global _start _start: ; 파일 열기: open("dreamhack.txt", O_RDONLY, 0) mov rax, 2 lea rdi, [filename] mov rsi, 0 xor rdx, rdx syscall
퀴즈 1
- 1번 코드의 경우, 현재 rbx값인 0x401A40에 8을 더한 값인 0x401A48의 주소에 있는 값을 rax에 저장하므로 답은 0xCOFFEE이다.
- 2번 코드의 경우, 현재 rbx값인 0x401A40에 0x8을 더한 값인 0x401A48를 rax에 저장하므로 답은 0x401A48이다.
- 1번 코드의 경우, 현재 rbx값인 0x555555554000에 rcx값인 0x2에 0x8을 곱한 값인 0x10을 더한 0x555555554010 주소에 있는 값인 0x3을 rax에 더하므로 답은 0x3133A이다.
- 3번 코드의 경우, 현재 rbx값인 0x555555554000에 rcx값인 0x4에 0x8을 곱한 값인 0x20을 더한 0x555555554020 주소에 있는 값이 0x3133A이므로 rax를 빼면 0이다.
- 4번 코드의 경우
inc는 레지스터의 값에 1을 더하므로 rax의 값인 0에 1을 더해 답은 1이다.
- 1번 코드의 경우
and이므로 두 레지스터 모두 값을 가지고 있는 부분만 rcx 레지스터를 기준으로 유지할 수 있다. 따라서, 0x1234567800000000이다.
- 2번 코드의 경우
and이므로 두 레지스터 모두 값을 가지고 있는 부분만 rcx 레지스터를 기준으로 유지할 수 있다. 따라서, 0x000000009abcdef0이다.
- 3번 코드의 경우, 위의 1번과 2번 코드로 인해 업데이트된 rax와 rbx 기준으로
or를 적용시킨다면, 0x123456789abcdef0이 된다. 이 값은 rax에 들어간다.
- 1번 코드의 경우
xor이므로 16진수 xor 계산 결과 0xEBACFBAE이다.
- 2번 코드의 경우
xor를 한 값에 같은 값으로xor를 한 번 더 적용하면 자기 자신이 나오므로, 0x35014541이다.
- 3번 코드의 경우
not연산 적용 시, 0xCAFEBABE가 된다.
퀴즈2
- 우선, dl은 4bit이며, 5번 명령줄을 통해 알 수 있는 것은 1번부터 5번까지의 명령줄이 총 28번 반복되어야 end에 도달할 수 있다는 것이다. 또한,
[rsi+rcx]이므로 4bit 단위로 xor가 적용되는 것이 총 28번 반복됨을 알 수 있다. 0x19와xor를 하게 되면 0x10을 빼는 것과 같은 효과를 가지므로, 이를 계산하여 변환하면Welcome to assembly world!라는 문장을 얻을 수 있다.
퀴즈3
- main 함수
- esi에 0xf, rdi에 0x400500를 저장한 뒤, write_n 함수를 호출한다.
- write_n 함수
- 스택에 rdi, esi 순으로 push 한 후, rdi와 rax 각각에 0x1을 저장하여 systemcall을 호출한다.
- systemcall 0x1 == sys_write
- sys_write의 인자로 0x1, 0x400500, 0xf가 들어간다.
- 따라서, 0x400500부터 0xf만큼 읽어서 stdout(0x1)에 출력한다.
=> 최종 출력 결과는 0x003f367562336420 == 07 yd43r, 0x003f367562336420 == ?6ub3d에 littel endian을 적용하여, r34dy 70 d3bug?
ready to debug?…이다.