Join the discord

SANS Holiday Hack Challenge 2024 - Stealing Christmas

27 Nov, 2024 11:10
SANS Holiday Hack Challenge started few weeks ago, and as usual I've decided to give it a go.
This write-up can be seen as unorthodox solution for the "Hardware Part 2" challenge but it's actually just the result of me messing around with it.

The itself challenge requires the player to somehow find the password for a elf access card management application and the solution is quite trivial.
However, what I've decided to do after solving it, was to dig a little deeper inside the guts of the challenge's infrastructure.

The access cars manager is a console application, where the player can use as a built in command on the Linux box:
slhslh@slhconsole\> slh --help
usage: slh [-h] [--view-config] [--view-cards] [--view-card ID]
           [--set-access ACCESS_LEVEL] [--id ID] [--passcode PASSCODE] [--new-card]

Santa's Little Helper - Access Card Maintenance Tool

options:
  -h, --help            show this help message and exit
  --view-config         View current configuration.
  --view-cards          View current values of all access cards.
  --view-card ID        View a single access card by ID.
  --set-access ACCESS_LEVEL
                        Set access level of access card. Must be 0 (No Access) or 1 (Full
                        Access).
  --id ID               ID of card to modify.
  --passcode PASSCODE   Passcode to make changes.
  --new-card            Generate a new card ID.
