At 2:14 on a Sunday morning, the mobile app told me it could not load a link preview.

My server disagreed.

02:14:07  GET /proof/4f6c2/uid-1001 HTTP/1.1
           Host: canary.example.test
           User-Agent: curl/7.81.0
           Source: 203.0.113.74

The URL I had pasted into the app did not contain canary.example.test.

The web page at the end of the redirect did not contain it either.

Only one thing contained that hostname: a short, deliberately harmless command embedded inside a parameter sent to a service that was supposed to exist only on 127.0.0.1.

The app showed a gray chain icon and Preview unavailable.

The command had already run.

I stared at the access log long enough for the line to stop looking like text and start looking like impact.

Then I closed the terminal, opened a new note, and wrote:

02:15 — probable command execution on preview worker
02:16 — do not inspect filesystem
02:16 — do not read environment
02:17 — reproduce once with new token, then report

The bug began as a URL preview in a mobile chat composer.

It ended as remote command execution in production.

Between those two things were one redirect, one assumption about localhost, and one pair of quotation marks.

The feature I was not testing

$COMPANY was a mobile-first collaboration app. Teams used it for chat, files, voice notes, and the usual collection of tiny conveniences that eventually become infrastructure.

When someone pasted a URL into a message, the app generated a card:

┌──────────────────────────────────────────────┐
│ The boring engineering behind tiny models   │
│ Models are large. Habits are surprisingly   │
│ portable.                                    │
│                                    example   │
└──────────────────────────────────────────────┘

The title, description, site name, and image came from $COMPANY’s servers, not from the phone. That is sensible. A backend can fetch a page once, cache the result, resize images consistently, and avoid making every recipient’s device contact an arbitrary third-party host.

It also means the backend is willing to visit a URL chosen by a user.

That sentence is where SSRF lives.

I had opened the Android application because its link cards looked slightly different from the web version. The web client discarded malformed pages quietly. Android sometimes returned an error description under the gray placeholder.

Different error handling often means different code.

I searched the APK for preview and found a small Retrofit interface:

interface PreviewApi {
    @POST("mobile/v3/link-previews")
    suspend fun createPreview(
        @Body request: PreviewRequest
    ): PreviewCard
}

data class PreviewRequest(
    val url: String,
    val locale: String,
    val appVersion: String
)

The endpoint required an ordinary account. No administrator role, enterprise plan, or special workspace permission was involved. The mobile client called it as soon as a URL appeared in the composer, before the message was sent.

With my own test account, the request looked like this:

POST /api/mobile/v3/link-previews HTTP/1.1
Host: api.$COMPANY.example
Authorization: Bearer <test-account-token>
Content-Type: application/json
X-Client: android/7.42.1

{
  "url": "https://research.example.test/ordinary-page",
  "locale": "en-US",
  "appVersion": "7.42.1"
}

The response was unsurprising:

{
  "title": "ordinary page",
  "description": "a page controlled for testing",
  "site_name": "research.example.test",
  "image_url": null
}

My server received two requests from an address belonging to $COMPANY: first HEAD, then GET.

That established the basic fact. The phone was not fetching the page. $COMPANY was.

The next question was where it was willing to go.

The filter looked better than most

I tried the obvious internal destinations directly.

http://127.0.0.1/
http://localhost/
http://10.0.0.1/
http://169.254.169.254/

All four failed before a network request was made:

{
  "error": "url_not_allowed",
  "message": "Private or reserved destinations cannot be previewed"
}

That was good. Better than good, actually. Plenty of preview endpoints still fail at the first sentence of SSRF prevention.

The service also rejected non-HTTP schemes, URLs containing credentials, bare hostnames without a dot, and several unusual textual forms of loopback addresses. Somebody had clearly thought about this.

For a minute I considered moving on.

Then I tested a public URL that redirected to another public URL. The preview followed it.

That is normal behavior for the web. It is also a second URL-validation problem hiding inside the first one.

The application had answered:

Is the URL the user submitted safe?

It had not yet answered:

Is every URL the server will actually request safe?

I configured my public test endpoint to return one redirect:

