System Hackig Step 2-2

Posted by : at

Category : dreamhack_system_hacking



STAGE 2

x86 Assembly: Part 1

리버싱 공부하면서 이미 했던 거라 여기에서는 간단하게 메모만 하고 넘어갑니다.

  • 어셈블리어 → (by 어셈블러) → 기계어
  • 역어셈블러 ↔  어셈블러
    • 즉, 역어셈블러는 기계어 → 어셈블리어. 우리가 리버싱 할 때 없으면 안되는 것!

어셈블리어와 x86-64

<요약> - CPU의 ISA(Instruction Set Architecture) 종류별로 어셈블리어가 다르다. - 여기에서 다룰 어셈블리어는 x64 어셈블리어.

어셈블리어의 기본 구조

ex) mov 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

어셈블리 명령어의 피연산자

“총 3가지 종류”

  1. 상수(Immediate Value): {immediate_value}
  2. 레지스터(Register): {register_name}
  3. 메모리(Memory): [{memory_address}] … 몰랐음!!
    • 앞에 크기 지정자(Size Directive)인 TYPE PTR 추가될 수 있다.
    • BYTE(1 byte), WORD(2 byte), DWORD(4 byte), QWORD(8 byte)
      • WORD가 2 byte인 이유: 맨 처음 인텔의 아키텍처가 16비트였는데, 나중에 아키텍처 확장 과정을 거치다 보니 WORD 자료형 크기를 변경해 버리면 다른 아키텍처에서 프로그램 호환이 안 되는 문제가 생겨서 그냥 16비트 유지.

x86-64 어셈블리 명령어

  • 리버싱과 시스템 해킹의 원리에서 공부했던 내용이므로 간략히 정리하고 넘어감.

데이터 이동 명령어

mov dst, src

  • src의 값을 dst에 이동.

lea dst, src

  • src의 유효 주소를 dst에 저장.
  • 즉, src는 memory 타입의 피연산자로 오게 된다.
    • ex) lea rsi, [rbx+8rcx]: rbx+8rcx를 rsi에 대입한다.
    • 많이 헷갈리니까 주의!!!

산술 연산 명령어

add dst, src

  • dst+=src

sub dst, src

  • dst-=src

inc op

  • op ++

dec op

  • op –

논리 연산 명령어

and dst, src

  • dst&=src
  • 마스킹 연산
    • eax=0xffff, ebx=0xcafe일 때, and eax, ebx를 시행하면 eax=0xcafe

or dst, src

  • dst =src
  • 역 마스킹 연산
    • eax=0xffff0000, ebx=0xdeadbeef일 때, or eax, ebx를 시행하면 eax=0xffffbeef

xor dst, src

  • dst^=src
  • 비트가 다르면 1, 같으면 0
  • 따라서, xor 연산을 동일한 값으로 두 번 실행할 경우, 원래 값으로 돌아감.
    • ex) rax=0x35014541, rbx=0xdeadbeef일 때,
      • xor rax, rbx 시행 후 rax=0xebacfbae
      • 또 다시 xor rax, rbx 시행하면 rax=0x35014541
    • 이런 성질을 이용해 XOR cipher가 개발(블록 암호화에서도 XOR의 성질을 이용해서 암호화하는 방식이 많은 것으로 기억함!)

not op

  • ~(op) 즉, op의 비트 전부 반전(1의 보수)
xor rax, rbx
xor rax, rbx
not eax

를 수행했을 때, 맨 마지막의 not eax까지를 수행하면 rax의 하위 32비트가 1의 보수를 취하여 다시 rax의 하위 32비트를 채우게 된다. rax의 하위 32비트가 eax이고, 실제로 그렇게 사용할 수 있다는 점 잊지 말자!

비교 연산 명령어

“두 피연산자의 값을 비교하고, 플래그를 설정한다.”

cmp와 test의 비교는 리버싱을 공부할 때 많이 헷갈렸던 부분이다. 주의하자!

