포스트

[2025 Hacksium Busan 본선] 선박 CCTV 시스템

[2025 Hacksium Busan 본선] 선박 CCTV 시스템

카테고리

Pwnable

문제 분석 및 풀이

서버에 처음 접속하면 회원가입 기능과 로그인 기능이 있는데, 회원가입 기능은 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
unsigned __int64 SignUp()
{
  ...
  printf("[*] New ID: ");
  fgets(s, 32, stdin);
  s[strcspn(s, "\n")] = 0;
  printf("[*] New PW: ");
  fgets(v3, 32, stdin);
  v3[strcspn(v3, "\n")] = 0;
  stream = fopen("accounts.db", "a");
  if ( stream )
  {
    fprintf(stream, "%s:%s:%d\n", s, v3, 0LL);
    fclose(stream);
    puts("[+] Sign up complete.");
  }
  ...
  • 입력한 ID와 PW가 “:”으로 구분되어 “accounts.db” 파일에 저장된다.

로그인 기능은 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
unsigned __int64 Login()
{
  ...
  printf("[*] ID: ");
  fgets(s, 32, stdin);
  s[strcspn(s, "\n")] = 0;
  printf("[*] PW: ");
  fgets(s2, 32, stdin);
  s2[strcspn(s2, "\n")] = 0;
  stream = fopen("accounts.db", "r");
  if ( stream )
  {
    v2 = 0;
    while ( fgets(v9, 128, stream) )
    {
      v1 = 0;
      v3 = __isoc99_sscanf(v9, "%31[^:]:%31[^:]:%d[^\n]", s1, v8, &v1);
      if ( v3 == 3 && !strcmp(s1, s) && !strcmp(v8, s2) )
      {
        strncpy(userID, s, 0x1FuLL);
        isAdmin = v1;
        v2 = 1;
        break;
      }
    }
  ...
  • ID와 PW를 입력받고, “accounts.db” 파일에서 한 줄씩 읽어 “:”으로 잘라 각각 ID, PW, Admin여부로 사용한다.

회원가입과 로그인을 할 때 “:”을 기준으로 필드를 구분하는데, 회원가입 시 ID와 PW를 입력할 때 “:”을 입력할 수 있으므로, 이 구조를 조작할 수 있다.
ID를 “a:a:1:asd”, PW를 “asd”로 입력하고 회원가입하면 “accounts.db”에는 “a:a:1:asd:asd”가 저장된다.
이를 Login 함수에서 해석할 때는 ID 필드가 “a”, PW 필드가 “a”, Admin여부 필드가 “1”로 인식되므로 “a”, “a”로 로그인하면 admin 계정으로 로그인할 수 있다.

로그인에 성공하면, 힙 청크(이하 스트림)를 할당, 수정, 삭제할 수 있는 기능들을 사용할 수 있다.
힙 청크는 할당하면 데이터 영역의 배열에 저장되며, 데이터 영역의 구조와 스트림의 구조는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
  typedef struct _stream {
    char name[32];
    char rtspURL[64];
  } STREAM;

  STREAM streams[8]; // bss:0x5040 | 힙 청크 주소가 저장되는 전역 배열.       
  char userID[32];   // bss:0x5080 | 로그인 당시 사용한 ID가 저장되는 문자열.  
  int  isAdmin;      // bss:0x50A0 | admin 여부.                       
  int  streamCnt;    // bss:0x50A4 | 지금까지 할당된 stream 갯수.

또한, 스트림과 관련된 아래 4가지 함수가 있다.

  • AddStream : 원하는 인덱스에 스트림을 새롭게 할당하고, streams 배열에 그 주소를 저장한다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    
    v2 = (char *)malloc(0x60uLL);
    if ( !v2 )
      exit(1);
    printf("[*] Stream name: ");
    fgets(v2, 32, stdin);
    v2[strcspn(v2, "\n")] = 0;
    printf("[*] RTSP URL: ");
    fgets(v2 + 32, 64, stdin);
    entries[index] = v2;
    ++streamCnt;
    
  • ShowStream : 할당한 스트림을 인덱스 기반으로 접근하고 namertspURL 필드를 출력해준다.
    1
    2
    3
    4
    5
    6
    7
    
    if ( index >= 0 && index < streamCnt && entries[index] )
    {
      v2 = (const char *)entries[index];
      printf("Stream #%d\n", (unsigned int)index);
      printf("Name: %s\n", v2);
      printf("RTSP: %s\n", v2 + 32);
    }
    
  • DeleteStream: 할당한 스트림을 인덱스 기반으로 접근하고, 삭제한다. (free)
  • EditStream : 할당한 스트림을 인덱스 기반으로 접근하고, namertspURL 필드를 수정한다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    if ( index >= 0 && index < streamCnt && entries[index] )
    {
      buf = (char *)entries[index];
      printf("[*] New stream name: ");
      read(0, buf, 0x20uLL);
      printf("[*] New stream url: ");
      read(0, buf + 32, 0x40uLL);
      puts("[+] Name updated.");
    }
    

스트림을 생성하는 함수에서는 STREAM 구조체를 동적 할당하고 streams 배열에 그 주소를 저장하는데, 갯수에 대한 제한이 없기 때문에 8개보다 많이 생성할 수 있다.
그런데 데이터 영역의 구조를 보면, streams[8] 위치에는 userID가 저장되어 있다. 메뉴를 출력하는 과정에서 항상 userID를 출력해주기 때문에, 9번째 스트림을 생성하면 메뉴 출력 과정에서 힙 주소를 leak할 수 있다.

userID는 회원가입과 로그인 과정에서 자유롭게 조작할 수 있기 때문에, userID를 원하는 주소로 입력한 후, 청크를 0번 인덱스에 9개 생성하고 8번 인덱스에 ShowStream 함수나 EditStream 함수로 접근하면, 해당 주소를 읽거나 조작할 수 있다.

힙 영역을 보면 “accounts.db” 파일 작업을 할 때 사용했던 FILE 구조체가 해제된 상태로 저장되어 있는데, FILE 구조체의 끝자락에는 libc 영역의 vtable이 저장되어 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
pwndbg> heap
Allocated chunk | PREV_INUSE
Addr: 0x55555555a000
Size: 0x290 (with flag bits: 0x291)

Allocated chunk | PREV_INUSE
Addr: 0x55555555a290
Size: 0x20 (with flag bits: 0x21)

Free chunk (tcachebins) | PREV_INUSE
Addr: 0x55555555a2b0
Size: 0x1e0 (with flag bits: 0x1e1)
fd: 0x55555555a

Top chunk | PREV_INUSE
Addr: 0x55555555a490
Size: 0x20b70 (with flag bits: 0x20b71)

pwndbg> x/gx 0x55555555a490
0x55555555a490: 0x00007ffff7fa00c0

pwndbg> x/gx 0x00007ffff7fa00c0
0x7ffff7fa00c0 <_IO_wfile_jumps>:       0x0000000000000000

pwndbg> vmmap 0x00007ffff7fa00c0
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
             Start                End Perm     Size Offset File
    0x7ffff7f9e000     0x7ffff7f9f000 ---p     1000 215000 /usr/lib/x86_64-linux-gnu/libc.so.6
►   0x7ffff7f9f000     0x7ffff7fa3000 r--p     4000 215000 /usr/lib/x86_64-linux-gnu/libc.so.6 +0x10c0
    0x7ffff7fa3000     0x7ffff7fa5000 rw-p     2000 219000 /usr/lib/x86_64-linux-gnu/libc.so.6

userID를 해당 주소로 입력하고, 이를 ShowStream 함수로 읽어서 libc 베이스 주소를 얻을 수 있다.

libc 베이스 주소를 얻었다면, userID__environ 변수 주소로 입력하고, 이를 읽으면 스택 주소를 얻을 수 있다.

스택 주소를 얻었다면, 스택 주소를 8씩 빼가면서 반환 주소의 위치를 찾고, 해당 위치에 접근할 수 있다면 EditStream 함수를 이용해 p64(pop_rdi) + p64("/bin/sh" 주소) + p64(ret) + p64(system)로 덮어쓴 후 반환하면 쉘을 실행할 수 있다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

"2025 Hacksium Busan" 카테고리의 게시물