🔐
땡칠로그/🔐시스템 보안 - 시스템 콜

시스템 보안 - 시스템 콜

태그
수업
CS
정리
완료 일시
May 16, 2024

지난 시간에

스택 구조 분석

  • 프롤로그
    • // 함수 호출 call function // = push %eip + jmp function // 프롤로그 push %ebp // 이 시점에서 스택에서 과거 eip(리턴 주소) 및 과거 ebp(스택 복구를 위한)이 들어있다 mov %esp, %ebp // 스택 프레임을 새로 시작, ebp가 움직이는게 맞는거다. 헷갈리지 말자.
  • 에필로그
    • leave // 스택 프레임 복구 -> mov %ebp, %esp(탑 축소) + pop %ebp(바텀 복구) ret // 흐름(PC) 복구 -> pop %eip
  • callee가 정리하는 경우 ret 인스트럭션
    • ret 0x8 // 인자 개수만큼 스택을 정리함
      이 경우 callee 입장에서 얼마만큼 정리해야할지 컴파일 타임에 정해야 하기 때문에, callee 정리 함수 호출 규약의 경우 가변 인자가 불가하다.

함수 호출 컨벤션

notion image
  • 인자를 뒤에서부터 쌓는 이유는, ‘스택’이기 때문이다.
    • pop 할 때 마지막에 넣은것부터 나오니까. 사용측을 고려한 동작이다.
  • fastcall의 경우, 인자 쌓는게 좀 특이한데, 2개 이상의 뒤쪽 인자는 스택에 삽입 후, 앞쪽 인자를 ecx, edx레지스터에 담는다.
    • 이 때도 실행 순서는 뒤에서부터이지만, 레지스터의 경우에는 스택이 아니기 때문에 인자 순서와 ecx, edx 삽입 순서는 동일하다.
      아래 코드에서 a는 ecx, b는 edx에 저장되지만, 그 실행 순서가 b → a라는 의미다.
      notion image
      notion image
  • stdcall, cdecl의 경우 생각한대로 뒤쪽 인자부터 스택에 push/mov 된다.
    • notion image
  • 일반적으로 인자를 쌓는 것은 결국 caller다.
    • notion image
      stdcall → stdcall 호출임에도, 호출자가 스택에 인자를 넣었다
      stdcall → stdcall 호출임에도, 호출자가 스택에 인자를 넣었다
  • 리턴이 4바이트를 넘어가는 경우
    • 예를 들어, 8바이트인 long long의 경우 eax 및 edx에 나눠서 리턴한다.
      notion image
      notion image
      주목할 점은, 상위 4바이트가 edx에 별도기록 된다는 것.
      이 8바이트 리턴을 메모리 0x0에다 기록하려면,
      mov %eax, 0x0 + mov %edx, 0x4로 해야 한다.
      eax 4바이트는 -16부터, edx 4바이트는 -12부터 기록했다.
      eax 4바이트는 -16부터, edx 4바이트는 -12부터 기록했다.
      이렇게 하면 little endian을 지켜서 0x0: 08 00 00 00 00 00 00 00 이 기록될 것이며, 읽었을 때 8로 정확하게 나올 것이다.
      notion image
      little endian이라도 4바이트 단위로 읽을 때는 정상 순서로 나온다
      little endian이라도 4바이트 단위로 읽을 때는 정상 순서로 나온다

시스템콜

  • 시스템콜이 뭔데?
    • 하드웨어 등 물리 자원을 다루는 커널 동작을 인터페이스로서 사전 정의해놓은 것
    • 한 프로그램이 하드웨어까지 제어할 수 없고, 또 보안상 없어야 하기 때문에, 로우 레벨 구현들은 인터페이스 뒤로 숨겨야 한다.
    • 범용적인 프로그램 작성 및 실행을 위해서이기도 하다.
  • 어떻게 동작하는건데?
      1. 프로그램 실행 중
        1. 인자를 유저 스택에 넣는다.
      1. libc.so 의 래퍼 함수 사용
        1. write, brk, sbrk 등등 시스템콜을 따라서 libc에 래핑되어 있음
        2. 함수 파라미터를 ebx, ecx, edx 레지스터에 삽입
        3. eax에 시스템콜 번호 삽입
        4. 인터럽트 0x80/sysenter
      1. system_call()
        1. 레지스터로 넘어온 인자들을 커널 스택에 저장하고 (eax = 시스템 콜 번호 포함)
          실제 시스템콜 바디를 찾아 실행한다.
          call *sys_call_table(,%eax,4)
          → Register Indirect Addressing을 사용했으며, base는 비워두고 disp + eax(index) * 4(scale)로 표현했음

      왜 이런 형태로 구현했을까?
      • CPU 벤더에서 RING으로 코드 실행 권한을 제어하는 방법을 정의해놨고,
      • 그를 사용해 리눅스 커널은 일반 프로그램이 하드웨어까지 마음대로 제어할 수 없도록 막아놓고, 일부를 사용할 수 있도록 시스템 콜이라는 인터럽트 기반 호출 인터페이스를 제공함.
      • 그 결과 RING 3에서 RING 0에 접근 혹은 실행 불가하며, 시스템콜을 통해 일부 실행 후 그 결과를 받을 수 있다.
        • notion image

  • C 함수 호출 규약 ≠ 시스템콜 인터페이스
    • notion image
      cdecl, stdcall, fastcall 등의 함수 호출 규약은 C 프로그램 내부에서의 함수 호출에 대한 것이고, 시스템 콜은 리눅스 커널에 대한 호출 인터페이스이다.

실제 동작 과정