cmp op1, op2

  • op1과 op2를 비교한다.
  • “빼서” 비교한다.
  • 연산의 결과는 ZF에 설정된다.
  • 보통 “같은지” 확인하기 위해 사용한다.
    • 같다면 ZF=1로 설정되고, jmp 분기문에서 설정에 따라 코드 플로우를 분기시키게 된다.

test op1, op2

  • op1과 op2를 비교한다.
  • “AND”를 취해 비교한다.
  • 연산의 결과는 ZF에 설정된다.
  • 보통 “자기 자신이” 0이었는지 확인하기 위해 사용한다.
    • 자신이 0이었다면 ZF=1로 설정된다.
  • 왜냐하면 자기 자신과 AND를 취했을 때 0이 나올 수 있는 경우는 오로지 자기 자신이 0일 때 뿐이므로!

분기 명령어

“rip를 이동시켜 실행 흐름을 바꾼다!”

리버싱을 할 때 정말 잘 알아둬야 한다. 왜냐하면 보통의 프로그램들은 분기에 따른 경우수를 기반으로 행동을 하기 때문이다.

rip가 이동되는 이유는 컴퓨터 아키텍처를 공부한 적 있다면 알 수 있다.

  • CPU는 jmp등의 분기문을 만나면 PC에 저장된 다음 인스트럭션 주소를 바꿔서 jmp 명령어가 가리키는 인스트럭션을 수행하게 되는데, 이때 PC가 rip이기 때문에 분기 명령어는 결론적으로 rip를 이동시켜 실행 흐름을 바꾸게 된다.
  • jmp 계열 명령어는 jmp op1, op2일 때 op1을 기준으로 이름을 붙이게 된다.
    • ex) jg op1, op2 // jump if op1 is greater than op2

x86 Assembly: Part 2

리버싱 공부하면서 이미 했던 거라 여기에서는 간단하게 하고 넘어갑니다.

  • 스택: 운영체제의 핵심 자료구조
  • 프로시저: C언어의 함수에 대응
  • 시스템 콜: 시스템 해킹의 관점에서 정말 중요함.

x86-64 어셈블리 명령어 Pt.2

스택

push: 스택에 쌓아넣기 pop: 스택에서 빼내기

  • push val: val에 들어있는 값을 스택의 최상단에 쌓음
    • 이 때 일어나는 연산을 pseudo-code로 표현하면 아래와 같다.
```wasm
rsp -= 8 //stack grows from high to low
[rsp] = val
```
  • pop reg: 스택 최상단의 값을 꺼내서 reg에 대입
    • 이 때 일어나는 연산을 pseudo-code로 표현하면 아래와 같다.
      rsp += 8 // decrease stack span
      reg = [rsp-8] //pop
    

프로시저

프로시저의 호출 및 반환과 관련해서는 call, leave, ret 명령어를 사용한다.

프로시저 불러서 진입하기: call

프로시저에서 나가서 원래의 함수로 돌아오기: return

스택프레임 정리하기: leave

