SONOTRI
  • x86 Assembly: Essential Part(1)
    2025년 03월 22일 18시 32분 47초에 업로드 된 글입니다.
    작성자: SONOTREE

    00. 서론 - 리버스 엔지니어의 언어: 어셈블리

    컴퓨터 속에는 하나의 거대한 세계가 있다. 복잡한 논리적 인과관계가 존재하고, 여러 개체가 상호작용하며, 그 세계에서 통용되는 기계어라는 언어가 있다. 그리고 리버스 엔지니어가 하는 일은 그 거대한 세계의 동작을 이해하는 것이다. 이를 위해 리버스 엔지니어가 갖춰야하는 기본 소양 중 하나는 컴퓨터의 언어를 이해하는 것이다.

     

    커리큘럼에서 다루는 x86-64를 비롯하여 대중적으로 많이 사용되는 아키텍처들은 인터넷에서 역어셈블러를 구하기 매우 쉽다. 따라서 우리가 어셈블리어만 이해할 수 있다면 역어셈블러를 사용하여 소프트웨어를 분석해 볼 수 있다.

    이 커리큘럼에서는 앞으로 두 코스에 걸쳐 어셈블리 언어에 대해 개략적인 설명을 하고, x86-64의 명령어들을 소개할 것이다. 

    01. 어셈블리어와 x86-64

    01.1. 어셈블리 언어

    어셈블리 언어는 앞에서 소개했듯, 컴퓨터의 기계어와 치환되는 언어이다. 이는 기계어가 여러 종류라면 어셈블리어도 여러 종류여야 함을 의미한다. 그리고 앞에서 명령어 집합구조(Instruction Set Architecture, ISA)를 설명할 때 이야기했듯, CPU에 사용되는 ISA는 IA-32, x86-64, ARM 등 종류가 굉장히 다양하다.

     

    따라서 이들의 종류만큼 많은 수의 어셈블리어가 존재한다. x64의 세계에서는 x64의 어셈블리어가 있고, ARM의 세계에는 ARM의 어셈블리어가 있다. 이 언어는 많이 알수록 좋지만, 이 커리큘럼에서는 x64아키텍처를 대상으로 하기 때문에 x64 어셈블리어만 소개 할것이다.

     

     

     

    01.2. x64 어셈블리 언어 - 기본구조, 명령어(Opcode)

    <기본 구조>

    x64 어셈블리 언어는 우리가 사용하는 한국어보다 훨씬 단순한 문법 구조를 가진다. 이들의 문장은 동사에 해당하는 명령어(Operation Code, Opcode)와 목적어에 해당하는 피연산자(Operand)로 구성된다.

     

    예를 들어 아래 코드에서 opcode는 move, operand는 eax와 3이다. opcode -> opreand2 -> opreand1 순서로 해석하면 편하다. 즉, 데이터를 이동시켜라, 대입해라(move) eax에 3을...이라고 해석할 수 있다.

     

    <명령어>

    앞으로 학습할 명령어들은 다음과 같이 분류할 수 있다.

    명령 코드
    데이터 이동(Data Transfer) mov, lea
    산술 연산(Arithmetic) inc, dec, add, sub
    논리 연산(Logical) and, or, xor, not
    비교(Comparison) cmp, test
    분기(Branch) jmp, je, jg
    스택(Stack) push, pop
    프로시져(Procedure) call, ret, leave
    시스템 콜(System call) syscall

     

     

     

    01.3. 피연산자(Operand)

    피연산자(Operand)에는 총 3가지 종류가 올 수 있다.

    • 상수(Immediate Value)
    • 레지스터(Register)
    • 메모리(Memory)

    피연산자 중 메모리 피연산자는 []으로 둘러싸인 것으로 표현되며, 앞에 크기 지정자(Size Directive) TYPE PTR이 추가될 수 있다. PTR(Pointer)은 어셈블리에서 메모리 참조 시 데이터의 크기를 명시할 때 사용된다.

    크기 지정자(Size Directive) TYPE PTR
    BYTE 1 바이트
    WORD 2 바이트
    DWORD 4 바이트
    QWORD 8 바이트

     

    메모리 피연산자의 예시를 함께 살펴보자. 참고로 아래의 코드는 opcode는 작성하지 않은 상태이다. 맨 위의 코드를 살펴보면 메모리 주소 0x8048000에서 8바이트(QWORD)의 데이터를 참조하겠다는 의미로 해석할 수 있다. 아래의 코드을도 같은 방식으로 해석하면 된다.


    02. x86-64 어셈블리 명령어

    02.1. 데이터 이동

    데이터 이동 명령어는 어떤 값을 레지스터나 메모리에 옮기도록 지시한다.

     

    mov dst(목적지, destination), src(출처, source) [src에 들어있는 값을 dst에 대입]

    mov rdi, rsi //rsi의 값을 rdi에 대입
    mov QWORD PTR[rdi], rsi //rsi의 값을 rdi가 가리키는 메모리(주소)에 8바이트 크기로 저장한다.
    mov QWORD PTR[rdi+8*rcx], rsi //rsi의 값을 rdi+8*rcx가 기리키는 주소에 8바이트의 크기로 저장

     

    lea dst, src [src의 유효주소(Effective Address, EA)를 dst에 저장한다]

    lea rsi, [rbx+8*rcx] //rbx+8*rcx를 rsi에 대입
    mov와 lea의 차이점
    - mov: Opcode mov는 단지 값을 넣는 역할을 한다. 예를 들어 MOV eax, 1은 1을 eax에 넣는다는 의미가 된다.
    - lea: Opcoad mov가 값을 가져오는 것이라면, lea는 주소를 가져온다. 즉, 메모리의 값은 읽어오지 않고 주소만 계산해서 저장하는 명령어이다. 예를 들어 lea rax, [rbx+8]은 rbx+8을 계산한 주소를 rax에 저장하라는 의미가 된다.

     

     

     

    02.2. 산술 연산

    산술 연산 명령어는 덧셈, 뺄셈, 곱셈, 나눗셈 연산을 지시한다.

    add eax, 3 //3을 eax에 더한다 => eax+=3
    add ax, WORD PTR[rdi] //rdi가 가리키는 메모리에서 2바이트 데이터를 가져와 ax에 더한다 => ax+=*(WORD*)rdi
    sub eax, 3 //3을 eax에서 뺀다 == eax에서 3을 뺀다 => eax-=3
    sub ax, WORD PTR[rdi] //rdi가 가리키는 메모리에서 2바이트 데이터를 가져와 ax에서 뺸다 => ax-=*(WORD*)rdi
    inc eax // eax의 값을 1 증가 => eax+=1
    dec eax // eax의 값을 1 감소 => eax-=1
    ax+=*(WORD*)rdi가 뭔지 잘 모를 수 있다. 하나하나 살펴보자
    (WORD*)rdi는 "rdi를 WORD(2바이트) 타입의 포인터로 캐스팅"한다는 의미이다. 그리고 앞에 있는 *연산은 포인터 rid가 가리키는 메모리 주소에서 2바이트의 데이터를 가져온다는 의미이다. 

     

     

     

    02.3. 논리 연산 

    논리 연산 명령어는 and, or, xor, neg 등의 비트 연산을 지시한다. 이 연산은 비트 단위로 이루어진다.

     

    <and 연산>

    : 둘 다 1일 때만 1. TT인 경우를 골라낼 때 사용한다

    [Register]
    eax = 0xffff0000
    ebx = 0xcafebabe
    [Code]
    and eax, ebx // eax와 ebx를 and 연산
    [Result]
    eax = 0b 1111 1111 1111 1111 0000 0000 0000 0000
    ebx = 0b 1100 1010 1111 1110 1011 1010 1011 1110
    eax = 0b 1100 1010 1111 1110 0000 0000 0000 0000
    eax = 0x CAFE0000

     

     

    <or 연산>

    : 둘 중 1이 하나라도 있으면 1이다. FF인 경우를 골라낼 때 사용한다.

    [Register]
    eax = 0xffff0000
    ebx = 0xcafebabe
    [Code]
    or eax, ebx // eax와 ebx를 or 연산
    [Result]
    eax = 0b 1111 1111 1111 1111 0000 0000 0000 0000
    ebx = 0b 1100 1010 1111 1110 1011 1010 1011 1110
    eax = 0b 1111 1111 1111 1111 1011 1010 1011 1110
    eax = 0x FFFFBABE

     

    <xor 연산> 

    : 두 비트가 서로 다르면 1, 같으면 1

    [Register]
    eax = 0xffff0000
    ebx = 0xcafebabe
    [Code]
    xor eax, ebx // eax와 ebx를 xor 연산
    [Result]
    eax = 0b 1111 1111 1111 1111 0000 0000 0000 0000
    ebx = 0b 1100 1010 1111 1110 1011 1010 1011 1110
    eax = 0b 0011 0101 0000 0001 1011 1010 1011 1110
    eax = 0x 3501BABE

     

    <not 연산> 

    : op의 비트를 전부 반전한다

    [Register]
    eax = 0xffff0000
    ebx = 0xcafebabe
    [Code]
    not eax //eax를 not 연산
    [Result]
    eax = 0b 1111 1111 1111 1111 0000 0000 0000 0000
    eax = 0b 0000 0000 0000 0000 1111 1111 1111 1111
    eax = 0x 0000FFFF

     

     

     

     

    02.4. 비교 ★

    비교 명령어는 두 피연산자의 값을 비교하고 플래그를 설정한다.

     

    <cmp>

    : cmp는 두 피연산자를 빼서 대소를 비교한다. 이때 연산의 결과는 op1에 대입하지 않는다.

     

    cmp op1, op2 = op1과 op2를 비교

    예를 들어, 아래와 같이 서로 같은 두 수를 빼면 결과가 0이 되어 ZF플래그가 설정되는데, 이후에 CPU는 이 플래그를 보고 두 값이 같았다는 것을 판단할 수 있다.

    [Code]
    mov rax, 0xA //0xA 값을 rax에 대입
    mov rbx, 0xA //0xA 값을 rbx에 대입
    cmp rax, rbx // rax-rbx = 0xA-0xA = 0 => 따라서 ZF가 1로 설정된다(ZF=1)

     

     

    <test>

    : test는 두 연산자에 AND 비트연산을 취한다. 이 경우에도 연산의 결과는 op1에 대입하지 않는다.

     

    test op1, op2 = op1과 op2를 비교

    예를 들어, 아래 코드에서처럼 0이된 rax를 op1과 op2로 삼아 test를 수행하면, and 연산 결과가 0이므로(00이므로 0) ZF플래그가 설정된다. 이후에 CPU는 이 플래그를 보고 rax가 0이었다는 것을 판단할 수 있다(같은 rax끼리의 연산이기 때문에 만약 rax가 1이라면 ZF 플래그가 설정되지 않는다).

    [Code]
    xor rax, rax // rax와 rax를 xor 연산 => xor 연산은 값이 다르면 1, 같으면 0
    test rax, rax //xor 연산 결과 rax와 rax는 같으므로 0이 된다. 따라서 ZF플래그가 설정된다(ZF=1)

     

     

     

     

    02.5. 분기 ★

    분기 명령어는 rip(명령어 포인터 레지스터)을 이동시켜 실행 흐름을 바꾼다.

    분기문은 여기 소개된 것 외에도 굉장히 많은 수가 존재한다. 그러나 몇 개만 살펴보면 이름을 통해 직관적으로 의미를 파악할 수 있기 때문에, 이들을 전부 다루기 보다는 앞으로 실제 코드를 분석하면서 감을 익혀나갈 수 있도록 하는 것이 좋다.

     

    <jmp>

    : 0번째 라인으로 점프하라고 하는 명령어

     

    jmp addr = addr로 rip을 이동시킨다

    아래에서 두 번째 라인을 살펴보자. jmp는 무조건 점프하라는 명령어로, 이 코드에서는 라인 1으로 점프하라고 하고 있다. 여기서 라인 1은 xor 연산을 하는 부분이다. 즉, rax 레지스터의 값은 0으로 설정되지만 jmp 1 명령어로 인해 계속해서 첫 번째 라인으로 점프하게 되므로 무한 루프문이 된다.

    [Code]
    xor rax, rax //rax 와 rax를 xor 연산한다. 연산 결과 rax는 0이 된다(같은값 xor 같은값 -> 0)
    jmp 1 //라인 1로 점프해라

     

     

    <je>

    : 직전에 비교한 두 피연산자가 같으면 0 라인으로 점프

     

    je addr = 직전에 비교한 두 피연산자가 같으면 점프(jump if equal)

    [Code]
    mov rax, 0xcafebabe //0xcafebabe값을 rax에 대입
    mov rbx, 0xcafebabe //0xcafebabe값을 rbx에 대입
    cmp rax, rbx // rax-rbx 후 대소 비교. rax-rbx의 값이 0이므로 ZF=1이 된다. 일단 여기서는 rax==rbx라고 이해하자
    je 1 //직전에 비교한 두 피연산자가 같으므로 1번 라인으로 점프한다

     

     

    <jg>

    : 직전에 비교한 두 연산자 중 전자가 더 크면 점프

     

    jg addr= 직전에 비교한 두 연산자 중 전자가 더 크면 점프(jump if greater)

    [Code]
    mov rax, 0x31337 //0x31337을 rax에 대입
    mov rbx, 0x13337 //0x13337을 rbx에 대입
    cmp rax, rbx // rax-rbx 후 대소 비교. rax-rbx의 값이 양수이므로 ZF=0, SF(음수일 경우 설정되는 플래그)=0
    jg 1 //직전에 비교한 두 연산자중 전자(rax)가 큰 상황이므로 라인 1으로 점프한다.
    댓글