x86 리눅스 리버싱

x86 리눅스 리버싱

2024년 5월 20일

레지스터 #

범용 레지스터 #

주 용도는 있지만 다양한 용도로 사용되는 레지스터이며, x86-64에서 8bit의 크기를 갖는다.

세그먼트 레지스터 #

각각의 세그먼트를 가리키는 레지스터이며, 16bit 크기를 가지고 있다. x64에서 cs, ds, ss 레지스터는 코드영역, 데이터영역, 스택영역을 가리킬때 사용되고, 나머지는 범용적인 용도로 사용된다.

명령어포인터 레지스터 #

RIP 레지스터를 의미하며, CPU가 실행시킬 다음 명령어를 가리켜서 명령어 사이클에 fetch해올 수 있게 한다.

플래그 레지스터 #

x64에서는 RFLAGS라는 64bit 크기의 플래그 레지스터가 있고, CPU의 명령(연산) 실행 후 상태에 따라 플래그가 세팅된다.

리눅스 메모리 구조 #

프로세스의 메모리는 크게 코드, 데이터, BSS, 힙, 스택 세그먼트로 5가지로 나뉘며 각 영역을 또 나눠 권한을 설정하게되면 CPU는 부여된 권한에 대한 행위만 할 수 있게 된다.
보통 코드가 저장되는 코드영역에는 rx 권한이 나머지 데이터가 저장되는 영역에는 r 또는 rw가 지정된다.

d3549236-8b22-4fcb-9e26-74c7824a427c

  • 코드 : 악의적인 코드를 추가할 수 없도록 하기 위해 보통 쓰기권한이 없으며, 컴파일된 기계 코드가 저장된다.

  • 데이터 : 쓰기 권한 유무에 따라 컴파일 시점에 값이 정해진 전역변수(+static)가 저장되는 data와 전역 상수가 저장되는 rodata로 나뉜다.
    아래를 보면 str_ptr은 포인터이고, “readonly"를 저장하는 메모리는 따로 만들어져야 하기 때문에 배열에 저장한 값들과 다르게 두 메모리 공간을 차지하게 된다.

    1int data_num = 31337;                       // data
    2char data_rwstr[] = "writable_data";        // data
    3const char data_rostr[] = "readonly_data";  // rodata
    4char *str_ptr = "readonly";  // str_ptr은 data, 문자열은 rodata
    
  • BSS : 컴파일 시점에 값이 정해지지 않은 전역 변수(+static)가 위치하는 영역이며, 프로그램이 실행되면서 모두 0으로 초기화된다. 값을 정하지 않은 변수들은 지역변수처럼 굳이 디스크에선 메모리를 할당하지 않아도 되기 때문에 data 영역과 나눈것이다.

    • 컴파일 시점에 코드에서 bss 섹션상의 오프셋과 사이즈는 변수별로 정해져서 컴파일되어 있으며, 링킹 과정에서 전체 bss섹션의 사이즈가 파일 헤더에 기록되고, 런타임에 헤더에 적힌 크기만큼 메모리를 할당 후 코드가 쭉 실행되면서 정해진 오프셋에 접근하게 되는 원리이다.
  • : 메모리를 최대한 사용하기 위해 스택과 힙은 반대방향으로 자라게 구현됐으며, 런타임에 동적으로 할당된 메모리가 저장된다.

  • 스택 : 런타임 중 파라미터나 지역변수 등이 저장되며 함수가 호출될때 생성되고 리턴되면서 반환된다.

  • 클래스 : 클래스도 결국 메서드(코드)와 멤버(변수)의 모임이다. 결국엔 메서드는 코드영역에 저장되고 멤버는 사실 인스턴스화 됐을때 메모리를 가리켜야 하기 때문에 스택이나 힙영역에 저장된다. 물론 static 멤버는 모든 클래스 공통이라 전역변수처럼 사용되어 초기화상태에 따라 bss나 data영역에 저장된다.


x86 어셈블리어 #

피연산자 #

