Medium · Practitioner
Scope and subject
This is a technical account of a security review of the Linux desktop client of
Burp Suite Professional 2026.10.1 (burpsuite.jar build 54773, bundled OpenJDK 26.0.2,
Zelix KlassMaster–obfuscated, 53,562 classes).
| Property |
Value |
| Installer |
burpsuite_linux_v2026_10_1.sh, 421,305,049 B, MD5 9f528745067e465f60cc945b9edbaf71 |
| Application JAR |
163,230,314 B, SHA-256 0d8aa48a… |
| Manifest |
Implementation-Version: 54773, Burp-JAR-Type: Installer |
| Installer framework |
install4j 12.0.5 (build 12102), media set 82, app id 6592-1155-2163-3973 |
Scope boundary. The delivered client software only. PortSwigger-hosted endpoints
(/activate/Activate.ashx, /api/desktop/v1.0/license/request, /Burp/Releases/CheckForUpdates) were
identified statically and deliberately not attacked. No remote service was tested or fuzzed.
Testing environment. Isolated HOME, Xvfb virtual display, product installed to a separate volume,
an in-process Java agent for observation, and a JDWP tracer. The operator's real profile was never
modified.
What "in-process" means here, and why it is not a dismissal. Every licensing finding requires code
executing inside the Burp JVM. That is a real precondition and it is stated everywhere in this report. It
is also the product's own documented model: the extension API runs third-party code in-process, and
user.vmoptions is a documented, user-editable file that the launcher -include-options's, so
-javaagent: is supported configuration rather than an exploit.
Revision 1.3 sharpened this paragraph's weakest word. "In-process" is not even the narrow precondition the
earlier drafts implied: the shared snippet compiler is entitlement-free for three of the fourteen script
kinds (VIEW_FILTER, CAPTURE_FILTER, AGENTIC_SCRIPT), so a Community install already compiles,
defines and instantiates user-written Java inside itself — no agent, no extension, no licence (§6.13d).
What keeps that from being a remote primitive is input provenance, not a gate.
Changelog
Every entry below is a change to this account. The target has been frozen at
burpsuite_linux_v2026_10_1.sh (md5 9f528745…) since the first pass.
| Version |
Date |
Change |
Evidence |
| 1.0 |
29 Oct 2026 |
Licensing pass criteria, the four-phase PoC (Zlaa.ZL + Zwdw.Zl), the installer/launcher review, failure classes F1–F7. |
analysis/, logs/, poc/ |
| 1.1 |
30 Oct 2026 |
Evidence bundle reconciled; the scripting lead resolved from "strongest open question" to a tested result. Added INS-12 (the previous-installations DB as a JVM authority) and SNIP-1 (unsandboxed first-party snippet execution), plus F8 and F9. |
§6.12, §6.13, paper-work/ |
| 1.2 |
30 Oct 2026 |
Two environment-limited questions closed: the earliest possible injection point (+2189 ms, 9 ms after the suite object) and a complete fake-JDK tree, which upgraded INS-12 to a demonstrated hijack that copies the planted runtime into the installation. |
§6.12, §6.13c, EarlyProbe logs |
| 1.3 |
30 Oct 2026 |
This revision — the compile-service escalation audit (closed negative). Zc63.compile has 7 callers, 6 with zero Zwh1 references; the entitlement actually lives on the rule type burp.Zr_b, where VIEW_FILTER/CAPTURE_FILTER/AGENTIC_SCRIPT carry null. So the pipeline is reachable in Community without a licence — which widens LIC-0's precondition — and no consumer compiles traffic-derived source, which is why the website→RCE escalation does not exist. Added §6.13d, failure class F10, E.7.1, H.3 #2 closed (with #11/#12 opened for the two remaining deliveries), and recommendations 15/18. |
SNIP1-ESCALATION-RESULT.md, xrefdb.tsv, decompiled Zr_b/Zpvy |
The revision history is itself part of the argument: of the four passes, three ended by closing a
question in the negative, and those closures constrained the threat model more than the confirmations did.
Medium · Practitioner
The result in brief
Three findings carry the report.
1. The licence decision is a mutable object reference (LIC-0). Publishing any object that implements
burp.Zp1p into the suite's current-licence field — via the public setter burp.Zlaa.ZL — satisfies all
26 entitlements. A second public method, burp.Zwdw.Zl(record), commits a caller-supplied record as
"activated" (Zqxl) with no validation at all.
[control] Zlaa.Zw() = null Zthb.Zf: allowed 0/26 title "Community Edition"
[injected] Zlaa.Zw() = FakeZa7e[…] Zthb.Zf: allowed 26/26 title "Professional"
prefs written: none
2. The gate is a presence check, and the crypto is fine (LIC-1, LIC-3). burp.Zthb.Zf(entitlement)
returns true when the entitlement is null, and otherwise only tests Zlaa.Zw() != null — no expiry,
product, edition, seat or activation re-evaluation. Meanwhile the validator itself held up: 4,220 of
4,220 adversarial inputs rejected with zero unexpected exceptions, and on-disk tampering rejected locally
with no outbound traffic.
3. The installer runs a JVM named by its own history file (INS-12). The previous-installations database
is consulted as JVM candidate #4; <entry>/jre/bin/java is executed, and if it looks like a JDK it is
adopted, runs the installation, and is copied into the installed product as its runtime.
Secondary findings — the validator's public mutable gate flag, plaintext licence key in a world-readable
prefs file, unsigned revocation instructions in the update channel, and an entitlement-gated REST API — are
in the Expert tier and in §6 of the full report.
Medium · Practitioner
Architecture
The licensing subsystem, as mapped from bytecode and decrypted constants:
UI (Zqq2 licensing wizard; Zlxq project wizard)
└ burp.Ztu6 (interface) ──implemented by── burp.Zwdw licence service — single chokepoint
├ burp.Ztr3 ──implemented by── burp.Zwbm store/config facade
│ ├ burp.Zait java.util.prefs persistence (node "burp")
│ ├ burp.Zlya revocation blocklist: 34 sets, 168,035 ids
│ └ burp.Z_ca local validator (dispatcher)
│ ├ burp.Zzh8 → trampoline chain (key validator)
│ └ burp.Zica → trampoline chain (activation-blob validator, returns void)
└ burp.Zlaa ──implemented by── burp.Zruv licence-info store (Zruv.ZE / .ZK)
burp.Zlac (entitlement interface) ──implemented by── burp.Zthb the feature gate, 251 call sites
Storage. Everything persists through java.util.prefs, i.e. ~/.java/.userPrefs/burp/prefs.xml:
| Pref key |
Meaning |
license<M> (M ∈ 1,2,3 = desktop / enterprise server / enterprise agent) |
licence key text |
expired_license<M> |
payload driving the renewal UI |
use_community_edition |
forces Community at startup |
Zm(digest(licenceNumber)) |
activation response blob, keyed by a digest of the licence number |
burp.eula |
EULA acceptance version (13 in this build) |
Two structural facts, both verified by cross-reference over a 2.4 M-entry reference database rather
than by reading a single class:
burp.Zlac.Zf is invoked at 251 call sites across the product.
burp.Zlaa.ZL / .ZJ — the only writers of the trust anchor — have five call sites each, all inside
burp.Zwdw.
The second is genuinely good design: the write surface of the trust anchor is one class. The defect is that
the class trusts its caller.
Medium · Practitioner
The bypass
Two facts make the bypass possible.
Publication happens before activation. In Zwdw.ZF(key, …), once the key validator returns, the
licence object is published before any activation check:
this.Ze.Zz(false); // clear use_community_edition
synchronized (this.ZU) {
this.Z_ = new Zah7(); // state: "awaiting activation"
this.ZD.ZL(var3); // *** trust anchor published here ***
this.ZD.ZJ(null);
}
return this.ZO(var2); // only now is the activation blob examined
So activation is not a security precondition for entitlement — it only changes which state object the
wizard displays.
The commit function validates nothing. Zwdw.Zl takes a caller-built record and, on the
non-expired branch, performs:
// burp.Zwdw.Zl(Z_l_ record) — decompiler failed; bytecode retained
0b: invokevirtual burp/Z_l_.ZKl ()Lburp/Zp1p; // info = record's licence object
11: invokevirtual burp/Z_l_.ZKD ()Ljava/lang/String; // blob = record's activation blob
15: ifnull 29 // if blob != null …
1f: invokevirtual burp/Zwdw.ZO (…)V // … persist it (never validate it here)
29: invokevirtual burp/Z_l_.ZKN ()Z // expired flag
2d: ifeq 62
62: invokeinterface burp/Zlaa.ZL (Lburp/Zp1p;)V // Zlaa.ZL(info)
71: invokeinterface burp/Zlaa.ZJ (Lburp/Zp1p;)V // Zlaa.ZJ(null)
7a: invokespecial burp/Zqxl.<init> ()V
7f: putfield burp/Zwdw.Z_ // state := Zqxl
There is no verification call in the method.
The demonstration. analysis/poc-license-pass.sh runs four phases in ~40 s: control (Community,
0/26), bypass (Professional, 26/26, "no licence keys written"), mutability (reset → 0/26, fake → 26/26
without restart), and an offline public-API-only proof. The product's own expiry dialog renders the forged
record's field: "Your Burp Suite Professional license expires on 26 October 2036" — the value the stand-in
object returns for ZG(), computed as now + 3650 days.
Medium · Practitioner
Why the strong crypto did not help
The validator is not the weak point, and this was measured rather than assumed.
$ java -cp extracted/jarx:analysis/oracle FuzzOracle 4220
tested=4220 acceptedZZ=0 acceptedZJ=0 ← gate at production value
$ java … FuzzOracle 4220 --gate
tested=4220 acceptedZZ=4220 acceptedZJ=0 ← gate flag forced
Inputs included empty, NUL bytes, 1 MB of A, base64-shaped blocks, Exception:/ExpiredLicense:
prefixes, the recovered 77-digit BigInteger literals, and random strings up to 20,000 characters. With the
gate at its production value: nothing accepted, and no unexpected exception — no NPE, no overflow, no
hang. The validator also begins a hash-blocklist check after cryptographic acceptance, which cannot help
an attacker and confirms the licence-number format.
So the gap is between validation and enforcement. Formally the design guarantees
authentic(info) ⇒ exists(info), and every consumer tests exists(info) — the converse's consequent,
never its premise. Affirming the consequent is normally a logic error with no security consequence; here it
is the security model, because exists(info) is trivially satisfiable and authentic(info) is never
asked again.
And the store is not the problem either. Injecting license1=FAKE… into prefs.xml yields Community
Edition, the injected value is not even cleaned up, and no outbound traffic occurs. The expired_license1
pref was exercised separately with the same result. On-disk values are only inputs to the validator; the
decision that matters lives in memory.
Medium · Practitioner
Installer findings
| ID |
Finding |
Precondition |
Status |
| INS-1 |
Predictable <progname>.<pid>.dir extraction directory, validated with [ -d ] (follows symlinks) |
pre-create the symlink for a known pid |
reproduced |
| INS-2 |
Every -J… argument appended verbatim to the JVM command line |
influence over the argument list |
reproduced |
| INS-3 |
No integrity or authenticity check on the 421 MB payload or the bundled JRE |
substitute the media |
inspection |
| INS-4 |
<installer>.vmoptions / -include-options trusted for classpath and JVM options |
write access to the installer's directory |
inspection |
| INS-5 |
JVM-version cache trusted on mtime match; created chmod g+w |
cache write access |
partly reproduced |
| INS-6 |
umask 0022 set at line 797, i.e. after extraction |
permissive caller umask |
inspection |
| INS-7 |
eval in expand_all_vars behind a character allowlist |
influence over include paths |
inspection — no execution achieved |
| INS-8 |
/tmp cache fallback symlink truncation — inert |
— |
refuted (variable not exported) |
| INS-12 |
Previous-installations database used as JVM authority → execute, adopt, bundle |
a writable directory named in the history file |
reproduced end-to-end |
INS-1 detail. The stub computes <progname>.$$.dir, mkdirs it, then accepts it if [ -d … ] — a test
that follows symlinks — and cds into it. A pre-created link means the 221 MB payload, including 163 MB of
executable application code, is unpacked into the attacker's chosen directory. Cleanup (rm -R -f) follows
the same link.
INS-2 detail. -J-javaagent:/path/x.jar is concatenated into INSTALL4J_ADD_VM_PARAMS, which is
expanded unquoted onto the java command line. Reproduction wrote a marker file from premain inside the
installer JVM. Note the same passthrough exists in the installed launcher: ps output during this
engagement showed both the -javaagent: option and the same token again after the main class, because
"$@" is forwarded too.
Medium · Practitioner
INS-12: the JVM hijack
The mechanism. search_jre tries candidates in order: INSTALL4J_JAVA_HOME_OVERRIDE →
pref_jre.cfg → jre.bundle → the previous-installations database → $PATH → hardcoded
/usr/lib/jvm/* → $JAVA_HOME/$JDK_HOME/$INSTALL4J_JAVA_HOME → inst_jre.cfg. The database lives at
$XDG_DATA_HOME/install4j/apps/<base64 app-id>, one install root per line, and each entry supplies
<root>/jre. The installer writes this file itself, on every run.
How it was found. The file already contained paths under /tmp — left there by this engagement's own
earlier test installs:
$ cat ~/.local/share/install4j/apps/NjU5Mi0xMTU1LTIxNjMtMzk3Mw__
/tmp/bt4/out
/tmp/bt3/out3
…
$ for d in $(cat …); do [ -d "$d" ] && echo "EXISTS $d" || echo "gone $d"; done
gone /tmp/bt4/out ← attacker re-creatable
The chain, reproduced. With a controlled HOME containing exactly one entry naming a directory we
control, and a copy of JRE 26.0.2 with bin/java replaced by a logging wrapper:
$ cat -n logs/revision-2026-10-30/pwned-marker-fakejdk2.txt
1 INVOCATION: -version
2 INVOCATION: -Dexe4j.moduleName=… -Di4j.previousJdk=true … install4j.Installer3813100476 -q -dir …/i9y-out
test_jvm executes <root>/jre/bin/java -version — no prompt.
- The version check passes, so the candidate is adopted; the installer's own command line carries
-Di4j.previousJdk=true.
- The installation itself runs on the attacker's binary.
- install4j copies the selected JVM into the installed product, byte-identical:
text
$ md5sum hostdir/jre/bin/java i9y-out/jre/bin/java
8d961066d169ab4e367e425008bf91dc hostdir/jre/bin/java
8d961066d169ab4e367e425008bf91dc i9y-out/jre/bin/java
inst_jre.cfg in the new installation also records the attacker's directory.
Impact. Under sudo — the documented way to install system-wide — this is local privilege escalation
plus a persistent installation whose runtime the attacker chose. Per-user, it is persistent compromise of
the product rather than a boundary crossing.
Two distinctions the report keeps. Each database entry is an install root, so an entry that is a
JRE is silently skipped with no execution at all (attempt 5 of our reproduction). And a repeat run with a
warm JVM cache skipped the -version probe and went straight to adoption — so "did the installer probe a
foreign java?" is not a reliable detection signal.
Medium · Practitioner
The updater, and revocation over an unsigned channel
The installed launcher metadata enables an unattended updater:
<launcher id="70" checkUpdater="true" updateExecutionMode="unattendedProgress" … />
Good news first. Update downloads are authenticated. net.portswigger.Zrt extends FileOutputStream
and feeds every written byte into a net.portswigger.Zfd signature context, verified with
SHA256withRSA against an RSA public key embedded in the client (modulus and e = 65537 recovered from the
obfuscated constants). That is genuine authentication of the artefact, and it matters because the updater
runs without user interaction.
The problem is what else the channel carries. net.portswigger.Zly has exactly four outcomes —
UP_TO_DATE, LICENSE_INVALID, UPDATE_AVAILABLE, INVALID_CHANNEL — so "revoke this client's
licence and exit" is a first-class value of the release-check response:
if (!var3.equals(var1.ZR)) return false; // response names *your* licence number
if (var1.Zf == Zly.LICENSE_INVALID) {
this.Za.ZQ(var3); // → burp.Zw98.ZQ
return false;
}
Zw98.ZQ deletes the activation blob (Zait.ZK) and the license<M> pref (Zait.ZO), then terminates
the process with Zp5d.LICENSE_WAS_NUKED. None of that is signature-checked — the SHA256withRSA
machinery covers the installer download, not this response. The licence number the comparison needs is
sent in the same request (&number=), so an interposer does not have to guess it.
Impact: a party who can terminate TLS (a TLS-inspecting proxy, common in exactly the environments where
Burp is used) can revoke a licence and close the application. Recovery is re-activation, so this is
availability rather than theft. A mitigating control was verified: the update base URL is set inside the
client only, so it cannot be redirected by editing configuration.
Medium · Practitioner
REST API and the local bridge
The desktop REST API is entitlement-gated. Enabling it in a fresh profile and driving the product into
the main UI produced no listener on the configured port:
config: {"enabled": true, "insecure_mode": false, "keys": [], "listen_mode": "loopback_only", "port": 18337}
--- listeners ---
LISTEN … [::ffff:127.0.0.1]:8080 (proxy)
LISTEN … [::ffff:127.0.0.1]:34423 (internal bridge)
--- probe 18337 --- HTTP 000 (connection failed)
The reason is a control: burp.Zlj9 (the REST API settings panel) checks Zlac.Zf(Zwh1.Zb) before every
action, and burp.Zcj (the service host) receives the same gate — so in Community the service cannot start
at all. That is why this is a static finding with a proven gate, not a live one.
What remains is the combination the UI permits. Defaults observed in a real config are safe
(enabled:false, loopback_only, insecure_mode:false, keys:[]). The residual risk is
listen_mode:"all_interfaces" together with insecure_mode:true — Burp's own UI text calls the latter
"not secure". Authentication, for completeness, compares a hash of the URL-path key against each
enabled stored key and returns no authentication at all if the key set is empty.
The local bridge binds 127.0.0.1 on an ephemeral port (burp.Zrb2 →
new ServerSocket(0, 0, 127.0.0.1)) and guards its routes with a Host: burpai.internal check, a GET
restriction, and a ///.. path filter — no token, no cookie, no origin authorisation. Three magic
routes exist (/__update_license_key, /__try_new_credentials, /__bridge_bootstrap.html) and they
dispatch to internal actions rather than accepting payloads. Every raw HTTP/1.0, HTTP/1.1 and TLS probe was
closed without a byte of response; 16 Host/path/method combinations produced nothing. Whether the required
handshake authenticates the caller is open.
Medium · Practitioner
An unsandboxed compile-and-run pipeline
The product ships a documented facility that compiles and runs Java inside its own process — the agentic
tool surface, whose own strings say it "Compiles and runs a skill-supplied Java/Montoya script inside the
Burp process" and "Runs arbitrary Montoya code". The natural question, since the licence bypass needs
in-process code, is whether that facility could reach the trust anchor without a -javaagent.
The mechanism, verified by driving the product's own classes:
| Property |
Observation |
| Compiler options |
[-proc:none, -Xlint:all] — no --class-path, no --release, no module restriction |
| Compiler entry |
burp.Zjhx.compile(String,String); Zrxc.Zo() = ToolProvider.getSystemJavaCompiler() |
| Loader |
burp.Zu4k extends ClassLoader; loadClass delegates to super first, defineClass only on failure |
| Loader parent |
jdk.internal.loader.ClassLoaders$AppClassLoader — the loader holding burp.* |
| Runtime sandbox |
none observed: no findClass override, no separate parent, no SecurityManager, no module layer |
A snippet with only java.* imports compiled, loaded and ran, and produced an object where
isInstance(burp.Zp1p) == true, reporting burp.Zlaa methods [ZL, Zn, Zw, ZJ, Zb] — including ZL, the
trust-anchor setter. A snippet with a literal import burp.Zlaa; compiled with 0 problems: the
documented import allowlist is not enforced by the compiler.
That is failure class F8 — a sandbox made of a compile-time convention. It cannot constrain
java.lang.reflect, which is explicitly allowed, and the compiler does not apply it in any case.
Medium · Practitioner
What actually contains it, and what that tells us
The container is an entitlement. The agentic module declares its requirement in one bytecode
instruction:
public burp.Zwh1 requiredEntitlement();
0: getstatic #17 // Field burp/Zwh1.ZZ:Lburp/Zwh1;
3: areturn
Zwh1.ZZ is referenced only by burp.agentic.* classes. In Community it is false, so the module is not
installed and the tools do not exist. Using the scripting feature to obtain a licence therefore requires
the licence the feature would provide — the bootstrap is circular, and that is the answer to what was
originally the report's most prominent open question.
The container is also a question of timing. With the anchor published as early as the JVM allows — a
probe that waits for the application to load burp.StartBurp and publishes within 9 ms of the suite object
being set:
+197ms StartBurp loaded by the application
+2182ms StartBurp.Zw set; suite=burp.Zruv
+2189ms ANCHOR PUBLISHED
+4077ms GATE zf(ZZ)=true … agenticClasses=0
+49083ms DONE agentic classes seen: 0
Forty-seven seconds of Zwh1.ZZ = true, from the earliest instant in-process code can act, and no
burp.agentic class loads. The module's installation decision precedes any publication a post-premain
thread can perform.
This is failure class F9 — check content without check lifetime. The same predicate is re-read at 251
call sites, which is why the wizard, the title and the REST-API entitlement flip instantly, and it is read
once at module wiring, which is why the same flip does not install the module. Both defensive and
offensive reasoning go wrong here in opposite directions.
Then the containment claim was tested against the other door, and it changed shape (revision 1.3). The
residual risk recorded earlier was "at least seven classes call Zc63.compile; if any is reachable without
an entitlement, the pipeline is reachable by another door". Auditing them answered both halves at once:
| Question |
Answer |
Method |
Callers of Zc63.compile |
7 — Zish, Zj35, Zpvy, Zq5_, Ztm2, Ztuc, Zud2 |
xrefdb.tsv |
Callers referencing Zwh1 |
6 × 0; Zish × 1 (Zwh1.Zu, carried by the rule type) |
xrefdb.tsv |
| Where the entitlement lives |
on burp.Zr_b (rule type), not on the compiler |
decompiled initialiser |
Rule types with null entitlement |
VIEW_FILTER, CAPTURE_FILTER, AGENTIC_SCRIPT — 3 of 14, and VIEW_FILTER is the default type |
same |
| What the loader does |
Zu4k's only user (Zpvy) calls getConstructor + newInstance, parent AppClassLoader |
bytecode |
| When a script actually runs |
on the first message the filter sees, not on load (ZU stores source, Zq compiles) |
bytecode |
So the door is wider than assumed: proxy view filters and logger capture filters compile and instantiate
user Java in Community Edition with no licence at all. LIC-0's precondition becomes "a user of a free,
documented, shipped feature" rather than "an attacker with an agent".
And the door still does not open onto a remote primitive, for a reason that has nothing to do with
entitlements: all eleven compile-consumers take their source from a stored script —
| Consumer |
Source origin |
Traffic-derived? |
Zpvy |
field set by ZU(source) |
caller-supplied |
Zf7, Z_os, Zirq |
prefs bambda.*_display_filter.bambda |
no |
Zj35, Zq5_, Zl5g/Zud2 |
editor text |
no |
Ztuc, Zqkn |
script object |
no |
Zish, Zrlq |
editor text / config (both entitlement-carrying) |
no |
Not one builds source from HttpRequestResponse bytes. A hostile page can influence the message a filter
evaluates; it cannot influence the code the filter compiles. The chain breaks at source provenance.
This is failure class F10 — capability mistaken for delivery. For a shared privileged service, the
service's own gate says nothing about its reachability: effective trust is the union of its callers' input
trust, and effective containment is the intersection of their input hygiene. Read only the service and you
over-claim ("free in-process code execution"); read only one caller and you under-claim ("a gated editor
helper"). The audit has to enumerate callers and record, per caller, both its entitlement and where its
input comes from — only the second column decides whether it is a remote primitive.
Still open, and honestly so: two deliveries could promote this to a paid finding — an untrusted project
file carrying a script (needs a Pro disk-project instance; Community refuses disk projects at burp.Zlxq)
and indirect prompt injection into the agentic module (needs Zwh1.ZZ, false in Community and not
installable at runtime). Neither was attempted, because a forged-licence surrogate would not describe a
customer configuration. See the report's H.3 #11/#12.
Medium · Practitioner
Failure taxonomy
Ten classes, each instantiated in this product and each generalising beyond it. F10 was added in revision
1.3 by the escalation audit, and it is the only one about how to reason about the product rather than
about what the product got wrong.
| # |
Class |
Instantiation |
| F1 |
Validation/enforcement asymmetry |
crypto validates acquisition; every consumer tests info != null |
| F2 |
Mutable global state as a trust input |
burp.Zrhm.ZK with public Zf/Zo/ZH; referenced by 1,241 classes at 2,225 sites |
| F3 |
Presence-as-entitlement (granularity collapse) |
26 Zwh1 entitlements reduced to one boolean |
| F4 |
Publication before completion |
anchor published at state Zah7, before the activation check |
| F5 |
Unauthenticated internal chokepoint |
one class owns the anchor's write surface; its public Zl(record) trusts the caller |
| F6 |
Unprotected secondary decision channel |
release-check response can revoke a licence; unsigned |
| F7 |
Secret in a shared namespace |
licence key in ~/.java/.userPrefs/burp/prefs.xml at 0664 |
| F8 |
Sandbox as a compile-time convention |
snippet imports; no runtime containment |
| F9 |
Check content without check lifetime |
251 live reads vs one wiring-time read of the same predicate |
| F10 |
Capability mistaken for delivery (shared-service trust inversion) |
the compile service is entitlement-free for 3 of 14 rule types yet no caller feeds it traffic-derived source (§6.13d) |
F2 in context. burp.Zrhm is a ZKM control-flow construct — an opaque predicate used product-wide —
that became a runtime mutable static. Setting it makes the validator's method-scan loop terminate after one
iteration, so Z_ca.ZJ returns null and Z_ca.ZZ throws nothing. The checks are removable; converting
that into a licence still needs a caller that treats absence-of-exception as success, and the activation
path (Zwdw.ZQ) is exactly that caller. The gap is one refactor, not one flag — and the root cause is a
build-time transformation promoted to runtime state without a security review of the promotion.
F7 in context. The licence key is written in clear text by Zait.Zm into a file that is -rw-rw-r--
inside 0755 directories: every local user can read the paid licence secret. The account/SSO token store,
by contrast, is in-memory only, and ~/.BurpSuite is created 0700. The prefs tree is the outlier because
Burp delegates to java.util.prefs.
Medium · Practitioner
Downstream effects
The interesting question is not what breaks inside the licensing subsystem, but what breaks because the
decision it makes is trusted elsewhere.
The licensing gate is also the only gate on a network service. The desktop REST API is gated on
Zwh1.Zb. The same gate a memory injection flips is the gate in front of that listener. So:
in-process code → publish forged Zp1p → Zwh1.Zb entitled → REST API startable
→ if listen_mode == all_interfaces and insecure_mode == true (a combination the UI permits)
→ unauthenticated network access to the request engine, sitemap, proxy history
Neither defect is severe alone; the composition is. A licensing control is load-bearing for an
access-control control, and controls should not be transitive.
The control plane cannot see what it cannot persist. Revocation — the 168,035-entry blocklist and the
remote LICENSE_INVALID path — operates on persisted artefacts. The bypass writes nothing, so:
- the blocklist is structurally inapplicable (a forged record never passes through
Zwdw.ZF);
- remote revocation is inapplicable (
Zw98.ZQ deletes artefacts that do not exist);
- local detection is unavailable (no on-disk difference from a clean Community install).
A control plane needs a persistent representation of the state it governs. If the authoritative state is
memory-only and mutable at runtime, revocation, audit and telemetry all lose their subject.
An amplification that did not happen. The natural escalation would be: one memory write buys arbitrary
code execution through a first-party, model-facing interface. Measurement refused it (§Scripting →
Containment). The result is a matched pair: the enabling direction fails, while the blast-radius
direction still matters, because the pipeline itself has no runtime containment and the containment it does
have is one once-evaluated entitlement deep.
Medium · Practitioner
Instruments
strace was unavailable (not installed, no password-less sudo), so syscall tracing was replaced with
three in-process techniques. For a JVM target these are strictly better at the layer that mattered.
| Technique |
Status |
Role |
strace / ltrace |
unavailable |
— |
/proc + ss + per-PID FD inspection |
works |
sockets, process discovery |
Java agent (-javaagent:BurpProbe.jar) |
works; primary |
Swing-tree dump, GUI driving by label text, reflective eval, implementor listing, static reads, licence commands |
| JDWP + JDI tracer |
works |
method-entry trace with argument values |
JDWP invokeMethod |
does not work |
HotSpot requires an event-suspended thread |
Offline oracles (FuzzOracle, ZaCheck) |
works |
fuzzing and public-API proof without a GUI |
The consequence is the difference between "the file was not written" and "the gate returned true for 26 of
26 entitlements". The agent can evaluate the product's own decision functions.
Tooling built for the engagement: a ZKM string-decryptor harvester (class files parsed directly, with a
backward rescan for interleaved constant pushes) plus a reflective decryptor invoker — 75,789 plaintext
constants recovered across 7,640 classes; a one-pass cross-reference database (2,416,956 references, 21.6 s)
that turned reverse engineering into grep; and a small harness that drives the product's own snippet
compiler and class loader.
Evidence hierarchy. Live process observation > bytecode > decrypted constants > derived index >
narrative. No claim in the report rests on the narrative tier alone.
Medium · Practitioner
Chronology
| When (EDT, 29 Oct) |
What |
| 13:48 |
Session begins; workspace holds one 421 MB file |
| 13:53 |
Stub carved (head -c 23951), 822 lines; payload boundary confirmed three ways |
| 13:55 |
Unattended install with INSTALL4J_KEEP_TEMP=yes |
| 13:56 |
JAR extracted to 53,562 classes; raw string corpus generated |
| 14:07 |
First Java-agent run; licensing wizard driven by label text |
| 14:52 |
eval burp.Zrhm Zo → false; validator throws on AAAA |
| 14:55 |
xref.py bug found: it had reported # 0 references for every query |
| 15:12 |
Fuzz run: 4,220 inputs, none accepted, no unexpected exceptions |
| 15:16 |
INS-1 reproduced (symlink extraction); INS-5 test: no marker |
| 15:21 |
sh -x proves the /tmp fallback is taken but the truncation is a no-op → INS-8 refuted |
| 15:22 |
INS-2 reproduced (-J-javaagent: marker) |
| 15:26 |
Gate 0/26 → 26/26 on a live instance |
| 15:28 |
Auto-injection before UI construction → Professional title, Pro wizard, expiry dialog on forged data |
| 15:47 |
Prefs "isolation" shown to be illusory; real store had been used |
| 15:54 |
Four-line experiment explains java.util.prefs resolution |
| 15:55 |
Stale-temp symlink test → canary survives (Files.walk does not follow links) |
| 16:06 |
REST API tests: no listener; gate identified statically |
| 30 Oct |
Bundle reconciled; snippet pipeline characterised; earliest-injection test; INS-12 hijack reproduced |
| 30 Oct 13:1x |
Zc63.compile callers enumerated (7); Zwh1 references counted per caller (6 × 0) |
| 30 Oct 13:2x |
burp.Zr_b entitlement table read: VIEW_FILTER/CAPTURE_FILTER/AGENTIC_SCRIPT = null |
| 30 Oct 13:2x |
burp.Zu4k users: one (Zpvy); ZU stores source, Zq compiles on first message |
| 30 Oct 13:32 |
Escalation closed negative: 11 consumers, 0 with traffic-derived source |
The full chronology, including the second parallel session and every command's transcripts, is §5 and
Appendix B of the report.
Medium · Practitioner
What the investigation got wrong
Seven failures, recorded because each changed a conclusion.
- A tool that failed closed and silently.
xref.py read two constant-pool words for CONSTANT_Integer
and desynchronised the pool; main() swallowed the parse exception, so every query returned
# 0 references. For part of the engagement the workspace contained "evidence" that the licence-info
setters had no callers. A direct-call positive control caught it and the fix was one line.
- Isolation that looked like isolation.
HOME= does not move java.util.prefs, because HotSpot
derives user.home from getpwuid(). Ten minutes of "isolated" runs wrote to the real profile. The
first fix was also wrong: userRoot is a base directory that has /.java/.userPrefs appended, so the
store silently moved to an empty second directory.
- Two analysts, one host, one namespace. A second automation process was launching Burp on the same
display and writing to the same probe command file. Symptoms: window-title assertions reading the wrong
window, doubled command blocks, two
agent loaded lines in one log.
- Self-terminating teardown.
pkill -f 'burpsuite.jar' matched the invoking shell, which then died
mid-script. Three recurrences during the work, including once in the revision.
- Decompiler failure mistaken for a dead end. CFR and Vineflower both fail on the switch-heavy
validator classes; the correct response was to treat the decompiler's bytecode echo as better evidence,
which is where the
Zwdw.Zl and Z_ca.ZJ listings in this report came from.
- Assertions without a control. Early "is it Professional?" claims relied on window titles read from a
shared display with no control instance. Fixed by restructuring the PoC to run a control phase first, so
every claim became a differential observation.
- Publishing a hypothesis before testing it. The scripting channel was described as the most important
open question before it was tested; testing took under an hour and refuted it. The refutation produced
five verified properties and the F9 lifetime distinction — none of which the hypothesis contained.
Medium · Practitioner
Priorities
Ordered by (severity × reachability). Items marked tested have reproduced evidence.
| # |
Recommendation |
Addresses |
| 1 |
Take the licence decision out of reach of in-process code — privatise Zlaa.ZL/ZJ, Zwdw.Zl, Zrhm.ZK, Zpym.Zx; re-verify entitlement on use against a signed assertion (tested) |
LIC-0, LIC-3, F1, F5 |
| 2 |
Installer: mktemp -d, refuse symlinked extraction dirs, allowlist -J VM options (never -javaagent/-agentpath/-jvm=) (tested) |
INS-1, INS-2 |
| 3 |
Never resolve an executable from a user- or world-writable history file; refuse candidates outside directories the installer owns; do not bundle a selected runtime without a signed manifest (tested) |
INS-12 |
| 4 |
Sign the Linux installer; sign the update feed; stop sending licence material in update requests |
INS-3, INS-10, INS-11 |
| 5 |
Authenticate the release-check response before honouring LICENSE_INVALID; require confirmation before deleting licence material |
AC-5, F6 |
| 6 |
Remove the public validator gate Zrhm; never let a mutable static decide a security outcome |
LIC-1, F2 |
| 7 |
Publish the trust anchor only after activation completes |
F4 |
| 8 |
Give the gate expiry/edition/seat semantics; stop collapsing 26 entitlements to one boolean |
LIC-3, F3 |
| 9 |
Store licence material 0600 outside the shared prefs tree; ignore group/world-writable .vmoptions; drop chmod g+w; set umask before extraction |
LIC-5, INS-4/5/6, F7 |
| 10 |
Give the snippet pipeline runtime containment: a class loader whose parent is limited to an allowlisted package set, tested in CI by attempting Class.forName("burp.Zlaa") |
SNIP-1, F8 |
| 11 |
Audit every consumer of the shared compile service for its own entitlement |
SNIP-1 residual |
| 12 |
Forbid insecure_mode unless the bind mode is loopback; move the API key out of the URL path; add a per-session token to the bridge routes |
AC-1, AC-2, AC-4 |
| 13 |
Verify (or implement) per-installation binding for manual activation |
LIC-4 |
Recommendations 1 and 12 would have prevented the PoC; recommendation 3 is the cheapest High-severity fix,
because it removes an authority rather than adding a check.
Medium · Practitioner
Evidence and reproduction
One-shot demonstration (~40 s, own display, own pref store, own process tag):
./analysis/poc-license-pass.sh # control → bypass → mutability → offline proof
./analysis/poc-license-pass.sh --keep # leaves the Professional window on :79
Offline, no agent, public API only:
cd analysis/oracle && javac -cp ../../extracted/jarx ZaCheck.java
java -cp ../../extracted/jarx:. ZaCheck
# -> Zlaa.Zw()=Za7e(with no activation) -> Zthb.Zf: 26/26 entitlements allowed
Static lookups:
cd extracted/jarx
javap -p -c burp/Zwdw.class # licence service state machine
javap -p -c burp/Zthb.class # the entitlement gate
javap -p -c burp/Zrhm.class # the mutable gate flag
python3 ../../analysis/xref.py burp.Zlac Zf # 251 call sites
grep -P '^burp\.Zait\t' extracted/strings-clean.tsv
Supporting material served with this site: the session transcript
(900 tool calls, rendered and downloadable), screenshots and raw UI logs,
an 8× screen capture, and the PoC sources
(poc-license-pass.sh, BurpProbe.java, ZaCheck.java, DumpBlock.java, PrefTest.java).
Retained evidence: 66 logs and screenshots in logs/, a checksummed snapshot of the revision's
measurements in logs/revision-2026-10-30/, both session transcripts (900 tool calls), and the eight
experiment scripts under paper-work/. Appendix A of the report indexes every file with its size, tier and
role.
Medium · Practitioner
Limits and open questions
Limitations.
- No genuine licence was available, so LIC-4 (activation replay binding) is unresolved, and every statement
about the successful activation path is static. The
Zica trampoline chain was not fully reversed; the
assessment that it is not practically forgeable rests on recovered BigInteger/MD5 material, uniform
failure across 4,220 inputs, and the JDWP trace showing rejection two hops into the chain.
- The enterprise build was not run, so the licence REST routes (AC-3) are untested.
- The loopback bridge was never spoken to: a pre-shared handshake is required and was not obtained.
- The snippet-channel result is mechanism-level: no agentic tool call was made, and the tool layer's own
source validation was not inspected.
- INS-12's mechanism is proved; the reachability of the history file in a real elevated install is
argued, not measured.
- The revision-1.3 escalation audit is static: caller counts, entitlement arguments and source
provenance come from the retained xref database and decompiled classes. No filter was authored and driven
in-product to watch a compile happen, so "no consumer compiles traffic-derived source" is a statement
about all eleven call sites as read, not about a run.
- Screenshots and the screen recording were catalogued by metadata and by the text logs from the same runs,
not visually inspected.
Open questions, in order of expected value:
- Can a project file carry a script that runs on load? Needs a Pro disk-project instance and a
trustworthy answer about load-time trust handling; a forged licence would not be representative.
- Can indirect prompt injection drive an agentic
run_custom_script? The vendor policy would accept
it; the module needs Zwh1.ZZ, which is false in Community and not installable at runtime.
- Can an unprivileged attacker seed another user's installer history file?
- Does the activation response bind to the installation, or only to the licence key?
- Does the bridge handshake authenticate the caller?
- Rotation or dual-key: what is the second update-verification modulus for?
The former top question — "are other consumers of the shared compile service reachable without an
entitlement?" — was answered in revision 1.3 and has moved out of this list into the result: yes, three of
the fourteen rule types are free, and the escalation that would have made it matter is refuted at source
provenance. Items 1 and 2 above are what remains of it.
All remaining items are labelled as untested in Appendix H of the report, next to the claims that could not
be regenerated and the artefacts that are missing.