JavacDoor, Maven Malware That Runs at Compile Time

JavacDoor: A Maven Artifact That Ran During Compilation, Not Installation

TL博士

A Maven Central artifact published as io.github.davidtimur:c2-lab shipped nine releases, eight of which executed a remote-access payload during the compilation of any downstream project that placed the jar on its annotation-processor path. No application code had to import it. No line of source had to reference it. There was no install script, no postinstall equivalent, and no lifecycle hook of any kind for tooling to inspect.

The execution vector is a single 46-byte file inside the jar: a Java service-provider registration that names a class implementing javax.annotation.processing.Processor. The Java compiler discovers such registrations automatically. Once found, javac instantiates and runs the class as a normal part of compiling. That means the payload's runtime environment is the build machine, at the moment the build machine is doing the one thing it exists to do.

Across the nine releases the command-and-control channel was rebuilt three times: an operator-supplied callback URL, then a reverse shell over an ngrok TCP tunnel, then an HTTP polling channel whose paths shifted twice more. The final release installed a no-op TLS trust manager as the JVM default, accepting any certificate for the duration of the compile.

The artifact carried the words "C2 Lab Payload" in its own POM and an MIT licence. It was live on Maven Central for the entire period we observed it and has since been removed, along with its whole io.github.davidtimur 組。

Anatomy: the compiler as execution engine

Java’s annotation processing framework exists so libraries can generate code at compile time — the mechanism behind Lombok, Dagger, and a long list of ORM and serialisation tools. A processor announces itself with a plain-text file inside the jar:

META-INF/services/javax.annotation.processing.Processor

The contents of that file in every affected release, verbatim and complete:

io.github.davidtimur.c2lab.C2Processor

The file is byte-identical across releases 1.0.1 through 1.0.8, md5 7d2a08a5c8869a47eea9fa62487dfbe4. Release 1.0.0 does not contain it.

日期 javac的 runs, it scans the annotation-processor path for these service entries and loads what it finds. Nothing in the project being compiled needs to mention the processor, annotate anything, or configure anything. Presence on the path is sufficient. This is the property that separates JavacDoor from the supply-chain patterns most tooling is built around:

信號模式 觸發 Visible as
npm install hook npm install scripts.postinstall in the manifest
Python import-time payload first import of the module module-level statement in source
JavacDoor javac on any downstream project a service-registration filename

An install hook is a declaration in a manifest, and a manifest is the first thing anyone reads. An import-time payload at least sits in readable source. A service registration is neither: it is a filename plus one line naming a class, and the behaviour lives in compiled bytecode a directory away.

The payload class itself performs host reconnaissance by shelling out. The constant pools of the compiled classes contain / bin / sh的, WHOAMI, uname -a, 密碼 on the Unix path and 任務列表 on the Windows path, together with redirectErrorStream to merge the child process’s error output into the captured stream. Two JSON templates carry results off the host — a registration beacon:

{"host":"%s","os":"%s","user":"%s","dir":"%s"}

populated from the 主機 命令和 os.name, user.name以及 user.dir system properties, and a result callback:

{"version":"%s","host":"%s","time":"%s","output":"%s"}  

The progress markers left in the compiler’s own output are unusually candid: [C2] compile-time execution complete, [C2] callback sent → HTTP , [C2] shell connected to .

Nine releases, three C2 generations

The releases are not nine copies of one payload. They are an iteration log, and reading them in order shows the channel being rebuilt while the delivery vector stayed fixed.

發行 Auto-executes at compile 渠道
1.0.0 no (no service file) Operator-supplied callback URL only
1.0.1 yes (vector introduced) Operator-supplied callback URL
1.0.2 「有」。 Reverse shell, ngrok TCP tunnel
1.0.3 「有」。 HTTP channel, /register /cmd /out
1.0.4 「有」。 HTTP channel, /register /cmd /out
1.0.5 「有」。 HTTP channel, /register /poll /out
1.0.6 「有」。 HTTP channel, /register /poll /out
1.0.7 「有」。 HTTP channel, /register /cmd
1.0.8 「有」。 HTTP channel + TLS validation disabled