HTTP/1.1 302 Found
Location: http://127.0.0.1:3001/
Cache-Control: no-store

Then I submitted the public address—not the loopback address—to the mobile preview API.

{
  "url": "https://research.example.test/to-loopback",
  "locale": "en-US",
  "appVersion": "7.42.1"
}

The request passed the filter.

My server returned the redirect.

The preview response came back three seconds later:

{
  "title": "mobile-renderer",
  "description": "internal screenshot service",
  "site_name": "127.0.0.1",
  "image_url": null
}

I had not bypassed the IP parser.

I had walked around it.

The original host was public. The destination after the redirect was not. The filter examined the first and the HTTP client trusted the rest.

In Python-shaped pseudocode, the bug was probably close to this:

def preview(user_url: str) -> Preview:
    parsed = parse_url(user_url)
    resolved = resolve(parsed.hostname)

    if not is_public_address(resolved):
        raise UrlNotAllowed()

    # requests follows redirects for GET by default
    response = requests.get(
        user_url,
        timeout=5,
        headers={"User-Agent": PREVIEW_USER_AGENT},
    )

    return extract_preview(response)

The validation and the request each looked reasonable in isolation.

The problem was that response might belong to a different host from user_url.

A redirect is not a continuation of the same trust decision. It is a new request.

I did not scan the network

Once an application will fetch loopback URLs, it is tempting to turn the preview endpoint into an internal port scanner.

That is also an efficient way to stop doing research and start creating unnecessary risk.

I tested only a handful of common local development ports, one request at a time, with long pauses. I stopped at the first identifiable service. The report included the exact list and timestamps so $COMPANY could distinguish my traffic from anything else.

Port 3001 returned an HTML page with this title:

<title>mobile-renderer</title>

The body was a tiny internal status screen:

mobile-renderer
build: 2023.08.11-4d8c2f1
mode: production

GET /health
GET /debug/render?url=<url>&width=<pixels>

The interesting word was not render.

It was debug.

The endpoint was bound to loopback, so it had no authentication. From the perspective of its author, that may have felt like authentication: only software on the same machine could reach it.

The preview service was software on the same machine.

And I could tell the preview service where to go.

mobile user
    │
    │  public URL
    ▼
link-preview API
    │
    │  follows 302
    ▼
127.0.0.1:3001
    │
    ▼
mobile-renderer debug route

A network boundary had become a permission boundary without anybody writing down that decision.

The first render was harmless

I sent the renderer a normal public page through the redirect chain:

https://research.example.test/to-render
  └── 302 → http://127.0.0.1:3001/debug/render
              ?url=https%3A%2F%2Fexample.org%2F
              &width=800

The mobile API waited several seconds and returned a screenshot URL hosted on $COMPANY’s CDN.

That proved three things:

  1. The preview worker could reach the renderer.
  2. The renderer accepted unauthenticated requests from loopback.
  3. The renderer performed a meaningful action, not merely a health check.

The generated image showed example.org. I deleted the preview from my test workspace and did not request any internal page.

At this point the finding was already serious SSRF. An ordinary mobile user could invoke internal HTTP endpoints and retrieve at least some of their output through the preview parser or generated artifacts.

But the renderer’s error behavior suggested there was another boundary behind it.

I changed the url parameter from a valid address to one containing a single encoded quotation mark.

The render failed immediately.

The mobile response included the first line of stderr:

{
  "error": "preview_generation_failed",
  "detail": "/bin/sh: 1: Syntax error: Unterminated quoted string"
}

A URL parser does not normally complain about shell quotes.

A shell does.

I wrote a second note:

01:46 — renderer likely builds command string
01:48 — do not guess broadly
01:49 — one canary-only proof

The pair of quotation marks

From the outside, the renderer appeared to launch a headless browser script with a command assembled as text.

The vulnerable code may have looked like this:

import { exec } from "node:child_process";
import { randomUUID } from "node:crypto";