프로시저를 call 할 당시, 원래의 함수에서 call 다음에 수행할 인스트럭션의 명령어 주소를 스택에 push 해둔 뒤, 콜했던 프로시저의 시작으로 rip를 이동하게 되는데, 이 때 스택에 저장되는 인스트럭션의 주소를 ‘Return address’라 칭한다.

  • call addr: addr에 있는 프로시저 호출
    • 이 때 일어나는 연산을 pseudo-code로 표현하면 아래와 같다.
      push return_address // push return address into the stack
      jmp addr
    
  • leave: 스택프레임 정리해서 원래 함수의 스택프레임 복구하기
    • leave 명령어와 동일한 기능을 수행하는 어셈블리 명령어의 조합은 아래와 같다.
      **mov** rsp, rbp // 현재 함수에서의 스택 밑바닥이 원래 함수에서의 스택의 가장 꼭대기가 된다.
                   // 즉, 현재 스택 꼭대기: 원래 함수의 스택 꼭대기에서 하나 더 간 상태.
      						 // 지금 rsp가 가리키는 위치에는 원래 함수의 스택 베이스 주소가 있다.
      **pop** rbp // 원래 함수의 스택 베이스 주소를 빼서 rbp에 넣어 준다.
      				// 또한 rsp를 하나 내림으로써 원래 함수의 스택 꼭대기와 일치시킨다.
    
  • ret: return address로 rip를 위치시킨다.
    • ret 명령어와 동일한 기능을 수행하는 어셈블리 명령어는 아래와 같다.
      pop rip // leave를 거친 이후 rsp가 가리키는 곳에는 콜했던 프로시저의 주소로 rip를 움직이기 전에
      			  // 넣어 두었던 다음 인스트럭션의 주소가 저장되어 있다.
      				// 따라서 다음에 수행할 인스트럭션의 주소를 스택에서 빼서 rip에 넣어 줌으로써
      				// 원래의 코드 플로우를 수복한다. 
    

해당 명령어가 실제로 펑션콜과 리턴 과정에서 어떻게 사용되는지 시나리오 형식으로 설명해 보자.

  1. 함수 A를 실행하다가 call function_B 명령어를 맞닥뜨렸다.
    1. 4에서 실행될 다음 인스트럭션의 주소가 스택에 push 된다.
    2. 함수 A의 스택 프레임을 유지하기 위해 A 스택 프레임의 rbp가 스택에 push 된다.
    3. 새로운 스택 프레임을 할당해 주기 위해 rbp를 rsp의 위치로 옮긴다.
    4. 앞에서 배웠듯, OS는 스택 프레임을 할당할 때 소규모로 할당한 후 가변적으로 운영한다. 따라서, 새로운 스택 프레임 공간을 할당하기 위해 rsp에서 0x30 정도를 뺀다.(통상적)
    5. 이렇게 새롭게 할당된 스택 프레임과 기존의 A의 스택 프레임 구조를 확인하면 아래와 같다.

      ========새로운========
      =======스택 프레임======
      =========공간=========
      A의 rbp 주소
      A 상에서의 다음 인스트럭션 주소
      기존에 A에서 쓰고 있던 임시 데이터들
  2. 함수 B를 실행한다.
    1. 앞에서 배웠듯, 스택에는 지역 변수가 저장된다. 따라서 함수 B의 지역 변수를 새로운 스택 프레임에 저장해 준다.
    2. 스택 프레임 위에서 여러 연산을 수행한다. 스택 프레임은 함수별로 할당되므로 해당 함수 한정으로만 유효할 수 있는 데이터를 스택에 저장할 것이다.
  3. 함수 A로 복귀한다.
    1. leave 명령어가 실행된다. 따라서, rsp가 rbp의 위치로 이동하고 스택에 있던 A의 rbp 주소가 pop 되면서 rbp가 기존의 A의 스택 프레임의 rbp 주소를 가지게 된다.
      • 한 마디로, B의 스택 프레임이 정리된 이후 A의 스택 프레임이 복구된다.
  4. 함수 A 상에서의 다음 인스트럭션을 실행한다.
    1. ret 명령어가 실행된다. 따라서, A 상에서의 다음 인스트럭션 주소가 rip로 들어간다.
      • 이 시점에서 call 을 수행하기 이전의 A의 스택 프레임 상태가 완전히 복구된다.
    2. rip가 가리키는 주소의 명령어를 실행한다.

시스템 콜

OS를 공부했다면 쉽게 이해할 수 있는 내용이다. 사실 OS 책의 1장 1페이지 정도 펼쳐봤다면 이해할 수 있는 내용이다. 따라서 자세한 부연 설명을 붙이지 않고, 간단히 정리만 하고 넘어간다.


