Security vulnerabilities

  • Status Assigned
  • Assigned To
    cbay
  • Private
Attached to Project: Security vulnerabilities
Opened by Bores - 25.08.2026

FS#461 - Incomplete fix for FS#401: SSRF guard does not normalise IPv4 literals

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

https://admin.alwaysdata.com/site/application/script/<id>/, field script_upstream_uri.

The fetch is triggered by POST https://admin.alwaysdata.com/site/application/script/<id>/update_script/.

The fetching host identified itself as 2a00:b6e0:1:84:1::1 running python-requests/2.32.5.

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.

   poc.sh (4.3 KiB)
Admin
cbay commented on 25.08.2026 15:34

Hello,

Can you confirm that the bug is fixed?

Kind regards,
Cyril

Bores commented on 25.08.2026 16:36

Hello,

Fixed. The four notations are refused sub-second now, and so are seven more that were not in the report, so it is real normalisation rather than a blacklist of the strings I sent. My own public address in those same notations is still parsed and fetched, so the feature is intact. The redirect path is covered too: behind a redirector whose Location header keeps the encoded form the private notations are refused, while a redirect to 3105827083 still follows the hop and stores the response.

It cost some range coverage, though. The seven canonical addresses FS#461 records as refused, against the figures already in the task:

http://127.0.0.1/                        was 0.583544s   now  0.876323s   refused
http://[::1]/                            was 0.634583s   now  0.862191s   refused
http://10.255.255.1:8080/                was 0.586345s   now  0.914873s   refused
http://192.168.0.1:8080/                 was 0.632095s   now 20.521652s   connection attempted
http://172.16.0.1:8080/                  was 0.581063s   now 20.991719s   connection attempted
http://169.254.169.254/                  was 0.593031s   now 20.703552s   connection attempted
http://[fd00:dead:beef:0:0:0:0:1]:8080/  was 0.676233s   now 20.730223s   connection attempted

Same reading as the original report: sub-second is a refusal, the full timeout is a real connection to something that does not answer. Not load, since the split held over three rounds interleaved with 10.255.255.1 and again in a second pass from a new script object, and not those addresses alone: 192.168.1.1 and 172.31.0.1 time out too, as do the encoded forms of the 192.168 and 172.16 rows.

The ULA row needs no timing argument. An HTTP server on my own account's ULA address, fetched through the field:

http://[fd00::7:89a5]:8207/marker.txt   0.701618s   stored: CONFIRM-RUN2-9f31c7

So the backend now reaches into fd00::/8, where my own account's address sits, and hands the body back to the customer who asked for it. Separate from FS#462, which is about the global IPv6 your own hosts use.

I did not look at what is behind any of those ranges, per your SSRF guidance. Listener stopped, test objects deleted.

Would you rather have the regression as its own task?

Kind regards,
Bores

Loading...

Available keyboard shortcuts

Tasklist

Task Details

Task Editing