app.get("/debug/render", (req, res) => {
  const target = req.query.url;
  const width = req.query.width || "800";
  const output = `/tmp/${randomUUID()}.png`;

  const command =
    `timeout 20s node capture.js ` +
    `--url "${target}" ` +
    `--width ${width} ` +
    `--output "${output}"`;

  exec(command, (error) => {
    if (error) {
      return res.status(500).send(error.message);
    }

    res.sendFile(output);
  });
});

exec() does not pass an argument array directly to a program. It starts a shell and gives the shell one command string.

That distinction is convenient when the command is constant.

It is dangerous when part of the string came from an HTTP request.

The renderer placed target inside double quotes, but quotes are not a security boundary. They are syntax. If untrusted input can alter that syntax, the shell gets to interpret the remainder.

I am not including the command-separator bytes used in the report. They add nothing to the engineering lesson and make the article more reusable than it needs to be.

The proof itself was deliberately small:

  • Generate a random token.
  • Cause the renderer to make one outbound request to a canary path containing that token.
  • Include the numeric process UID in the path.
  • Do not read a file, list a directory, print environment variables, or start an interactive shell.
  • Reproduce once with a second token.
  • Stop.

The public redirect server never mentioned the canary hostname. The target page never mentioned it. Only the injected fragment did.

At 2:14:07, the canary request arrived:

GET /proof/4f6c2/uid-1001 HTTP/1.1
Host: canary.example.test
User-Agent: curl/7.81.0

The source address matched the preview infrastructure.

The user agent did not match the preview fetcher or headless Chromium.

The path included the expected one-time token and uid-1001.

I repeated the test with a different token and received a second request.

Then I used a benign delay as an independent signal. A normal failed render returned in about 600 milliseconds. The redacted test delayed the response by five seconds, three times in a row.

Two different observations now pointed to the same conclusion:

user-controlled mobile request
  → server-side redirect to loopback
  → unauthenticated internal render endpoint
  → shell command injection
  → command execution as uid 1001

The preview failed.

The command did not.

Why this was RCE even without a reverse shell

“Remote code execution” sometimes gets confused with a cinematic terminal window.

The terminal is optional.

The security boundary fails when an untrusted remote user can cause the target to execute an operating-system command of the user’s choosing. A one-shot canary request is enough to prove that boundary failed.

I did not establish persistence. I did not enumerate the container. I did not inspect mounted volumes, credentials, service-account tokens, customer data, or neighboring services. I did not test whether the process could escape its container.

Those actions would have increased risk without changing the finding.

The report said exactly what I had confirmed:

Confirmed:
- Any ordinary account can invoke the mobile link-preview endpoint.
- URL validation applies to the initial URL only.
- Redirects can reach loopback services.
- A loopback-only renderer interpolates the `url` parameter into a shell command.
- Arbitrary command execution is possible as the renderer user (uid 1001).
- The worker has outbound network access.

Not tested:
- Filesystem contents
- Environment variables or credentials
- Access to customer data
- Access to cloud metadata
- Container escape
- Lateral movement
- Persistence

That separation matters. A report should make the proven impact difficult to dismiss and the untested impact impossible to mistake for a claim.

$COMPANY later confirmed that the renderer ran in a production container with read access to a preview-object bucket and credentials for an internal logging service. It did not hold mobile signing keys.

I had not accessed any of them.

The RCE was severe without borrowing impact from secrets I did not need to touch.

The mobile part was only the handle

No command ran on my phone.

The APK contained the endpoint and supplied the user-controlled URL, but the vulnerable code lived in the backend services supporting the mobile experience.

That distinction is worth making because “mobile vulnerability” often gets interpreted as memory corruption, a malicious intent, or code execution on a handset.

This was different:

Android app
   │  authenticated API request
   ▼
mobile preview backend
   │  SSRF
   ▼
loopback renderer
   │  command injection
   ▼
production container

The phone was the handle.

The backend was the blade.

The web application did not reproduce the issue because it called a newer GraphQL preview service. That service rejected redirects entirely. Android and iOS still used the older /mobile/v3/link-previews route for latency and compatibility reasons.

One product had two implementations of the same convenience feature.

Only one of them had received the security fix.

This is how old APIs become vulnerability museums: they continue to work, which is mistaken for evidence that they continue to be safe.