OS는 시스템 보호를 위해 시스템과 직결된 행위(ex: 네트워크 통신, 파일시스템 테이블 생성/추가/삭제, 파일 Read/Write…)는 실행 권한을 분리해서 관리한다. 이럴 때 부여하는 권한이 바로 커널 권한과 유저 권한이다. 유저 권한을 가진 프로세스들은 커널 권한이 필요한 시스템 행위에 직접 엑세스할 수 없고, OS에게 요청해 OS가 대신 커널 권한이 필요한 시스템 행위를 수행하고 그 결과를 받아보는 것이 기본이다.

이때 OS는 유저 권한과 커널 권한을 사용하는 상태를 오가는데, 유저 권한을 사용할 때를 ‘유저 모드’, 커널 권한을 사용할 때를 ‘커널 모드’에 있다고 표현한다.

개인적으로 OS는 정말 재미있고 흥미로운 문제와 해결의 연속이라고 느꼈기 때문에, 만일 흥미가 생겼다면 공부해 보는 것을 추천한다. 리눅스부터 공부하는 게 좋을 것이다. 멀웨어 분석이 목표라면 윈도우를 공부해야 하겠지만…

  • Q. 그럼 프로세스에서 exec echo HOSTNAME 을 실행할 때 프로세스는 커널 모드로 진입하게 되는 건가요?
  • A. 아닙니다. OS의 구성요소가 아닌 유저 레벨에서 생성되고 관리되는 요소들이라면 무조건 유저 모드에서 벗어날 수 없습니다. 해당 프로세스는 커널 권한이 필요한 행위를 수행하고 싶을 때 OS에게 수행을 요청하고 그 결과만 받아보게 됩니다. 즉 OS가 커널 모드로 진입하고 빠져나옵니다.

즉, OS를 침해하는 해킹이 가장 심각한 결과를 이끌어내는 경우는 해당 해킹이 OS의 커널 모드 권한을 빼앗았을 때 발생한다.

그렇다면 시스템 콜(System Call)이란 무엇일까? 위에 서술한 내용 중에 답이 있다… (소곤소곤)

굵은 글씨로 쓰인 내용을 보자. 그리고 다음 설명을 보자. System Call이란 유저 모드에서 커널 모드에서의 수행이 필요할 때 ‘요청하는 것’이다. 이해가 확 될 것이다.

정리해서,

x64 아키텍처에서는 시스템콜을 하기 위해 syscall 명령어를 쓴다. 나도 프로그래밍 하다가 본 것 같다… 아마도?


여기에서 끝나면 안된다. 지금 이 포스트의 목적은 ‘어셈블리어’를 공부하는 것이지 OS 개론을 공부하는 게 아니기 때문이다.

syscall 명령어로 사용할 수 있는 시스템 콜은 함수다. 즉, 인자가 있고, 해당 인자를 이용해 어떠한 행위를 한다. 이 경우에는 커널에게 ‘사전 정보’를 주면서 ‘원하는 행위’를 요청하는 것임을 추측할 수 있다.

이렇게 원하는 행위와 사전 정보를 전달할 때, 아키텍처 레벨로 내려가 본다면 당연히 레지스터가 개입할 것이다. 파라미터가 있는 펑션콜이 수행되는 과정을 한 번 어셈블리 디버거로 보게 되면 직관적으로 알 수 있다. 그렇다면 어떤 레지스터가 통상적으로 개입할까? 그리고 어떤 레지스터에 ‘사전 정보’와 ‘원하는 행위’가 들어갈까?

리눅스의 경우 ‘원하는 행위’를 가리키는 데이터는 rax에 저장된다. 해당 값은 시스템 콜 테이블이라는 시스템 콜이 저장되어 있는 테이블의 인덱스 넘버이므로, 어떠한 행위를 원하는지를 시스템이 인식할 수 있다.

