TL; DR
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.
როდესაც ჯავაკი 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:
| Pattern | Trigger | 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, ვინ ვარ მე, uname-a, pwd 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 მასპინძლის სახელი command and 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 /poll — 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 classTask$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
javacas the parent of/bin/sh -c(Unix) or a Windows command interpreter- Child commands
whoami,uname -a,pwd(Unix) ortasklist(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 propertycallback.url
Publisher metadata
- Group
io.github.davidtimur; publisher addressdavudboi999@gmail[.]com; signing keyE520C345EF94423D
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.