The report

I sent the report at 02:43 UTC, twenty-nine minutes after the first command-execution proof.

The title was intentionally boring:

Critical: mobile link-preview SSRF to loopback renderer leads to OS command execution

The first page contained only the chain and impact:

Precondition:
  Any authenticated user account.

Entry point:
  POST /api/mobile/v3/link-previews

Root causes:
  1. Initial URL is validated, but redirect destinations are not.
  2. Loopback renderer trusts network location instead of authenticating callers.
  3. Renderer passes a user-controlled URL to child_process.exec().

Impact:
  Remote command execution on a production preview worker as uid 1001.

Safety:
  Tested only with owned accounts, controlled hosts, one canary callback,
  and a benign timing proof. No data or credentials accessed.

Then came the exact reproduction, request IDs, timestamps, the two random canary tokens, source IPs, and a screen recording showing the mobile app’s failed preview beside the canary log.

I included three fixes rather than one because the exploit had three independent trust failures:

  1. Revalidate every redirect hop and block non-public destinations at the network layer.
  2. Remove or authenticate the loopback debug route.
  3. Stop invoking a shell with user-controlled text.

At 03:11, the program acknowledged the report.

At 03:37, an engineer asked one question:

Does command execution require the renderer to return a successful image?

It did not.

The command ran before the render callback returned, so the outer API could show an error while the side effect had already happened.

That explained the title I had not yet written.

At 04:02, $COMPANY disabled preview generation on the mobile endpoint. Pasted links still sent as text, but no card was created.

At 12:26, the report was marked critical.

Three patches, not one

The permanent fix arrived five days later.

1. Every destination became a new decision

The preview fetcher no longer followed redirects automatically. It processed them one at a time, resolving and validating each destination before making the next request.

The invariant was written like this in the remediation notes:

For the initial URL and every redirect hop:

scheme ∈ {http, https}
resolved address is globally routable
connected address equals a validated resolution
destination is not loopback, private, link-local, multicast, or reserved
redirect count ≤ 3
response size and time are bounded

Application validation was backed by an egress rule preventing the preview network from reaching loopback-adjacent services, private ranges, link-local addresses, and cloud metadata endpoints.

That second layer matters. URL parsing is full of representations, redirects, DNS behavior, proxies, and disagreement between libraries. The OWASP SSRF prevention guidance recommends network controls in addition to application checks for exactly this reason.

The old code made one policy decision and then delegated navigation to a general-purpose HTTP client.

The new code kept policy in the loop.

2. Localhost stopped pretending to be authentication

The debug renderer route was removed from production.

The ordinary rendering API moved behind a separate service identity and required a short-lived, audience-bound token. Requests arriving merely from loopback or the cluster network were no longer considered trusted.

The renderer also moved into a more restricted container:

read-only root filesystem
no cloud instance metadata route
no general outbound internet access
minimal object-store permission
no shell package in runtime image
CPU and wall-clock limits per render

A service should be able to survive the caller making a mistake.

“Only internal systems can reach this” is useful context. It is not authorization.

3. The URL became an argument, not a program

The renderer replaced exec() with a direct process invocation and an argument array:

import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";

app.post("/render", requireServiceIdentity, (req, res) => {
  const target = validateRenderUrl(req.body.url);
  const width = validateWidth(req.body.width);
  const output = `/tmp/${randomUUID()}.png`;

  const child = spawn(
    process.execPath,
    [
      "capture.js",
      "--url", target,
      "--width", String(width),
      "--output", output,
    ],
    {
      shell: false,
      timeout: 20_000,
      stdio: ["ignore", "ignore", "pipe"],
    },
  );

  child.on("close", (code) => {
    if (code !== 0) {
      return res.status(502).json({ error: "render_failed" });
    }

    res.sendFile(output);
  });
});

The important part is not spawn as a magic safe word. It is that the URL is passed as one argument to one program, without a shell being asked to reinterpret it.

The route still validates the URL, width, timeout, and output path. Removing the shell closes the command-injection class; input validation and sandboxing limit the damage from whatever the renderer itself might do.