어떤 행위를 원하는지 알았다면 그 행위에 필요한 인자를 받아야 할 차례다. 한 예로, 현재 메모리에 있는 값을 읽어 와 콘솔에 write하려 한다고 하자. (write가 핵심이다. 프로세스가 선언한 데이터가 저장되어 있는 영역을 read할 땐 유저 권한으로 한다. 애초에 메모리에는 커널만을 위한 영역이 따로 있어서 엄격하게 커널과 유저 권한을 분리한다)

write를 하기 위해서는 ‘어디에’ ‘무엇을’ ‘얼마만큼의 길이로’ 쓸 것인지를 알려 줘야 한다. 그리고 OS는 정확하게 이 세 가지 요건을 입력받아 write 연산에 사용한다. 각 정보는 rdi, rsi, rdx에 저장된다. 레지스터의 용도를 고려해 보면 일리가 있게 저장되는 셈이다.

rdi는 destination, 즉 정보가 어디로 향할지를 저장하는 데 쓰이고, rsi는 source, 즉 정보의 출처가 어디인지 지정하는 데 쓰이므로 각각 ‘어디에’와 ‘무엇을’ 을 저장하는 데 적합한 논리를 가지고 있기 때문이다.

그렇다면 ‘얼마만큼의 길이로’를 저장하는 rdx는? 이건 사실 일반적인 convention 때문에 쓰인다. 그 convention이란 무엇이냐면…

rdi 첫 번째 인자 저장
rsi 두번째 인자 저장
rdx 세번째 인자 저장
rcx 네번째 인자 저장
r8 다섯번째 인자 저장
r9 여섯번째 인자 저장

바로 위의 테이블이다. 일반적으로 함수의 파라미터를 전달할 때 레지스터는 위와 같은 순서로 인자를 저장한다.

그런데 좀 이상하네… 지금 이걸 쓰면서 생긴 의문인데, 이런 convention은 system call에 인자를 전달하면서 생겨나서 유저 권한의 함수를 콜할 때도 쓰게 된 거 아닌가? 그럼 system call에 rdx를 세번째 인자를 전달할 때 쓰는 이유는 대체 왜지? 언젠가 알게 되겠지… 지금은 이게 핵심이 아니니까 넘어가자.

정리해 보자. 만일 콘솔에 메모리 상의 어떤 정보를 읽어와 write한다고 치면

syscall 명령어가 실행된다. 이렇게 syscall 명령어가 실행되면 CPU는 rax부터 쳐다보게 된다. (언니 뭐부터 할까요) 그렇게 어떤 시스템 콜을 실행할지 결정하면 CPU는 해당 시스템 콜에 사용할 인자를 확인하기 위해 아래의 순서로 레지스터를 참조한다.(유저 권한에서 시스템 콜을 요청할 때는 아래의 sequence로 진행되고, 커널 권한의 인터페이스에서 뭔가를 할 땐 또 다른 sequence로 레지스터를 읽는다)

rax에서 write system call을 가리키고 있기 때문에, 이제 커널은 write(out_mode, data_add, length)를 수행하게 된다. 인자가 3개이므로 커널이 참조하는 레지스터는 rdi, rsi, rdx가 된다.

참고로 syscall 이 리턴되면 레지스터 컨벤션에 따라 rax에 시스템 콜의 결과가 저장된다. 에러 번호라든가 성공 여부라든가…

  • 부록: System call table 중 주요 몇 가지 시스템 콜(검색하면 나옴)

    syscall rax arg0 (rdi) arg1 (rsi) arg2 (rdx)
    read 0x00 unsigned int fd char *buf size_t count
    write 0x01 unsigned int fd const char *buf size_t count
    open 0x02 const char *filename int flags umode_t mode
    close 0x03 unsigned int fd    
    mprotect 0x0a unsigned long start size_t len unsigned long prot
    connect 0x2a int sockfd struct sockaddr * addr int addrlen
    execve 0x3b const char *filename const char *const *argv const char *const *envp

About TouBVA
TouBVA

A Security Researcher, now concentrating on Security Consulting.

Email : touBVa@gmail.com

Website : https://toubva.github.io