- Status Closed
-
Assigned To
cbay - Private
Opened by Bajinder - 24.09.2026
Last edited by cbay - 24.09.2026
FS#495 - Cross Customer Data Read via Unvalidated _order Parameter
Name of Vulnerability:
An undocumented query string parameter named `_order` is passed straight into Django's `order_by()` with no validation at all, so an attacker can supply an arbitrary ORM path rather than a value. This both traverses relations into models the caller has no access to and, through a multi-valued reverse relation, emits one row per related row of a shared parent, turning the position of the attacker's own row into a comparison oracle against every other customer's value in the chosen column.
Vulnerability Category:
Access Control Issues, Exposure of Sensitive Member Information, Cross-Customer Disclosure.
CVSS 3.1 score: 8.6 High (`AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N`).
Confidentiality is High because arbitrary column content belonging to other customers is recoverable, demonstrated end to end. Integrity and Availability are None because this is a read-only primitive.
The vulnerable component is one list view's query string handling, while the impacted component is the stored data of the whole customer base.
I am deliberately not scoring this Critical. The primitive yields an ordering, not raw bytes, so recovering a column verbatim requires a column the attacker can also write text into, which is what I demonstrate. This is not true of `password`, which is hashed, nor of `dkim_private_key`, which the API validates as a key pair. I show below that `_order` sorts on those columns, but I do not claim to have recovered them.
Description:
This is not the filter defect reported previously, and the fix for that issue does not touch this one.
The earlier report concerned `key=value` filter parameters reaching `.filter()`. This is a different parameter reaching a different ORM operation. The filter helper could not traverse relations usefully because the value still had to be a real primary key.
`_order` has no value restriction. The path itself is the value, so an arbitrary ORM traversal is simply accepted.
```text
GET /mailbox/?_order=name 200, ordering applied
GET /mailbox/?_order=zzbogus4471 500
GET /mailbox/?_order=domainzzbogus 500, uncaught FieldError
GET /mailbox/?_order=domaindkim_private_key 200
GET /mailbox/?_order=domainaccountcustomerpassword 200, accepted
```
The 500 responses are how I mapped the model graph: a path that resolves returns 200, while one that does not resolve raises an uncaught `FieldError`.
### TWO PROPERTIES COMBINE TO MAKE THIS A CROSS-CUSTOMER READ
First, relation traversal.
A free plan customer's own mailbox sits on `odata.net`, which is Domain primary key 1 and belongs to the customer. From that one owned row, `domain` reaches the customer's own domain columns, and `domainaccountcustomer` reaches the Customer model, which carries `last_login` and is therefore part of the authentication model.
Second, and this is the actual read primitive, ordering by a multi-valued reverse relation does not deduplicate.
`domainmailboxes<column>` emits one output row per mailbox on `alwaysdata.net`, so a single owned row is repeated once per other customer's row. The resulting sequence is ordered by their values in the chosen column.
An attacker with a domain of their own contributes exactly one row to the same result set, and its index is the rank of a string the attacker chose among all of the other values.
Changing that string and rereading the index creates a comparison oracle, and a comparison oracle is a blind read.
### MEASURED RESULTS
All measurements below were performed on 2026-09-24:
```text
GET /mailbox/
200
GET /mailbox/?_order=domainmailboxesname
200
43,700,241 bytes
50,929 rows
GET /mailbox/?_order=domainmailboxesname&page=2
identical result
GET /mailbox/?_order=domainsubdomainsaddressessite
200 24,919,125 bytes
GET /mailbox/?_order=domainsubdomainsaddressessiteaccount
200 48,162,527 bytes 56,148 rows 43.6 seconds
```
A malformed path such as:
```text
GET /mailbox/?_order=domainsubdomainsaddressessitezzbogus
```
returns a 500 response.
### WHAT THE SAME DEFECT ALSO REACHES
I am stating this as reach, not as an additional impact claim.
On `alwaysdata.net`, subdomains are every customer's `<account>.alwaysdata.net`. Therefore, the chain above pivots from subdomains to addresses to site to account to customer.
The control plane can therefore sort 56,148 rows by the `password` column of its authentication model.
I am recording how far an unvalidated path travels, not claiming disclosure of that column. I did not recover a single byte of it, and I cannot do so with this technique because recovery requires a column an attacker can also write a comparand into, while `password` is hashed.
The demonstrated impact of this report is the cross-customer recovery described below.
### WHAT THIS REACHES THAT A CUSTOMER CANNOT ALREADY REACH
My shell is `drwxr-x–x`, so it cannot be listed, and a filesystem-wide grep for my planted secret returns nothing.
A customer's own shell cannot read a single other customer's mailbox row through any route.
The same control on the API confirms the authorization boundary:
Customer A receives HTTP 404 on `/mailbox/635949/` and on `/v1/mailbox/635949/` when requesting Customer B's mailbox, while receiving 200 on its own row in the same minute.
## VULNERABLE INSTANCES
```text
GET https://admin.alwaysdata.com/mailbox/?_order=<arbitrary ORM path>
```
The parameter is honoured identically on:
```text
/site/
/domain/
/ssl/
/job/
/service/
/ip/
```
and on:
```text
https://api.alwaysdata.com/v1/ ```
with the same syntax.
It is not honoured on:
```text
/token/
/support/
/database/
/subscription/
/permission/
```
The issue is reachable by any authenticated customer on a free plan. No paid plan, special role, or additional permission grant is required.
## TWO THINGS I WANT TO SAY BEFORE YOU ASK
### FIRST
The same person registered both customers, and that is not relevant to the authorization boundary.
The two customers below were created by one researcher. What matters is that the control plane models them as separate customers and enforces that separation everywhere else.
Customer A receives 404 on Customer B's mailbox on both the panel and API while receiving 200 on its own row in the same minute.
Customer A's API token against Customer B's account ID returns 404, byte-identical to a nonexistent ID.
The account pickers are disjoint, and Customer B does not appear in Customer A's customer selector.
The boundary this report crosses is the one the platform draws, which is the relevant security boundary for this finding.
### SECOND
The value I recovered was one I deliberately planted.
I wrote a canary into a free-text column of a mailbox belonging to the other customer and then recovered it from a session that has no access to that mailbox.
I did this so that no real third-party data was ever read, inferred, or recorded.
The oracle necessarily ranks my probes against the other 50,928 rows in the population, which is the defect itself, but every value I reconstructed was my own.
## STEPS TO REPRODUCE
Total cost: EUR 0.
Two unrelated customers were used, each with their own permissions and disjoint account pickers.
```text
Customer A = user 489835
Customer B = user 489840
Mailbox B = 635949
```
### 1. Plant the canary
As Customer B, store a secret in a free-text column of B's own mailbox on `alwaysdata.net`.
I used `autoresponder_subject`.
The 8-character secret came from `/dev/urandom` and was never opened. Its SHA-256 was committed to the shared working log at `08:57:29Z`, before any probe, so the recovery below is verifiably blind rather than reconstructed from the probe process.
### 2. Create comparands
As Customer A, create one domain of your own and eight mailboxes.
Each mailbox becomes an independent comparand in the same request because the reverse relation emits each mailbox once per population row.
### 3. Issue the ordering request
As Customer A:
```http
GET /mailbox/?_order=domainmailboxesautoresponder_subject
```
Read the indices at which your own eight rows appear.
Each index tells you how many values sort before that comparand, allowing approximately `log2` of the alphabet to be resolved for a character position.
### 4. Repeat
Two requests per character were enough against a 36-character alphabet.
### 5. Result
All 8 characters were recovered from `09:00:01Z` to `09:12:26Z`, in 16 requests.
The recovered string hashes to the digest committed at `08:57:29Z`.
Customer A never had access to Customer B's mailbox before or after the test, as demonstrated by the authorization controls.
## PROOF OF CONCEPT
### COST PER PROBE
Approximately 37 seconds and 786 KB, not 43 MB.
`autoresponder_subject` sorts the whole population and PostgreSQL sorts ascending with `NULLS LAST`, so the interesting window sits near output index 720 of approximately 50,930.
The response can be read as a stream and abandoned once the relevant window has passed.
This saves my bandwidth, not server-side work. Time to first byte remains approximately 33 to 37 seconds.
### THE FIVE CONTROLS
All controls were run in the same window because a rank on its own is not sufficient evidence.
C0: Baseline, canary never planted
The window is a contiguous empty region of population rows. Any later split is caused by the canary.
C1: Predict the rank of a known value
The predicted rank was `32 | 1 | 32`.
C2: Canary removed
The split collapses back to a single block and the delta returns to 0.
This is the control that isolates the canary and was the control that could not be completed earlier.
C3: Replant a different chosen value
The predicted result was `48 | 1 | 16`, matching the changed ordering position.
C4: A second, separately authenticated session
The same reading was reproduced.
### AUTHORIZATION CONTROL
In the same minute, Customer A receives:
```text
404 on /mailbox/635949/
404 on /v1/mailbox/635949/
200 on its own /mailbox/635597/
```
### THE FAN-OUT ITSELF
This was proved on both front doors without making a 43 MB request.
Bounding the queryset to a single owned mailbox:
```text
/v1/mailbox/?name=c2&_order=domainmailboxesid
```
returns eight copies of the control row compared with:
```text
?_order=id
```
The same behaviour occurs on the panel.
### WHAT I DID NOT DO
I did not read, infer, or record any value belonging to another customer.
Every value I reconstructed was one I planted on an account I own.
The generalisation to the other 50,928 rows is argued from the mechanism, not exercised.
## IMPACT
Any authenticated customer on a free plan can read the contents of another customer, one comparison at a time, without ever touching an object they do not own and without triggering any object-level authorization check.
No object-level check is involved because the rows returned are the attacker's own rows.
What is recoverable verbatim today is any column the attacker can also write, which covers the free-text mailbox columns.
I measured which columns the parameter actually accepts, in one batch with a control:
```text
?_order=domainmailboxesredirect_to
resolves indicates where a customer forwards their mail
?_order=domainmailboxessieve_filter
resolves
?_order=domainmailboxesautoresponder_message
resolves
?_order=domainmailboxesantispam_folder
resolves
?_order=domainmailboxespurge_folders
resolves
?_order=domainmailboxespassword
resolves sortable, but not recoverable verbatim
?_order=domainmailboxeszzbogus4471
500 / 1,033 bytes NEGATIVE CONTROL
```
What is additionally sortable, but which I did not recover and am not claiming as a disclosure, includes the `password` column of the authentication model and `dkim_private_key` of `alwaysdata.net` itself.
Neither is recoverable verbatim using this technique because `password` is hashed and the API validates `dkim_private_key` as a key pair.
I mention them only to show how far the unvalidated path travels when sizing the fix.
I would also point out that the fix for the earlier filter report is separate from this query string handling issue. This parameter is a second instance of the same underlying habit. I mention this to be useful about the class, not to relitigate the earlier ticket.
## RECOMMENDATION
1. Validate `_order` against an explicit allow list of orderable columns, just as would be done for a filter field. Rejecting anything containing `__` would close the traversal on its own.
2. Apply `.distinct()` wherever an ordering may cross a multi-valued relation. The de-duplication is what converts a sort into a read primitive.
3. Catch `FieldError` and return HTTP 400 rather than HTTP 500. The uncaught exception currently acts as an oracle over the internal model graph, including models the panel never renders.
4. Paginate this view. It currently renders 56,148 rows and approximately 48 MB in the demonstrated request.
5. The same parameter is honoured on `api.alwaysdata.com`, so both front doors should be fixed. I checked and the API has no pagination parameter at all.
## DISCLOSURE
I want to explicitly document the testing activity because the application reports alerts when this parameter is exercised.
I caused 36 alerts on this parameter between approximately `06:00Z` and `09:15Z` on 2026-09-24.
Every test object I created has been deleted and verified gone, and both mailboxes I touched were restored field by field with a read-back verification.
Loading...
Available keyboard shortcuts
- Alt + ⇧ Shift + l Login Dialog / Logout
- Alt + ⇧ Shift + a Add new task
- Alt + ⇧ Shift + m My searches
- Alt + ⇧ Shift + t focus taskid search
Tasklist
- o open selected task
- j move cursor down
- k move cursor up
Task Details
- n Next task
- p Previous task
- Alt + ⇧ Shift + e ↵ Enter Edit this task
- Alt + ⇧ Shift + w watch task
- Alt + ⇧ Shift + y Close Task
Task Editing
- Alt + ⇧ Shift + s save task
Hello,
Thanks for the report. Can you confirm that the vulnerability is fixed?
Kind regards,
Cyril
Hello Cyril,
Partially. The panel is fixed. The API is not, and it still carries the whole primitive.
I retested on 2026-09-24 between 12:54Z and 13:03Z from 117.253.56.133. Note the egress IP changed since the report, which said 117.255.15.131.
WHAT IS FIXED, on admin.alwaysdata.com
_order is now validated against an allow list. Legitimate ordering still works and everything else is refused before it reaches the ORM.
That last one is the one that matters. It was the read primitive and
I tried 22 ways around the allow list and all 22 were refused: uppernd trailing space, tab, %00, %0a, a leading minus, dotted paths,
comma and semicolon and space separated pairs, _order[] list syntax, and the reverse accessor with no prefix. I also checked for a validator to ORM desync using
a duplicated parameter, since Django hands the last value to both: ?_mailboxesname gives 404 and the reverse order gives 200. The
validator and order_by() read the same value, so there is no gap between them. The allow list is per view and it holds on the siblings: /site/ /domain/ /ssl/ /job/ /service/ /ip/ /migration/ /log/ /ssh/ /ftp/ /webdav/ all return 404 on a
traversal. Several of them now refuse even ?_order=name, so you tighasked. Negative control: /database/ returns 200 for a traversal,
but it returns 200 for ?_order=zzbogus4471 and for an invented parameter name too, so it is ignoring the parameter exactly as the report recorded and it is not
a bypass. Recommendation 3 is also satisfied on the panel. There is no uncaugher, so the schema oracle over your model graph is gone from that
front door. WHAT IS NOT FIXED, on api.alwaysdata.com api.alwaysdata.com was not touched. This is recommendation 5. Same a minute: _order=zzbogus4471 panel 404 api 500
_order=domaindkim_private_key panel 404 api 200
The 500 body is still "Erreur interne : nous avons ete notifies", sond the schema oracle it gives are both still live there.
The parameter is genuinely reaching order_by() on the API and is noturements, none of which would hold if it were ignored:
1. Ordering changes the output. /v1/site/?_order=id returns ids [10r=-id returns [1078598, 1078596].
2. The path is resolved against your real model graph. Across 9 rese ssh ftp webdav database record token, ?_order=zzbogus4471
3. The missing .distinct() is still missing, which is recommendatioonverts a sort into a read. One owned object is still emitted once
GET /v1/ftp/?_order=id 1 row, 1 distinct id GET /v1/ftp/?_order=account__sites__id 2 rows, 1 distin GET /v1/site/?_order=id 2 rows, 2 distinct ids GET /v1/site/?_order=account__sites__id 4 rows, 2 distin GET /v1/ssh/?_order=id 2 rows, 2 distinct ids GET /v1/ssh/?_order=account__sites__id 4 rows, 2 distinSo both properties the report identified as the two that combine intrbitrary relation traversal and an undeduplicated multi valued fan
out, are still present on the API.
I deliberately did not rebuild the end to end extraction. GET /v1/mailbox/?_order=domainmailboxesid was accepted and was still rendering after 90 seconds,
which is consistent with the 50,929 row fan out, and I aborted it. Ru a full 44 MB render and would not tell either of us anything the
six bounded measurements above do not already show. I would rather not plant another canary on the second account to prove a mechanism that is already
established. If you want the full chain reproduced on the API befored I will run it and send you the timings.
Recommendation 4 is also still open on the API. There is no paginati
ONE POSSIBILITY WORTH RULING OUT FIRST
On this report and on three others in the last round the fix had been pushed but not yet applied in production when I retested, so please check whether the API tier simply has not picked up the deploy before treating this as a shat it is, tell me and I will retest it.
DISCLOSURE FOR THIS RETEST
I caused 15 uncaught 500s, all on api.alwaysdata.com, all on _order, between 12:57Z and 13:03Z, from 117.253.56.133. Your 500 page says you are alerted
immediately, so here is the count.
I also started 2 large fan out requests on /v1/mailbox/ and aborted
I created one API token named r4retest0924, token id 5579, on accouneach the API for this retest. It has been deleted and I verified
the key now returns 401. No object was created, modified or deleted anywhere else, and I did not read, infer or record any value belonging to another customer
during this retest.
Is the API fixed now?
fixed
Thanks, you can now claim your bounty by opening a support ticket.