InvoiceOwl SSRF: IMDSv2, Internal Services and Redis
InvoiceOwl renders PDF receipts. POST /api/render accepts {"template_url": "https://..."} and a headless Chromium on the backend loads that URL, prints it, and returns a PNG to your browser. Validation is a regex on the raw string that rejects anything containing localhost, 127.0.0.1, 169.254.169.254, or the RFC1918 prefixes 10., 192.168. and 172.16-31. The hostname is then resolved and fetched normally. Four error strings are distinguishable: "url blocked" (filter), "fetch failed: timeout after 3000ms" (connection), "fetch failed: unsupported content-type" (response received but rejected), and "render failed" (Chromium crash). You confirm the deployment is AWS EKS; the node role has no instance-profile permissions worth having and IMDSv2 is enforced with hop limit 1. From the JS bundle you know internal DNS names: redis-master.default.svc.cluster.local:6379 with no authentication, and metrics.default.svc.cluster.local:9100 exposing /metrics and /debug/pprof. Your goal is to demonstrate real impact, not just "SSRF confirmed".
1. Does IMDSv2 with hop limit 1 make this finding low risk? What internal targets should you prioritise instead, and why?
Read the worked answer
It removes the easiest path, not the finding. IMDSv2 requires a PUT to /latest/api/token with a X-aws-ec2-metadata-token-ttl-seconds header, and a headless Chromium driving a URL fetch issues GET. Hop limit 1 additionally means packets from a pod, which traverse the container bridge, are dropped before reaching the metadata service in most configurations. Even if you could reach it, you already know the node role has no useful permissions. So the AWS credential path is a dead end by three independent mechanisms - say so in the report and move on rather than burning an hour on IPv6 encodings of 169.254.169.254.
Where the impact actually is: the unauthenticated internal services you already have names for.
metrics.default.svc.cluster.local:9100. This is the highest-value target because it is plain HTTP on a normal port, so Chromium speaks it natively with no protocol tricks./metricstypically leaks cluster topology, pod names, environment labels, request paths and versions - an inventory that makes every other finding easier to exploit./debug/pprofis worse:GET /debug/pprof/heap?debug=1can return heap contents including tokens, andcmdlinereturns the process arguments. Try/debug/pprof/cmdlinefirst - it is small and unambiguous.- The Kubernetes API and kubelet. Probe
https://kubernetes.default.svc:443andhttps://<node-ip>:10250. A kubelet with anonymous authentication enabled returns/podswithout credentials, which discloses environment variables across the whole node, and/run/...on some builds allows exec. This is the finding that justifies a critical rating. redis-master:6379. Reachable, but protocol-limited - see the next task.file://and other schemes. Test whether the renderer acceptsfile:///etc/passwdand, if it does,file:///var/run/secrets/kubernetes.io/serviceaccount/token. A file read of the service account token is often the shortest route to the cluster API and is a stronger impact statement than reading metrics.
How to phrase risk: "unauthenticated SSRF from the internet into the cluster network, with confirmed reachability of internal-only services" is a high finding regardless of IMDS. Anchor the severity on the internal service you actually reached and what it exposed, not on the metadata endpoint you could not.
2. The regex runs on the raw string before DNS resolution. Enumerate the bypass classes this admits, say which you would try first, and how you would confirm success.
Read the worked answer
The filter is a blacklist over the literal text of the URL with no resolution step, so every encoding and indirection class is open:
- Attacker-controlled DNS pointing at the internal IP. The simplest and first thing to try: register or reuse
evil.examplewith an A record of169.254.169.254(or a CNAME to an internal name). The string contains none of the blocked tokens. Because the check never resolves, there is not even a TOCTOU problem to solve. - Alternate numeric representations of the same address.
2130706433(decimal),0x7f000001(hex),0177.0.0.1(octal),127.1(short form), and IPv4-mapped IPv6[::ffff:127.0.0.1]or[::ffff:a9fe:a9fe]. The regex matches none of them; the resolver and Chromium do. - Public wildcard resolvers.
127.0.0.1.nip.io,localtest.me,sslip.io- zero setup, resolves to loopback, no blocked substring. - Redirect chains. Host an allowed URL on your own server that 302s to
http://169.254.169.254/.... Redirect targets are frequently not re-validated. Note that Chromium follows redirects but re-checks the scheme, and 307/308 preserve method - relevant only if you ever need something other than GET. - DNS rebinding. Only needed if validation were done at resolution time; here it is strictly a string check, so rebinding is overkill. Mention it for completeness, not as the plan.
- Credential and port syntax tricks.
http://[email protected]/, and trailing-dot or mixed-case hostnames.
Order of attempts: (1) your own A record, because it is silent, reusable and cannot be patched by adding one more regex entry; then (3) wildcard resolvers as a zero-cost confirmation; then (2) numeric forms.
Confirming: do not use the blocked target first. Point your domain at an HTTP listener you control on the internet and confirm you see the request arrive from the platform's egress IP - that proves the component is fetching attacker-chosen URLs and gives you a timestamped log line for the report. Only then repoint the A record internally and use the error strings as your oracle: timeout after 3000ms means the connection was attempted and filtered; unsupported content-type means you got a real response from a live service; url blocked means you tripped the regex and need a different encoding.
3. Chromium will not speak the Redis protocol. How would you still try to reach Redis, and what do the four error strings tell you about where in the pipeline you get control?
Read the worked answer
Start by reading the pipeline backwards from the error strings. url blocked means your payload was rejected before any network activity - a pure string check. fetch failed: timeout after 3000ms means a connection was attempted, so you control host and port and have a blind port-scan oracle across the cluster network. fetch failed: unsupported content-type is the most informative: the response was received and then inspected, which means a fetch stage that can speak HTTP to an arbitrary port and hands the body to a validator. render failed means Chromium was actually invoked. Those four branches imply there is a fetch/validation stage separate from the browser, and that validator is where non-HTML responses are rejected - very likely a second HTTP client (requests, httpx, Go's net/http) rather than Chromium.
Consequence for Redis. Redis does not URL-decode, so a Chromium GET of http://redis-master:6379/%0d%0aSET%20x%20y%0d%0a produces a literal GET /%0D%0ASET... command line and Redis replies -ERR unknown command. Redis will also close the connection after an inline command it does not know, so multi-request smuggling rarely survives. What you can still establish:
- Reachability and version fingerprint.
http://redis-master:6379/returns an-ERR unknown command 'GET'body; if the error string surfaces to you as an "unsupported content-type" branch you have confirmed an unauthenticated Redis on the internal network, which is a real finding even without execution. - A raw-socket fetcher, if one exists. If the validator stage turns out to be a client that does not normalise the path, or if the renderer accepts
gopher://,dict://orfile://, the CRLF payload becomes live and Redis becomes RCE:CONFIG SET dir /var/www/html,CONFIG SET dbfilename shell.php,SET x '<?php ... ?>',SAVE, or the cron and SSH-key variants. Enumerate scheme support early - a singlefile:///etc/hostnametest tells you whether you have a filesystem read, which is usually worth more than Redis anyway. - Do not overstate. If you can prove reachability but not execution, report exactly that: "unauthenticated Redis reachable from the internet via SSRF; protocol-level exploitation not demonstrated within the engagement window". Claiming Redis RCE you did not achieve is the fastest way to lose credibility on the findings you did prove.