Contents

BreizhCTF 2026 - Seems Empty

Seems Empty

  • Difficulty: Very Easy
  • Category: Reverse
  • Author: AntwortEinesLebens

Description

During an audit, a strange binary was only displaying a bland message. No network activity, no suspicious writes — nothing but perfectly harmless output. Dismissed among unremarkable artifacts, it was nonetheless tampered with by a malware group.

Even what seems empty can hide a secret.

Files:

  • seems-empty.pyc — Python bytecode compiled for CPython 3.12

Solve

Step 1 — Reconnaissance

file seems-empty.pyc
# seems-empty.pyc: Byte-compiled Python module for CPython 3.12 or newer,
# timestamp-based, .py timestamp: Sun Apr  5 12:20:40 2026 UTC, .py size: 1132 bytes

A .pyc is a compiled Python file (bytecode). The source text is not directly readable. First reflex: run strings to look for readable hints.

strings seems-empty.pyc
Franchement, s'ils sont assez limit
s pour s'arr
ter 
 une phrase aussi vide, tant mieux pour nous.
On leur montre trois mots sans int
t, et ils appelleront 
a une analyse.uq
There's nothing 
to see here...c
J'ai planqu
 la charge dans les caract
res invisibles.
Vu le niveau habituel en face, ils vont encore conclure que c'est "juste une string".
TODO :
1. R
rer le contenu cach
 avec StegCloak.
2. Utiliser "empty" comme mot de passe.
3. Extraire le secret sans ab
mer le leurre ; il ne faudrait pas les brusquer intellectuellement.
main.py
get_secretr
On affiche 
a proprement, et ils pourront croire qu'ils ont fait le tour.N)
print
messager
mainr
__main__N)
__doc__r
__name__r
<module>r

Three key pieces of information:

  1. The payload is hidden in invisible characters
  2. The tool used is StegCloak
  3. The password is "empty"

Between "There's nothing " and "to see here...", strings shows what looks like a blank space but actually contains dozens of invisible Unicode characters — that’s where the flag is hidden.

Step 2 — Extracting constants from the bytecode

CPython 3.12 is not supported by standard decompilers like uncompyle6. However, Python can read its own bytecode using the marshal module:

import marshal

with open("seems-empty.pyc", "rb") as f:
    f.read(16)  # skip header (magic + flags + timestamp + source size)
    code = marshal.load(f)

for c in code.co_consts:
    print(repr(c))

Output:

"Franchement, s'ils sont assez limités pour s'arrêter à une phrase aussi vide, tant mieux pour nous.\nOn leur montre trois mots sans intérêt, et ils appelleront ça une analyse."
"There's nothing \u2062\u200d\u200c\u200c\u2062\u2062\u2062\u2062\u200d\u200c\u2062\u200d\u2064\u200c\u2061\u2062\u2063\u2064\u200d\u200c\u200c\u200c\u200d\u200c\u200d\u2062\u2061\u2062\u2062\u2063\u2062\u2061\u200c\u2064\u200c\u2062\u200c\u2062\u2061\u2062\u2061\u200c\u200d\u2062\u200c\u200d\u2064\u2061\u200d\u2061\u2062\u2064\u200c\u2061\u200d\u2062\u2062\u2061\u200c\u200c\u200c\u200d\u200c\u200d\u2062\u200c\u2062\u2062\u2062\u2062\u2061\u2062\u200c\u2062\u2062\u2063\u200d\u2062\u200d\u200c\u2064\u200c\u200c\u2063\u2062\u200c\u200c\u2062\u2061\u2063\u2061\u200c\u200c\u2063\u2062\u2063\u2063\u2061\u200c\u200c\u2062\u200c\u200d\u2062\u2061\u2062\u2064\u2062\u2064\u200c\u2062\u2063\u2061to see here..."
<code object get_secret at 0x7bce79f139e0, file "main.py", line 7>
<code object main at 0x7bce79f464c0, file "main.py", line 20>
'__main__'
None

The cover string contains 113 invisible Unicode characters inserted between "nothing " and "to". They all belong to the 6 codepoints used by StegCloak:

CodepointCharacterRole
U+200CZERO WIDTH NON-JOINERvalue 0
U+200DZERO WIDTH JOINERvalue 1
U+2061FUNCTION APPLICATIONvalue 2
U+2062INVISIBLE TIMESvalue 3
U+2063INVISIBLE SEPARATORvalue 4
U+2064INVISIBLE PLUSvalue 5

Save the string to /tmp/steg_input.txt to keep it alongside the StegCloak install:

for c in code.co_consts:
    if isinstance(c, str) and '⁢' in c:
        with open("/tmp/steg_input.txt", "w", encoding="utf-8") as f:
            f.write(c)
        print("Saved.")

Step 3 — Understanding StegCloak

StegCloak is a Node.js library that hides messages inside text using invisible “zero-width” Unicode characters.

Encoding:

  1. The secret message is compressed then optionally encrypted (AES)
  2. The result is encoded at 2 bits per ZWC (6 codepoints → 4 data combinations + 2 flag characters)
  3. The ZWC sequence is inserted just before a word in the cover text

Decoding: StegCloak splits the text on spaces, finds the word preceded by ZWC characters, and extracts the sequence. That’s why the payload sits just before "to" in "nothing [ZWC]to see here...".

Step 4 — Installing StegCloak via Docker

docker run --rm -v /tmp:/tmp node:18-alpine sh -c \
  'cd /tmp && npm install stegcloak'

Step 5 — Extracting the flag

Create /tmp/decode.js:

const StegCloak = require("/tmp/node_modules/stegcloak");
const fs = require("fs");

const stegcloak = new StegCloak(true, false);
const text = fs.readFileSync("/tmp/steg_input.txt", "utf8");

// Note: the argument order is reveal(coverText, password)
// NOT reveal(password, coverText)
const secret = stegcloak.reveal(text, "empty");
console.log("FLAG:", secret);
node /tmp/decode.js
# FLAG: BZHCTF{n07_r3411y_3mp7y}

Trap to avoid: the method is declared reveal(secret, password) in StegCloak’s source where secret refers to the cover text (not the hidden secret). Swapping the arguments fails silently with "Invisible stream not detected!".

Flag : BZHCTF{n07_r3411y_3mp7y}