write 함수를 쓰는 과정을 예시로 들어본다.
  1. caller
    1. 먼저 호출자 쪽에서 인자들을 넣고, clib.so에 정의된 wrapper 함수 write를 호출한다.
  1. callee
    1. 이 wrapper 함수는 인자들을 레지스터에 넣고, int 0x80을 날리는 인스트럭션들을 담는다.
      notion image
      💡
      음 근데 왜 인자가 +8, +12, +16 이지?
      caller 쪽에서 파라미터들을 스택에 저장 후 callee에 진입하기까지 무슨일이 일어나는지 생각해봐야 한다.
      스택에 parameter passing 목적으로 1을 넣은 후 실제 함수를 call 했다면, 스택 관점에서 push %eippush %ebp가 수행되었다.
      call writepush %eip jmp write 이며,
      함수 프롤로그에는 push %ebp, mov %esp, %ebp가 포함되어 있다.
      따라서 스택 프레임 상단(esp)+0에는 (구)ebp 가, 스택 프레임 상단(esp)+4에는 (구)eip가 저장되어 있다.
      그러므로 파라미터에 접근하기 위해서는 esp+8 부터 접근하는 것이 당연하다.

      주의할 점은 이건 컴파일러 마음대로이며, 프롤로그가 수행된 직후 기준으로 esp=ebp이기 때문에 0x8(%ebp), 0xc(%ebp), 0x10(%ebp)라고 해도 말이 된다.
  1. 특정 레지스터들 저장
    1. 인터럽트를 받는 순간 eip, cs, eflags, esp, ss 등의 레지스터를 스택에 저장한다.
      강의자료에서는 커널 코드인 것처럼 설명해놨는데, 이건 CPU 자체 인터럽트 처리 메커니즘의 일부다.
  1. CPU의 인터럽트 해석
    1. CPU가 주체가 되어 인터럽트를 해석한다.
      커널 로드(시스템 부팅) 시에 IDT, GDT가 로드되며, 그 시작 주소가 각각 IDTR, GDTR에 저장된다.
      인터럽트가 발생했을 때 CPU는 IDTR를 참조, 인터럽트 번호를 가산해 인터럽트 번호에 맞는 IDT Entry를 찾는다.
      그리고 GDTR 참조, IDT Entry에 기록된 CS Selector의 GDT offset을 가산해 GDT 엔트리를 찾는다.

      IDT 및 GDT를 참조해 인터럽트를 해석하는 과정
      IDT 및 GDT를 참조해 인터럽트를 해석하는 과정

      그리고 마침내 GDT Entry의 Base Address(0xC1000000)와 IDT의 커널 루틴 Offset(0x4380)을 가산해 인터럽트 핸들러의 실제 주소(0xC1004380, system_call)를 찾아 실행한다.
      그 주소에는 인터럽트 핸들러 system_call이 있다.
      그 주소에는 인터럽트 핸들러 system_call이 있다.

      💡
      권한 레벨 체크, max(CPL, RPL) ≤ DPL 계산하기
      notion image
       
      notion image
      max(CPL, RPL) = 3 > DPL = 0 이므로 접근 불가, segfault 발생
      💡
      RPL이 있는데 DPL은 왜 필요한가?
      • max(CPL, RPL) : 낮은 권한(최소 권한) 찾기
        • (Ring 3 → User Mode, Ring 0 → Kernel Mode)
      • ≤ DPL : 실제로 접근할 부분이 그 낮은 권한마저 만족시키는가?
      위 두 조건을 모두 검사해야 안전하다.
      일반 어플리케이션은 Ring 3, 시스템 콜은 Ring 0에서 실행된다.
      일반 어플리케이션이 시스템 콜을 통해 Segment X(Ring 0)에 접근한다고 해보자.
      시스템 콜을 호출하면 코드가 Ring 0에서 실행되기 때문에, 원래라면 접근 불가했을 Segment X에 쓰기가 가능해진다.
      이런 일을 막기 위해 max(CPL, RPL) 한 후 DPL과 비교해야 하는 것이다.

  1. 인터럽트 핸들러 진입, 커널 스택 프레임 시작
    1. syscall 인터럽트 핸들러에 진입해 후속 작업들을 수행한다.
      먼저 디버깅을 위해 pushl_cfi %eax를 사용해 Call Frame 정보인 eax를 저장한다.
      이후 SAVE_ALL 매크로로 모든 레지스터를 저장한다.
      이후 system_call_table을 참조해 시스템콜 번호에 해당하는 시스템 콜 핸들러를 호출한다.
      call *sys_call_table(,%eax,4)
      → Register Indirect Addressing을 사용했으며, base는 비워두고 disp + eax(index) * 4(scale)로 표현했음
  1. 시스템 콜 핸들러 진입(sys_write)
    1. 웃기지만 이제서야 원래 목표한 시스템콜인 함수 sys_write에 진입했다.
      인자들을 활용해 화면에 글자를 찍는다.
  1. 스택 복구 및 유저모드 전환
    1. 인터럽트 핸들러의 RESTORE_ALL이 수행되어 각종 레지스터 및 스택이 복구된 후
      iret으로 인터럽트에서 유저모드로 복귀한다.
      💡
      리턴값은 어떻게?
      notion image
      PT_EAX는 스택에서 EAX가 저장된 위치를 나타낸다.
      위 인스트럭션에 따르면, 스택에 저장된 eax값은 eax에 저장된 시스템 콜 핸들러의 리턴값으로 최신화된다.
      따라서 이 작업 이후 RESTORE_ALL을 하면 eax가 시스템 콜 핸들러의 리턴값으로 설정된 후 유저모드로 돌아가게 된다.