Security patches work better when they do not require one line of code to be perfect.

Retesting the chain

The patched mobile endpoint returned previews again on August 18.

The original public test URL still worked.

A public-to-public redirect worked, up to the new three-hop limit.

A public URL redirecting to loopback failed before the second request:

{
  "error": "url_not_allowed",
  "reason": "redirect_destination_not_public"
}

My loopback service received nothing.

I tried an ordinary hostname that resolved to a public address and then changed. The connection was rejected when the actual destination no longer matched the validated address.

The old internal /debug/render route returned 404 from the renderer image and was unreachable from the preview network anyway.

Finally, $COMPANY supplied a test fixture containing the characters that had previously changed the shell command. The renderer treated the entire value as one invalid URL argument and returned a normal validation error.

No canary request arrived.

No timing shift appeared.

The preview failed.

This time the command failed with it.

The regression tests that describe the real boundary

The most useful tests were not named after my payload. They were named after the properties the system had forgotten.

def test_preview_revalidates_every_redirect_destination():
    public = controlled_server.redirect_to("http://127.0.0.1:3001/")

    result = create_mobile_preview(url=public)

    assert result.status == 400
    assert result.reason == "redirect_destination_not_public"
    assert controlled_loopback_service.requests == []
test("renderer passes URL as one argument without a shell", async () => {
  const suspicious = makeStringContainingShellSyntax();

  await expect(render(suspicious)).rejects.toMatchObject({
    code: "INVALID_RENDER_URL",
  });

  expect(spawn).not.toHaveBeenCalledWith(
    expect.anything(),
    expect.anything(),
    expect.objectContaining({ shell: true }),
  );
});

And the infrastructure test mattered as much as either unit test:

preview-worker → public HTTP/HTTPS: allowed through controlled egress
preview-worker → loopback/private/link-local/metadata: denied
renderer       → arbitrary internet: denied
renderer       → preview object bucket: write-only, scoped prefix

A fixed payload is one regression test.

A fixed trust model is a system of them.

The three assumptions in the exploit

No individual mistake felt large enough to become critical.

The URL validator blocked private addresses.

The renderer listened only on loopback.

The command put the URL inside quotes.

Each sentence sounds like a mitigation.

Together they produced RCE.

“We block internal URLs”

The service blocked the URL the user supplied. It did not block the URL the HTTP client eventually fetched.

Validation attached to a string instead of to the network action.

“The endpoint is only on localhost”

That was true from the network’s point of view.

It was false from the attacker’s point of view because another reachable service could be instructed to make the request.

Reachability composes.

“The value is quoted”

Quoting is part of shell grammar, not an alternative to it.

Once a shell receives a command string containing untrusted text, correctness depends on every transformation preserving syntax exactly. The safer design is not to ask a shell to parse the value at all.

The exploit did not defeat three strong defenses.

It passed through three defenses that protected different objects from the ones developers thought they protected.

The report closed

$COMPANY closed the report on August 24 after the mobile clients had resumed preview generation and the infrastructure changes had reached production.

The reward was $BOUNTY. The amount matters less than the severity classification, and the severity matters less than the patch surviving the feature being turned back on.

The engineer who handled the report sent one final note:

We originally treated the redirect check, renderer authentication, and shell invocation as separate issues. The incident review now models them as one request path.

That is the right lesson.

Vulnerabilities are often reviewed one function at a time because code is stored one function at a time. Attackers get the whole request path.

In February, a PDF button reminded me that reachability is not authorization.

In June, SAML reminded me that authentication is not tenancy.

This one added a smaller rule:

A redirect is a new request, not a footnote to the old one.

The first URL was public.

The second URL was localhost.

The third value became shell syntax.

Every component did something it had been designed to do:

  • The mobile app asked for a preview.
  • The previewer followed a redirect.
  • The renderer accepted a local request.
  • The shell interpreted a command.

The vulnerability lived in the word therefore between those sentences.

No command ran on the phone.

No firewall port opened to the internet.

No cryptography failed.

I pasted a link into a mobile app.

The app said the preview failed.

The server ran the rest.