Three details in that table are worth drawing out, because each one changes how the artifact set should be read.

The vector arrives at 1.0.1, not 1.0.2. Release 1.0.0 carries the same reconnaissance and callback logic but has no service file and no annotation-processing imports; it runs only if something calls it. From 1.0.1 the service file is present and the compiled classes import javax.annotation.processing.SupportedSourceVersion. Any assessment that compares the two ticketed releases — 1.0.0 and 1.0.2 — correctly concludes that something changed, but misidentifies where.

The reverse shell exists in exactly one release. Release 1.0.2 contains 0.tcp.ngrok[.]io, the log line [C2] shell connected to 0.tcp.ngrok[.]io:19823, an interactive banner c2-shell, and a frame terminator __END__. Releases 1.0.3 onward contain none of those and instead reach an HTTPS host. A takedown request naming only the ticketed releases would have cited a dead TCP endpoint while leaving the live HTTP channel, present in six later releases, unmentioned.

The final release removes transport verification. Release 1.0.8 adds a class implementing javax.net.ssl.X509TrustManager whose certificate-checking methods do nothing, an always-true hostname verifier registered through setDefaultHostnameVerifier,配備 trustAll routine that installs both as the JVM’s defaults. The effect is that for the remainder of that compile, the JVM accepts any certificate from any host — not only for the payload’s own traffic, but for anything else the build does over TLS afterwards.

The HTTP channel, in all six releases that use it, is fronted by a single host: tableful-fervor-crazed.ngrok-free[.]dev. Every request carries the header ngrok-skip-browser-warning, which suppresses the interstitial page that free ngrok tunnels serve to browsers. Path sets shift across releases — /out disappears at 1.0.7, and /cmd alternates with /輪詢 — but the host never changes.

The self-exclusion tell

Every POM from 1.0.1 onward sets a compiler argument for the artifact’s own build:

-proc:none

That flag disables annotation processing. Its effect here is precise: when the project containing the processor is itself compiled, the processor does not run.

This flag has entirely ordinary uses. A project that ships an annotation processor often needs to avoid applying that processor to itself during bootstrap, and build-tool documentation recommends exactly this. Taken alone it proves nothing.

Taken together with what the processor does, though, it describes a specific asymmetry: the code executes on the machines of everyone who compiles against the artifact, and not on the machine that builds the artifact. The flag appears in the same release that introduces the service file — 1.0.1 — and in every release after it. The correlation between “the release where compile-time execution begins” and “the release where compile-time execution is switched off locally” is the single most useful analytic signal in the artifact set, and it is visible in a plain-text POM without decompiling anything.

We note the effect and stop there. Nothing in the artifacts speaks to why the flag was set.

One further piece of metadata deserves mention, mostly to dispose of it. The POM names the project “C2 Lab Payload”, describes it as a “C2 lab payload artifact”, and licenses it MIT. Self-labelling of this kind is sometimes offered as evidence that a package is a research exercise rather than a live threat, and occasionally that reading is correct — a declared canary with no reachable infrastructure is a different object from this one. It does not apply here. A functional implant published to a public repository, reachable by any consumer, with outbound infrastructure that was rebuilt three times across nine releases, is a live capability regardless of what its metadata calls it. The name in the POM changes nothing about what happens on a machine that compiles against it.

Indicators for build machines

If a build host compiled against this artifact, the evidence sits in build logs and network telemetry rather than in a persistent implant on disk — the payload runs inside the compiler process and exits with it.

In the jar or local repository cache

  • META-INF/services/javax.annotation.processing.Processor 命名 io.github.davidtimur.c2lab.C2Processor
  • Service file md5 7d2a08a5c8869a47eea9fa62487dfbe4
  • Classes under io/github/davidtimur/c2lab/: C2Processor, C2Task, Task, and in 1.0.8 the inner class Task$1

In build output

  • [C2] compile-time execution complete
  • [C2] callback sent → HTTP
  • [C2] callback failed:
  • [C2] shell connected to
  • Interactive banner c2-shell; frame terminator __END__