피연산자는 상수와 레지스터, 메모리 주소가 올 수 있다.

  • 메모리 : 메모리 피연산자는 []로 둘러싸여있으며, 크기 지정자 TYPE PTR이 추가될 수 있다. BYTE, WORD, DWORD, QWORD는 각각 1,2,4,8 바이트를 의미한다.

예시 #

  • mov rdi, rsi : rdi 레지스터에 rsi 값을 대입
  • mov QWORD PTR[rdi], rsi : rdi가 가리키는 8byte 메모리 주소에 rsi 값 대입. rdi가 8byte가 아니라 rdi가 가리키는 메모리가 8byte이다.
  • lea rsi, [rbx+8*rcx] : rsi에 rbx+8rcx 값을 저장한다. 사실 상수로 rbx+8rcx 를 계산해서 rsi에 넣는 mov 명령과 동일하지만, 주소를 저장한다는 의미로 lea를 사용한다. mov로 주소를 계산해서 넣으려면 두개의 명령을 사용해야한다.
  • add ax, WORD PTR[rdi] : ax(16bit)에 rdi가 가리키는 WORD 크기의 데이터만큼 더한다.
  • and, or, xor, not 으로 비트단위 논리 연산을 수행한다
  • neg DWORD PTR [rbx] : rbx가 가리키는 4byte의 부호를 반전시킨다. (2의보수)
  • cmp op1, op2 : op1과 op2를 빼서 ZF가 세팅되는지 확인한다. 결과는 버려진다. 두 값이 같은지를 확인한다. 이기능을 test로 구현하긴 어렵다.
  • test op1, op2 : op1과 op2를 & 연산 해서 ZF가 세팅되는지 확인한다. 결과는 버려진다.
    1; 플래그가 세팅되어 있다면 &의 결과가 0이 아니기 때문에 ZF가 세팅되지 않음.
    2test eax, 0x1   ; eax의 최하위 비트가 설정되어 있는지 확인
    3jnz bit_is_set  ; 비트가 설정되어 있으면(ZF==0) bit_is_set으로 분기
    4
    5; 둘다 0인 경우에 ZF가 세팅된다. 
    6; `cmp eax, 0x0` 과 동일해 보이지만, 감산기를 거치지 않아된다는 장점이 있다.
    7test eax, eax   ; eax의 값이 0인지 확인
    8jz is_zero      ; 값이 0이면 is_zero로 분기
    
  • 점프명령은 op1 기준으로 이름이 정해졌다. 예를들어 jg의 경우 op1이 큰 경우에만 해당 label로 점프한다.
  • call addr : rip를 스택에 저장(push)하고, addr로 점프한다.
  • leave : 스택 프레임을 정리한다. mov rsp, rbp + pop rbp
  • ret : call에서 푸시해둔 return address로 이동한다.

시스템 콜 #

f53fd5d8-6f72-4d2d-8217-a6af85f1639a

운영체제는 연결된 하드웨어나 소프트웨어를 조작하기 위해 만들어졌기 때문에 최고 권한에서 실행되며, 해킹을 보호하기 위해 유저모드와 커널모드로 추가적으로 권한이 나뉘어 있다.

커널모드는 전체 시스템을 제어하기 위한 모드이며 저수준의 하드웨어 작업들은 커널모드에서 진행된다.

유저모드는 일반 응용 프로그램들이 포함되어 있는데, 소프트웨어에서 하드웨어 접근 및 조작을 위해 시스템 콜을 호출하면, 운영체제가 커널모드에서 하드웨어를 조작해주는 방식이다.

syscall 이라는 명령어를 사용하며, rax에 시스템 콜의 번호를 지정하고, rdi -> rsi -> rdx -> rcx -> r8 -> r9 -> stack 순으로 시스템 콜에서 사용하는 인자를 저장 후 호출하게된다.
따로 외울필요 없이 시스템 콜 테이블을 검색해서 보면 된다.

출처: https://syscalls64.paolostivanin.com/ 43bd08cb-3f74-48ba-93f3-85bcb471c72f

comments powered by Disqus