Skip to content
Suzu
7 min read

【Crypto】AIS3 Pre-Exam 2026 Writeup: EasyPAINT

A crypto writeup for EasyPAINT, extracting the Nuitka app logic and recovering the cryptographic path.

Series
30 posts
View the complete series →
On this page

【AIS3 Pre-Exam 2026 Writeup】Crypto - EasyPAINT

I started by trying to recover the source from app.bin.

file app.bin
checksec --file=app.bin

app.bin is a stripped PIE ELF. It has Full RELRO, canary, NX, PIE, FORTIFY, SHSTK, and IBT. For a moment it looked like normal binary reversing, but the strings and runtime behavior did not feel like a small C program. It was a Nuitka onefile app.

GDB was not very useful at first. I could see the ELF loader and Python runtime, but not a clean Python source. No useful glibc printf path either. This was a crypto app wrapped as a GUI. No trigger, no path.

The onefile extractor I tried first produced broken filenames. I went back and parsed the Nuitka onefile layout manually. The real child binary is in correct_extract/app.bin.

file correct_extract/app.bin
checksec --file=correct_extract/app.bin

The child binary is still stripped. Direct disassembly was noisy. Runtime hook was easier.

An LD_PRELOAD hook on nanosleep worked better. When Python was initialized, the hook ran a small Python script inside the process and dumped __main__ globals. The dump gave the important constants and function names.

Useful globals:

DIM = 512
P = 257
Q = 127707000586261266406140258152342164811114836442218367658989756920303451547391
LWE_VALUE_BYTES = 33
ENC_MAGIC = b"BDRW"
STR_A_TO_Z = "abcdefghijklmnopqrstuvwxyz_0OABCDEFG"
PASSWORD_SHA256 = "fed99ec1e1ba1e504a432aede0f32956f2562d51b485aef41fdbc04e5ba33697"

There were also functions like:

derive_secret
build_matrix
mat_vec_mul
encrypt_payload
decrypt_payload
stream_xor_encrypt
stream_xor_decrypt
pack_encrypted_payload
unpack_encrypted_payload

The GUI says the password is 64 bytes. That was wrong. Runtime testing showed the real check wants 512 bytes. I patched the hash check in memory once to enter the app and inspect the crypto functions.

enc.bin has a simple format.