In process telemetry

  • javac as the parent of /bin/sh -c (Unix) or a Windows command interpreter
  • Child commands whoami, uname -a, pwd (Unix) or tasklist (Windows) parented by a compile step

In network telemetry

  • Outbound TCP to 0.tcp.ngrok[.]io:19823 (release 1.0.2)
  • HTTPS to tableful-fervor-crazed.ngrok-free[.]dev, paths /register, /cmd, /poll, /out (releases 1.0.3 to 1.0.8)
  • Request header ngrok-skip-browser-warning: true
  • Request bodies matching {"host":...,"os":...,"user":...,"dir":...} or {"version":...,"host":...,"time":...,"output":...}

In configuration

  • 環境變量 CALLBACK, CALLBACK_URL; system property callback.url

Publisher metadata

  • 類別 io.github.davidtimur; publisher address davudboi999@gmail[.]com; signing key E520C345EF94423D

A build that ran release 1.0.8 warrants one extra check. Because that release installs a permissive trust manager as a JVM-wide default, any TLS connection made later in the same JVM — dependency resolution, artifact upload, a deployment step — proceeded without certificate validation. Traffic from that window should not be assumed authenticated.

Why compiled artifacts need different scanning

JavacDoor is a useful test case because it defeats two common assumptions at once, and neither failure is specific to any one vendor’s tooling.

The first assumption is that dangerous code announces itself in a manifest. A great deal of supply-chain tooling is organised around lifecycle hooks, because for npm and PyPI that is where the action usually is. JavacDoor has no hook. Its trigger is a service-registration file whose name is a Java interface and whose content is a class name. To catch that statically you have to treat META-INF/services/javax.annotation.processing.Processor as an execution entry point in its own right, on par with a 安裝後 script — and then follow the named class into bytecode. Ecosystems have their own auto-discovery mechanisms of this shape, and each is an entry point whether or not tooling enumerates it as one.

The second assumption is that strings live in source files. For a jar, the endpoints, the shell commands, the JSON templates, and the log markers are all in the constant pools of 。班級 files. Tooling that greps text finds nothing — not because the strings are obfuscated, but because they are in a structured binary container that a text scan does not parse. Every network indicator in this post came out of constant-pool parsing. One of them is a fully-formed https:// URL sitting in plain view inside a class file; a text scan of the jar’s readable content still would not surface it. There is no encoding to defeat here, only a container format to read.

Both gaps have the same shape: an artifact format was treated as a bag of files rather than as a structure with defined semantics. The corrective is unglamorous — parse the container, enumerate the ecosystem’s own auto-discovery entry points, and follow them into compiled code. For build-time vectors specifically, a third check is cheap and surprisingly diagnostic: compare what an artifact does to consumers against what it exempts itself from. An artifact that registers a compile-time processor and simultaneously disables compile-time processing for its own build has told you something about itself in two lines of plain-text POM.

For teams consuming Maven artifacts today, three practical measures follow:

  • Treat the annotation-processor path as an execution boundary. Dependencies that land there run code in your build. Where a build does not need annotation processing, -proc:none is as useful defensively as it apparently was locally here; where it does, pin the processor set explicitly rather than inheriting it from the compile classpath.
  • Log compiler subprocesses. A compile step that spawns a shell is anomalous in most projects and is trivially alertable.
  • Do not treat metadata as testimony. “Lab”, “test”, “payload”, and “PoC” in a package name or description are not scope limits. Reachability and behaviour are.

The artifact and its entire group were removed from Maven的 Central following our report; both the artifact path and the group path now return 404, and the Central index reports no matching coordinates. That closes this artifact. It does not close the vector, which is a documented feature of the Java compiler and available to anyone who publishes a jar.

參考

This post cites no external sources. All findings are first-hand static analysis of the nine published jars, retrieved from Maven Central before removal. No code from the artifacts was executed at any point.

sca-tools-software-composition-analysis-tools
優先處理、補救並保護您的軟體風險
註冊免費帳號。
不需要信用卡。

確保您的軟體開發和交付安全

使用 Xygeni 產品套件