본문 바로가기

PWN

Assaultcube Fuzzing

Assaultcube는 Open source FPS 게임입니다. 리눅스, 윈도우, 맥에서 플레이 가능하며, 매우 낮은 사양의 PC로도 정상적인 플레이가 가능하다는 특징이 있습니다. 최근 해당 게임에서 취약점이 발견되었고, 해당 취약점을 모티브로한 CTF 문제가 출제된 적이 있습니다. 

 

퍼징 결과 한 개의 원데이 취약점을 발견하였습니다. 본 문서에서는 원데이 취약점이 나왔던 Client가 Server로 보내는 message에 대해서만 퍼징을 했습니다. Map, Server to Client 등 좀 더 많은 벡터에 대해서 퍼징을 한다면 제로데이 취약점을 발견할 수도 있을 것으로 예상됩니다. 나중에 시간될 때 하거나 다른 분이 하시면 구경할 예정.

 

Motivation

  1. CTF 문제랑 1-day 문서 있는 거 보고 재미있어 보였음.

    https://medium.com/@elongl/assaultcube-rce-technical-analysis-e12dedf680e5

    https://dreamhack.io/ctf/writeups/48

  2. 다른 분들이 아직 퍼징 안 돌려본 것 같음

  3. Server-Client 형태의 프로그램 퍼징을 한 번 해보고 싶었음. ★

목표 설정

  1. Attacker가 Client라고 가정. Client에서 Server로 malformed data를 전송
  2. Attacker가 Server라고 가정. Server에서 Client로 malformed data를 전송

여기서는 1번 시나리오를 가정하고 퍼징을 돌렸습니다.

 

Fuzzing

Code 분석

Server는 다음과 같은 방식으로 Client에서 전송한 패킷을 처리합니다. socket 등을 init한 후 client에게 message를 받아 처리하는 함수를 무한 루프 형태로 실행시킵니다. 

void initserver(bool dedicated, int argc, char **argv)
{
		//Init Server


        for(;;) serverslice(5); //무한 Loop
    }
}

 

initserver 서버는 크게 두 부분으로 나눌 수 있습니다. 첫 번째로 Client에서 Event 패킷을 받아오는 부분입니다.

			case ENET_EVENT_TYPE_RECEIVE:
            {
                int cn = (int)(size_t)event.peer->data;
                if(valid_client(cn)) process(event.packet, cn, event.channelID);
                if(event.packet->referenceCount==0) enet_packet_destroy(event.packet);
                break;
            }