python3 - <<'PY'
from pathlib import Path
b = Path("enc.bin").read_bytes()
print(len(b), b[:4], int.from_bytes(b[4:8], "big"))
print((len(b) - 8 - int.from_bytes(b[4:8], "big")) // 33)
PY

The result:

17416 b'BDRW' 512
512

So it is:

BDRW || ciphertext_len || ciphertext || 512 LWE values

Each LWE value is 33 bytes, big endian.

The encryption logic became clear after probing the functions.

secret[j] = int.from_bytes(sha256(bytes([password[j]])).digest(), "big")
product = matrix * secret mod Q
lwe[i] = product[i] + key[i] + 257 * error[i] mod Q
ciphertext = stream_xor_encrypt(plaintext, key)

The stream cipher uses plaintext feedback.

pt[0] = ct[0] ^ key[0]
pt[i] = ct[i] ^ ((key[i] * pt[i - 1]) & 0xff)

At this point I first thought about recovering the password from the SHA256 hash. The alphabet has 36 chars, and the password length is 512. That is not happening directly.

I also tried some clue-based guesses. The challenge text mentions a whale and a sentence like “must imagine … happy”. Some candidate flags and password phrases looked natural, but the stream constraint rejected many of them. Guessing was too weak.

Another dead end was taking lwe[i] % 257 as the key. That would work only if product[i] was zero mod 257. The decrypted bytes were garbage. So the matrix/product part mattered.

I visualized residues and bit patterns too. It looked random. No image hiding in enc.bin.

The part I missed for a while: secret has 512 entries, but each entry only comes from one password byte. The password byte must be in:

abcdefghijklmnopqrstuvwxyz_0OABCDEFG

So secret[j] has only 36 possible values:

H[ch] = int.from_bytes(sha256(ch.encode()).digest(), "big")

For one row of the matrix, the product is not really a 512-dimensional unknown if I only care about counts of each password character in that row.

It can be written as:

sum(count[ch] * H[ch]) mod Q

where count[ch] is bounded. This turns each LWE row into a 36-dimensional bounded knapsack / CVP problem.

Now the problem was small enough for lattice tools.

fpylll was enough for the CVP part:

python3 -m venv .tools/venv
.tools/venv/bin/pip install fpylll cysignals

The lattice basis used one coordinate per possible password character and one large coordinate for the modular sum. After LLL, CVP.closest_vector gave a product close to each LWE value.

The residual is:

residual = product - lwe[i]

From the formula:

lwe[i] = product[i] + key[i] + 257 * error[i] mod Q

the key byte is:

key[i] = (-residual) % 257

The error part vanishes modulo 257.

This recovered 506 of 512 key bytes. The first six were unstable, but the plaintext after that already looked readable:

say_happy_brute_day_to_...

The feedback stream lets me work backwards from the first stable byte. With the known prefix AIS3{, there was only one printable solution for the missing front:

AIS3{whale_will_s

After filling those six keys, the whole plaintext decrypted.

Full exploit:

#!/usr/bin/env python3
from hashlib import sha256
from pathlib import Path
import re
import string

from fpylll import CVP, LLL, IntegerMatrix


Q = 127707000586261266406140258152342164811114836442218367658989756920303451547391
STR_A_TO_Z = "abcdefghijklmnopqrstuvwxyz_0OABCDEFG"
LWE_VALUE_BYTES = 33
DIM = 512


def unpack(path: str):
    blob = Path(path).read_bytes()
    assert blob[:4] == b"BDRW"
    ciphertext_len = int.from_bytes(blob[4:8], "big")
    ciphertext = blob[8 : 8 + ciphertext_len]
    off = 8 + ciphertext_len
    lwe = [
        int.from_bytes(blob[off + i * LWE_VALUE_BYTES : off + (i + 1) * LWE_VALUE_BYTES], "big")
        for i in range(DIM)
    ]
    return ciphertext, lwe


def recover_known_keys(lwe):
    # The 512 secret entries only take 36 possible values: sha256(single password
    # byte).  Each LWE row can therefore be solved as a 36-dimensional bounded
    # knapsack count vector instead of a 512-dimensional binary row.
    hashes = [int.from_bytes(sha256(ch.encode()).digest(), "big") for ch in STR_A_TO_Z]
    bound = 32
    scale = 2048
    n = len(hashes)

    basis = IntegerMatrix(n + 1, n + 1)
    for i, h in enumerate(hashes):
        basis[i, i] = scale
        basis[i, n] = h
    basis[n, n] = Q
    LLL.reduction(basis)

    target_prefix = [(bound // 2) * scale] * n
    keys = []
    for c in lwe:
        v = CVP.closest_vector(basis, tuple(target_prefix + [c]))
        counts = [round(v[i] / scale) for i in range(n)]
        residual = v[-1] - c
        ok = -65790 <= residual <= 0 and all(0 <= x <= bound for x in counts)
        key = (-residual) % 257 if ok else None
        keys.append(key if key is not None and key < 256 else None)
    return keys


def decrypt(ciphertext, keys):
    out = bytearray(len(ciphertext))
    out[0] = ciphertext[0] ^ keys[0]
    for i in range(1, len(ciphertext)):
        out[i] = ciphertext[i] ^ ((keys[i] * out[i - 1]) & 0xFF)
    return bytes(out)


def fill_first_keys(ciphertext, keys):
    # CVP recovers bytes 6..511.  Work backwards from the first stable plaintext
    # byte and use the AIS3{ prefix to determine the six missing stream keys.
    rough_keys = [k if k is not None else 0 for k in keys]
    rough = decrypt(ciphertext, rough_keys)

    candidates = {rough[16]: bytes([rough[16]])}
    printable = list(range(32, 127))
    for i in range(16, 5, -1):
        key = keys[i]
        nxt = {}
        for plain_i, suffix in candidates.items():
            target = ciphertext[i] ^ plain_i
            for prev in printable:
                if ((key * prev) & 0xFF) == target:
                    nxt[prev] = bytes([prev]) + suffix
        candidates = nxt

    prefix = b"AIS3{"
    for suffix in candidates.values():
        known = prefix + suffix
        trial = keys[:]
        trial[0] = ciphertext[0] ^ known[0]
        ok = True
        for i in range(1, 6):
            target = ciphertext[i] ^ known[i]
            prev = known[i - 1]
            sols = [k for k in range(256) if ((k * prev) & 0xFF) == target]
            if not sols:
                ok = False
                break
            trial[i] = sols[0]
        if ok:
            return trial
    raise RuntimeError("failed to recover first six keys")


def main():
    ciphertext, lwe = unpack("enc.bin")
    keys = recover_known_keys(lwe)
    assert sum(k is not None for k in keys) == 506
    keys = fill_first_keys(ciphertext, keys)
    flag = decrypt(ciphertext, keys).decode()
    assert re.fullmatch(r"AIS3\{.*\}", flag, re.DOTALL)
    print(flag)


if __name__ == "__main__":
    main()

Run:

.tools/venv/bin/python solve.py

The flag is 512 bytes. Short exact form:

AIS3{whale_will_say_happy_brute_day_to_ + "u" * 468 + _owo}

Share this page