I wanted to learn how they implemented it, so I started sniffing around the filesystem and found my target in /bin:
/binslh@slhconsole\> ls -al /bin/slh
-rwsr-xr-x 1 root root 14924816 Oct 16 23:52 /bin/slh
slh@slhconsole\> file /bin/slh 
/bin/slh: setuid ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, 
BuildID[sha1]=4900f1057c817d78f6abf8c33793107b79dcd1a7, for GNU/Linux 2.6.32, stripped
The application was an ELF file (duh, it's a Linux box), but what looked suspicious to me was the fact it's so big - about 14 megabytes.
After looking inside /tmp I've noticed the directories starting with "_MEI":
/tmpslh@slhconsole\> ls -al /tmp    
total 16
drwxrwxrwt 1 root root 4096 Nov 27 17:38 .
drwxr-xr-x 1 root root 4096 Nov 27 17:38 ..
drwx------ 8 root root 4096 Nov 27 17:38 _MEIJmpDMd
drwx------ 3 slh  slh  4096 Nov 27 17:38 _MEIk9yk58
Those are usually dropped by python's PyInstaller bundles, and after doing a strings on the app, it confirmed my suspicions:
stringsslh@slhconsole\> strings /bin/slh | grep "python"
Failed to pre-initialize embedded python interpreter!
Failed to allocate PyConfig structure! Unsupported python version?
Failed to set python home path!
Failed to start embedded python interpreter!
b_brotli.cpython-310-x86_64-linux-gnu.so
b_cffi_backend.cpython-310-x86_64-linux-gnu.so
bcharset_normalizer/md.cpython-310-x86_64-linux-gnu.so
bcharset_normalizer/md__mypyc.cpython-310-x86_64-linux-gnu.so
blib-dynload/_bz2.cpython-310-x86_64-linux-gnu.so
blib-dynload/_codecs_cn.cpython-310-x86_64-linux-gnu.so
blib-dynload/_codecs_hk.cpython-310-x86_64-linux-gnu.so
blib-dynload/_codecs_iso2022.cpython-310-x86_64-linux-gnu.so
blib-dynload/_codecs_jp.cpython-310-x86_64-linux-gnu.so
blib-dynload/_codecs_kr.cpython-310-x86_64-linux-gnu.so
blib-dynload/_codecs_tw.cpython-310-x86_64-linux-gnu.so
blib-dynload/_contextvars.cpython-310-x86_64-linux-gnu.so
blib-dynload/_decimal.cpython-310-x86_64-linux-gnu.so
blib-dynload/_hashlib.cpython-310-x86_64-linux-gnu.so
blib-dynload/_json.cpython-310-x86_64-linux-gnu.so
blib-dynload/_lzma.cpython-310-x86_64-linux-gnu.so
blib-dynload/_multibytecodec.cpython-310-x86_64-linux-gnu.so
blib-dynload/_opcode.cpython-310-x86_64-linux-gnu.so
blib-dynload/_queue.cpython-310-x86_64-linux-gnu.so
blib-dynload/_sqlite3.cpython-310-x86_64-linux-gnu.so
blib-dynload/_ssl.cpython-310-x86_64-linux-gnu.so
blib-dynload/_uuid.cpython-310-x86_64-linux-gnu.so
blib-dynload/resource.cpython-310-x86_64-linux-gnu.so
blib-dynload/termios.cpython-310-x86_64-linux-gnu.so
blibpython3.10.so.1.0
6libpython3.10.so.1.0
From here the plan was simple - exfiltrate the executable, unpack the PyInstaller and decompile the user code pyc.
Unfortunately, the console had a buffer of only few kilobytes, so exfiltrating a 14MB beast of a file over it was out of the question.
And I really don't need to, because the largest portion of the bundle is the standalone Python runtime, while the user code that I was looking for, should be only a few kilobytes.
The new plan was to unpack the PyInstaller on the Linux box, then extract only the pyc through the console.
Also, the fact that the Linux box had running Python3 on it helped me a lot.

In general, the PyInstaller is pretty straightforward for unpacking - somewhere near the end of the file there is the MEI header that holds the offsets to the data sections:
the MEI headerstruct MEI_HEADER {
    byte    signature;            // always "\x4D\x45\x49\x0C\x0B\x0A\x0B\x0E"
    DWORD   offset_data_start;    // offset to file data
    DWORD   offset_toc_start;     // offset to table of content
    DWORD   toc_size;             // size of table of content
    DWORD   u4;                   // unknown, don't care either
};
These offsets are relative to the beginning of the MEI_HEADER structure and go backwards from it.
I'm interested in the TOC (table of contents) where all the file entries are described, so to get there and grab it, I've executed the following code:
Get the TOC>>> data = open("/bin/slh", "rb").read()
>>> data.rfind(b"MEI")
14922682
>>> import struct
>>> (sig, offset_data_start, offset_toc_start, toc_size, u4) = struct.unpack_from(">8sLLLL", data, 14922682)
>>> offset_data_start = 14922682-offset_data_start+0x58
>>> offset_toc_start = offset_data_start+offset_toc_start
>>> data_toc = data[offset_toc_start:offset_toc_start+toc_size]
>>> import base64
>>> base64.b64encode(data_toc)
b'AAAAIAAAAAAAAADNAAABDwFtc3RydWN0AAAAAAAAAAAAAAAwAAAAzQAAB...
This Base64 encoded string holds the entire table of content (file description structure) of the package, so all I had to do next was to decode it on my machine and parse it.
The TOC is an array of the following entries:
TOC_ROWstruct TOC_ROW {
    DWORD    offset_next;       // offset to the next file entry
    DWORD    offset;            // offset to the file data
    DWORD    size_zip;          // compressed file size
    DWORD    size_raw;          // uncompressed file size
    DWORD    flag_compressed;   // is file compressed
    byte     type;              // file type (eg. runtime, user code, etc.)
};
The slh was located at offset 0x322A and its size was 0xF0C, so while at it, I've decompressed it and then exfiltrated it like so:
Get the slh pyc>>> slh_data = data[offset_data_start+0x322A:offset_data_start+0x322A+0xF0C]
>>> import zlib
>>> base64.b64encode(zlib.decompress(slh_data))
b'4wAAAAAAAAAAAAAAAAAAAAAEAAAAQAAAAHN8AAAAZABkAWwAWgBkAGQBbAFaAWQAZAFsAloCZABkAWwDWg...
This Base64 data was holding the uncompressed pyc of the challenge, and all I had to do was fixing its header and decompile it:
Partially decompiled slh.pycimport argparse
import sqlite3
import uuid
import hashlib
import hmac
import os
import requests
PASSCODE = os.getenv('SLH_PASSCODE', 'CandyCaneCrunch77')
secret_key = b'873ac9ffea4dd04fa719e8920cd6938f0c23cd678af330939cff53c3d2855f34'
expected_signature = 'e96c7dc0f25ebcbc45c9c077d4dc44adb6e4c9cb25d3cc8f88557d9b40e7dbaf'
completeArt = '\n       *   *   *   *   *   *   *   *   *   *   *\n'\
              '   *                                             *\n'\
              '*      ❄  ❄  ❄  ❄  ❄  ❄  ❄  ❄  ❄  ❄  ❄  ❄  ❄     *\n'\
              '*  $$$$$$\\   $$$$$$\\   $$$$$$\\  $$$$$$$$\\  $$$$$$\\   $$$$$$\\  * \n'
              '  * $$  __$$\\ $$  __$$\\ $$  __$$\\ $$  _____|$$  __$$\\ $$  __$$\\ *\n'
              '   *$$ /  $$ |$$ /  \\__|$$ /  \\__|$$ |      $$ /  \\__|$$ /  \\__| *\n'
              '    $$$$$$$$ |$$ |      $$ |      $$$$$\\    \\$$$$$$\\  \\$$$$$$\\   \n'
              '   *$$  __$$ |$$ |      $$ |      $$  __|    \\____$$\\  \\____$$\\  *\n'
              '  * $$ |  $$ |$$ |  $$\\ $$ |  $$\\ $$ |      $$\\   $$ |$$\\   $$ | *\n'
              '*   $$ |  $$ |\\$$$$$$  |\\$$$$$$  |$$$$$$$$\\ \\$$$$$$  |\\$$$$$$  |   *\n'
              ' *  \\__|  \\__| \\______/  \\______/ \\________| \\______/  \\______/  *\n'
              '*         *    ❄             ❄           *        ❄    ❄    ❄   *\n'
              '   *        *     *     *      *     *      *    *      *      *\n'
              '   *  $$$$$$\\  $$$$$$$\\   $$$$$$\\  $$\\   $$\\ $$$$$$$$\\ $$$$$$$$\\ $$$$$$$\\  $$\\  *\n'
              '   * $$  __$$\\ $$  __$$\\ $$  __$$\\ $$$\\  $$ |\\__$$  __|$$  _____|$$  __$$\\ $$ | *\n'
              '  *  $$ /  \\__|$$ |  $$ |$$ /  $$ |$$$$\\ $$ |   $$ |   $$ |      $$ |  $$ |$$ |*\n'
              '  *  $$ |$$$$\\ $$$$$$$  |$$$$$$$$ |$$ $$\\$$ |   $$ |   $$$$$\\    $$ |  $$ |$$ | *\n'
              ' *   $$ |\\_$$ |$$  __$$< $$  __$$ |$$ \\$$$$ |   $$ |   $$  __|   $$ |  $$ |\\__|*\n'
              '  *  $$ |  $$ |$$ |  $$ |$$ |  $$ |$$ |\\$$$ |   $$ |   $$ |      $$ |  $$ |   *\n'
              '*    \\$$$$$$  |$$ |  $$ |$$ |  $$ |$$ | \\$$ |   $$ |   $$$$$$$$\\ $$$$$$$  |$$\\ *\n'
              ' *    \\______/ \\__|  \\__|\\__|  \\__|\\__|  \\__|   \\__|   \\________|\\_______/ \\__|  *\n'
              '  *                                                            ❄    ❄    ❄   *\n'
              '   *      *    *    *    *    *    *    *    *    *    *    *    *    *    *                                                                                                                                        \n'

class GameCLI:
    
    def __init__(self):
        self.parser = argparse.ArgumentParser("Santa's Little Helper - Access Card Maintenance Tool", **('description',))
        self.setup_arguments()
        self.db_file = 'access_cards'
        self.api_endpoint = os.getenv('API_ENDPOINT', '')
        self.api_port = os.getenv('API_PORT', '')
        self.resource_id = os.getenv('RESOURCE_ID', '')
        self.challenge_hash = os.getenv('CHALLENGE_HASH', '').encode('utf8')

    def setup_arguments(self):
        
        def access_level(value):
            ivalue = int(value)
            if ivalue not in (0, 1):
                raise argparse.ArgumentTypeError(f'''Invalid access level: {value}. Must be 0 (No Access) or 1 (Full Access).''')

        arg_group = self.parser.add_mutually_exclusive_group()
        arg_group.add_argument('--view-config', 'store_true', 'View current configuration.', **('action', 'help'))
        arg_group.add_argument('--view-cards', 'store_true', 'View current values of all access cards.', **('action', 'help'))
        arg_group.add_argument('--view-card', int, 'ID', 'View a single access card by ID.', **('type', 'metavar', 'help'))
        arg_group.add_argument('--set-access', access_level, 'ACCESS_LEVEL', 'Set access level of access card. Must be 0 (No Access) or 1 (Full Access).', **('type', 'metavar', 'help'))
        self.parser.add_argument('--id', int, 'ID', 'ID of card to modify.', **('type', 'metavar', 'help'))
        self.parser.add_argument('--passcode', str, 'PASSCODE', 'Passcode to make changes.', **('type', 'metavar', 'help'))
        arg_group.add_argument('--new-card', 'store_true', 'Generate a new card ID.', **('action', 'help'))

    def run(self):
        self.args = self.parser.parse_args()
        if self.args.view_config:
            self.view_config()
            return None
        if None.args.view_cards:
            self.view_access_cards()
            return None
        if None.args.view_card is not None:
            self.view_single_card(self.args.view_card)
            return None
        if None.args.set_access is not None:
            self.set_access(self.args.set_access, self.args.id)
            return None
        if None.args.new_card:
            self.new_card()
            return None
        None('No valid command provided. Use --help for usage information.')

    def view_config(self):
        print('Error loading config table from access_cards database.')

    def view_access_cards(self):
        print('Current values of access cards: (id, uuid, access, signature)')
        conn = sqlite3.connect(self.db_file)
        cursor = conn.cursor()
        cursor.execute('\n            SELECT * FROM access_cards\n        ')
        rows = cursor.fetchall()
        for row in rows:
            print(row)
        conn.close()

    def view_single_card(self, card_id):
        print(f'''Details of card with ID: {card_id}''')
        conn = sqlite3.connect(self.db_file)
        cursor = conn.cursor()
        cursor.execute('\n            SELECT * FROM access_cards WHERE id = ?\n        ', (card_id,))
        row = cursor.fetchone()
        conn.close()
        if card_id == 42:
            self.check_signature()
        if row:
            print(row)
            return None
        None(f'''No card found with ID: {card_id}''')

    def set_access(self, access, id):
        if self.args.passcode == PASSCODE:
            if self.args.id is not None:
                card_data = self.get_card_data(id)
                sig = self.generate_signature(access, card_data['uuid'], **('access', 'uuid'))
                conn = sqlite3.connect(self.db_file)
                cursor = conn.cursor()
                cursor.execute('\n                    UPDATE access_cards SET access = ?, sig = ? WHERE id = ?\n                ', (access, sig, id))
                conn.commit()
                conn.close()
                if self.args.id == 42:
                    self.check_signature()
                print(f'''Card {id} granted access level {access}.''')
                return None
            None('No card ID provided. Access not granted.')
            return None
        None('Invalid passcode. Access not granted.')

    def debug_mode(self):
        if self.args.passcode == PASSCODE:
            if self.args.id is not None:
                card_data = self.get_card_data(self.args.id)
                sig = self.generate_signature(card_data['access'], card_data['uuid'], **('tokens', 'uuid'))
                conn = sqlite3.connect(self.db_file)
                cursor = conn.cursor()
                cursor.execute('UPDATE access_cards SET sig = ? WHERE id = ?', (sig, self.args.id))
                conn.commit()
                conn.close()
                print(f'''Setting {self.args.id} to debug mode.''')
                print(self.get_card_data(self.args.id))
                return None
            None('No card ID provided. Debug mode not enabled.')
            return None
        None('Invalid passcode. Debug mode not enabled.')

    def get_card_data(self, card_id):
        pass
    # WARNING: Decompyle incomplete

    def new_card(self):
        id = str(uuid.uuid4())
        print(f'''Generate new card with uuid: {id}''')

    def generate_signature(self, access, uuid = (None, None)):
        data = f'''{access}{uuid}'''
        signature = hmac.new(secret_key, data.encode(), hashlib.sha256).hexdigest()
        return signature

    def check_signature(self):
        card_data = self.get_card_data(42)
        if card_data and card_data['sig'] == expected_signature:
            self.send_hhc_success_message(self.api_endpoint, self.api_port, self.resource_id, self.challenge_hash, 'easy')
            return print(completeArt)

    def send_hhc_success_message(self, api_endpoint, api_port, resource_id, challenge_hash, action):
        url = f'''{api_endpoint}:{api_port}/turnstile'''
        message = f'''{resource_id}:{action}'''
        h = hmac.new(challenge_hash, message.encode(), hashlib.sha256)
        hash_value = h.hexdigest()
        data = {
            'rid': resource_id,
            'hash': hash_value,
            'action': action }
        querystring = {
            'rid': resource_id }
    # WARNING: Decompyle incomplete

if __name__ == '__main__':
    game_cli = GameCLI()
    game_cli.run()
    return None
The decompilation failed on a couple of places, but whatever.
Of course this was not the correct way to solve it, but it was a fun experiment nonetheless.
© nullsecurity.org 2011-2024 | contacts