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.