두 번째는 Client에서 전송한 Event 패킷을 처리하는 부분입니다.

 

		if(minremain>0)
    {
        processevents();
        checkitemspawns(diff);
        bool ktfflagingame = false;
        if(m_flags) loopi(2)
		        {

 

 

 

클라이언트에게 이벤트 패킷을 받아오는 process 함수부터 보겠습니다. enet_host_service 함수를 통해 클라이언트로부터 패킷을 읽어온 후 그 패킷을 인자로 process 함수를 호출합니다.

 

ENetEvent event;
    bool serviced = false;
    while(!serviced)
    {
        if(enet_host_check_events(serverhost, &event) <= 0)
        {
            if(enet_host_service(serverhost, &event, timeout) <= 0) break;
            serviced = true;
        }
        switch(event.type)
					//생략
						case ENET_EVENT_TYPE_RECEIVE:
            {
                int cn = (int)(size_t)event.peer->data;
                if(valid_client(cn)) process(event.packet, cn, event.channelID);
                if(event.packet->referenceCount==0) enet_packet_destroy(event.packet);
                break;
	            }

process 함수 내부에서는 패킷의 data와 data의 길이를 파싱하여 사용합니다. 이때 clients라는 전역 변수를 참조하네요.

 

void process(ENetPacket *packet, int sender, int chan)
{

    ucharbuf p(packet->data, packet->dataLength);
    char text[MAXTRANS];
    client *cl = sender>=0 ? clients[sender] : NULL;
    pwddetail pd;
    int type;

    if(cl && !cl->isauthed)
    {

이제 Events를 처리하는 processevents 함수를 봅시다. 모든 clients에 대하여 events가 있는지 확인하고 events가 있다면 이벤트를 처리합니다.

 

void processevents()
{
    loopv(clients)
    {
        client *c = clients[i];
        if(c->type==ST_EMPTY) continue;
        if(c->state.akimbomillis && c->state.akimbomillis < gamemillis) { c->state.akimbomillis = 0; c->state.akimbo = false; }
        while(c->events.length())
        {
            gameevent &e = c->events[0];
            if(e.type<GE_SUICIDE)
            {
                if(e.shot.millis>gamemillis) break;
                if(e.shot.millis<c->lastevent) { clearevent(c); continue; }
                c->lastevent = e.shot.millis;
            }
            switch(e.type)
            {
                case GE_SHOT: processevent(c, e.shot); break;
                case GE_EXPLODE: processevent(c, e.explode); break;
                case GE_AKIMBO: processevent(c, e.akimbo); break;
                case GE_RELOAD: processevent(c, e.reload); break;
                // untimed events
                case GE_SUICIDE: processevent(c, e.suicide); break;
                case GE_PICKUP: processevent(c, e.pickup); break;
            }
            clearevent(c);
        }
    }
}

 

 

코드를 분석했으니 이제 퍼징 방법은 간단하게 생각할 수 있습니다.

  1. Server init 함수 실행
  2. process 함수 실행
    1. 입력 값을 받는 부분을 패치하여 AFL의 입력 값을 받도록 함.
  3. eventsprocess 함수 실행
  4. exit(1);

Get Seed

효율적인 퍼징을 위해서는 좋은 시드를 사용해야 합니다. 시드를 만드는 방법에는 직접 코드를 분석하여 패킷 포맷을 맞춰주는 방식이 있고, 실제로 프로그램을 실행시킨 후 전송받은 데이터를 seed로 사용할 수도 있습니다. 여기서는 후자의 방법을 사용했습니다.

process 함수의 시작 부분에 seed 파일을 생성하고 packet data를 length 만큼 저장하는 코드를 추가했습니다. 이 패치를 적용한 프로그램을 시킨 후 클라이언트에서 접속하면 seed 파일이 생성됩니다.

 

void process(ENetPacket *packet, int sender, int chan)
{
    char tmp[0x1000];
    sprintf(tmp,"seed_%d",seed_count++);
    ucharbuf p(packet->data, packet->dataLength);
    FILE* pFile = fopen(tmp, "w");
    fwrite(packet->data,packet->dataLength,1,pFile);
    fclose(pFile);
    char text[MAXTRANS];
    client *cl = sender>=0 ? clients[sender] : NULL;
    pwddetail pd;
    int type;

Code patch

이제 퍼징을 위해 코드 패치를 해봅시다. 우선 initserver 함수에서 무한 Loop를 시작하기 전에 아래 코드를 추가해주었습니다.

  1. 클라이어트가 서버에 접속하기를 기다릴 수 없으니 임의로 client를 추가했습니다.
  2. 클라이언트로부터 패킷을 받아올 수 없으니 임의로 패킷 구조체를 할당한 후 process의 인자로 넘겨줍니다.
  3. process 함수를 실행시켜 client에 event를 추가합니다.
  4. processevents 함수를 실행시켜 event를 처리합니다.
  5. exit 함수를 호출하여 무한 루프가 시작되기 전에 종료합니다.

 

				client &c  = addclient();
        c.isauthed=1;
        ENetPacket * packet_afl = enet_packet_create ("packet",
                                          strlen ("packet") + 1,
                                          ENET_PACKET_FLAG_RELIABLE);
        process(packet_afl,0,1);
        processevents();
        exit(1);

        for(;;) serverslice(5);

process 함수 내부는 afl으로부터 입력을 받도록 패치했습니다.

 

void process(ENetPacket *packet, int sender, int chan)
{

    enet_uint8 * AP = (enet_uint8 *) malloc(0x10000);
    packet->data = AP;
    int tmp = read(0,AP,0x10000);
    packet->dataLength = tmp;
    ucharbuf p(packet->data, tmp);

    char text[MAXTRANS];
    client *cl = sender>=0 ? clients[sender] : NULL;
    pwddetail pd;
    int type;
    if(cl && !cl->isauthed)
    {

마지막으로 Makefile을 아래와 같이 수정했습니다.

# Ideally, you can override these parameters directly via the commandline, or by
# creating a seperate 'Makefile_local' file (this way, your changes aren't
# accidentally commited to the AssaultCube repository).

# AssaultCube now uses clang++ as a compiler, as there have been random crashes
# found to have been caused by the g++ compiler in the past. This seems to have
# been fixed now by relaxing the optimization that g++ does, so although we'll
# continue using clang++ (just in case), you can use g++ if you prefer.
	CXX=afl-clang-fast++ -fsanitize=address -g


//생략
%.h.gch: %.h
	clang++ $(CXXFLAGS) -x c++-header -o $@.tmp $(subst .h.gch,.h,$@) /// 이 부분은 왜인지는 모르겠는데, afl-clang-fast++로 컴파일하면 에러가 납니다. 
	$(MV) $@.tmp $@

 

Check Fuzzing is well done

위의 절차를 통해서 퍼저가 실행되는 것은 확인했지만, 퍼저가 정상적으로 동작하는지 확신하지 못했습니다. 어디서 문서를 보고 따라 한 게 아니기 때문에, 코드 패치를 잘못해서 실제로는 events가 처리되지 않는 등의 문제가 발생할 수 있다고 생각했습니다. (실제로 이러한 문제 때문에 중간에 코드 패치를 한 번 더 해주었습니다.)

우선 기본에 발견된 one-day 취약점이 존재하도록 코드를 패치한 후 퍼저를 돌리기로 결정했습니다. one-day 패치는 process 함수 내에 있는 SV_PROMARYWEAP 이벤트를 아래와 같은 코드로 패치하면 됩니다. 이 취약점은 조건문에 or 연산자가 아닌 and 연산자가 들어가서 공격자가 gun array list에서 벗어나 전역변수에서 OOB 공격을 할 수 있게 하는 취약점입니다.

 

해당 패치를 적용한 후 15시간이 좀 넘게 퍼저를 돌리면 크래시가 나옵니다.

 

 

크래시를 확인하면 원데이 취약점 때문에 크래시가 발생한 것을 확인할 수 있습니다.

 

 

결론

Server client 형태의 프로그램을 퍼징하기 위해 코드 패치를 진행했습니다. 코드 패치는 seed를 얻는 부분, 입력 값을 afl으로부터 받게 하는 부분으로 구성되어 있었습니다. 그 외에는 일반적인 퍼징과 비슷하네요. 제로데이를 찾을 수 있었다면 좋았겠지만, 일단 원데이를 찾음으로써, Server Client 형태의 프로그램 fuzzing을 해봤다는 것에 의의를 뒀습니다. 퍼징에 대한 개선점이 많이 생각나서 아마 나중에 다시 한 번 해보지 않을까 싶습니다.

'PWN' 카테고리의 다른 글

Linux 버그 보는 곳  (1) 2021.06.06
Ida python for bug hunting  (0) 2021.03.11
HITCON CTF 2020 / Dual  (0) 2020.11.30
사이버작전경연대회 2020 / vaccinesimulator  (0) 2020.09.15
pwnable.kr / crcgen  (0) 2020.07.16