{
    "componentChunkName": "component---src-templates-blog-post-js",
    "path": "/the-preview-failed-the-command-did-not/",
    "result": {"data":{"markdownRemark":{"id":"cb7e2681-4a42-5f02-b818-88f8bf434cce","excerpt":"At 2:14 on a Sunday morning, the mobile app told me it could not load a link preview. My server disagreed. The URL I had pasted into the app did not contain…","html":"<p>At 2:14 on a Sunday morning, the mobile app told me it could not load a link preview.</p>\n<p>My server disagreed.</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">02:14:07  GET /proof/4f6c2/uid-1001 HTTP/1.1\n           Host: canary.example.test\n           User-Agent: curl/7.81.0\n           Source: 203.0.113.74</code></pre></div>\n<p>The URL I had pasted into the app did not contain <code class=\"language-text\">canary.example.test</code>.</p>\n<p>The web page at the end of the redirect did not contain it either.</p>\n<p>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 <code class=\"language-text\">127.0.0.1</code>.</p>\n<p>The app showed a gray chain icon and <strong>Preview unavailable</strong>.</p>\n<p>The command had already run.</p>\n<p>I stared at the access log long enough for the line to stop looking like text and start looking like impact.</p>\n<p>Then I closed the terminal, opened a new note, and wrote:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">02:15 — probable command execution on preview worker\n02:16 — do not inspect filesystem\n02:16 — do not read environment\n02:17 — reproduce once with new token, then report</code></pre></div>\n<p>The bug began as a URL preview in a mobile chat composer.</p>\n<p>It ended as remote command execution in production.</p>\n<p>Between those two things were one redirect, one assumption about localhost, and one pair of quotation marks.</p>\n<h2 id=\"the-feature-i-was-not-testing\" style=\"position:relative;\"><a href=\"#the-feature-i-was-not-testing\" aria-label=\"the feature i was not testing permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The feature I was not testing</h2>\n<p><code class=\"language-text\">$COMPANY</code> 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.</p>\n<p>When someone pasted a URL into a message, the app generated a card:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">┌──────────────────────────────────────────────┐\n│ The boring engineering behind tiny models   │\n│ Models are large. Habits are surprisingly   │\n│ portable.                                    │\n│                                    example   │\n└──────────────────────────────────────────────┘</code></pre></div>\n<p>The title, description, site name, and image came from <code class=\"language-text\">$COMPANY</code>’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.</p>\n<p>It also means the backend is willing to visit a URL chosen by a user.</p>\n<p>That sentence is where <a href=\"https://owasp.org/Top10/2021/A10_2021-Server-Side_Request_Forgery_%28SSRF%29/\">SSRF</a> lives.</p>\n<p>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.</p>\n<p>Different error handling often means different code.</p>\n<p>I searched the APK for <code class=\"language-text\">preview</code> and found a small Retrofit interface:</p>\n<div class=\"gatsby-highlight\" data-language=\"kotlin\"><pre class=\"language-kotlin\"><code class=\"language-kotlin\"><span class=\"token keyword\">interface</span> PreviewApi <span class=\"token punctuation\">{</span>\n    <span class=\"token annotation builtin\">@POST</span><span class=\"token punctuation\">(</span><span class=\"token string-literal singleline\"><span class=\"token string\">\"mobile/v3/link-previews\"</span></span><span class=\"token punctuation\">)</span>\n    <span class=\"token keyword\">suspend</span> <span class=\"token keyword\">fun</span> <span class=\"token function\">createPreview</span><span class=\"token punctuation\">(</span>\n        <span class=\"token annotation builtin\">@Body</span> request<span class=\"token operator\">:</span> PreviewRequest\n    <span class=\"token punctuation\">)</span><span class=\"token operator\">:</span> PreviewCard\n<span class=\"token punctuation\">}</span>\n\n<span class=\"token keyword\">data</span> <span class=\"token keyword\">class</span> <span class=\"token function\">PreviewRequest</span><span class=\"token punctuation\">(</span>\n    <span class=\"token keyword\">val</span> url<span class=\"token operator\">:</span> String<span class=\"token punctuation\">,</span>\n    <span class=\"token keyword\">val</span> locale<span class=\"token operator\">:</span> String<span class=\"token punctuation\">,</span>\n    <span class=\"token keyword\">val</span> appVersion<span class=\"token operator\">:</span> String\n<span class=\"token punctuation\">)</span></code></pre></div>\n<p>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.</p>\n<p>With my own test account, the request looked like this:</p>\n<div class=\"gatsby-highlight\" data-language=\"http\"><pre class=\"language-http\"><code class=\"language-http\"><span class=\"token request-line\"><span class=\"token method property\">POST</span> <span class=\"token request-target url\">/api/mobile/v3/link-previews</span> <span class=\"token http-version property\">HTTP/1.1</span></span>\n<span class=\"token header\"><span class=\"token header-name keyword\">Host</span><span class=\"token punctuation\">:</span> <span class=\"token header-value\">api.$COMPANY.example</span></span>\n<span class=\"token header\"><span class=\"token header-name keyword\">Authorization</span><span class=\"token punctuation\">:</span> <span class=\"token header-value\">Bearer &lt;test-account-token></span></span>\n<span class=\"token header\"><span class=\"token header-name keyword\">Content-Type</span><span class=\"token punctuation\">:</span> <span class=\"token header-value\">application/json</span></span>\n<span class=\"token header\"><span class=\"token header-name keyword\">X-Client</span><span class=\"token punctuation\">:</span> <span class=\"token header-value\">android/7.42.1</span></span>\n<span class=\"token application-json\">\n<span class=\"token punctuation\">{</span>\n  <span class=\"token string-property property\">\"url\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"https://research.example.test/ordinary-page\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token string-property property\">\"locale\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"en-US\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token string-property property\">\"appVersion\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"7.42.1\"</span>\n<span class=\"token punctuation\">}</span></span></code></pre></div>\n<p>The response was unsurprising:</p>\n<div class=\"gatsby-highlight\" data-language=\"json\"><pre class=\"language-json\"><code class=\"language-json\"><span class=\"token punctuation\">{</span>\n  <span class=\"token property\">\"title\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"ordinary page\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"description\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"a page controlled for testing\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"site_name\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"research.example.test\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"image_url\"</span><span class=\"token operator\">:</span> <span class=\"token null keyword\">null</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>My server received two requests from an address belonging to <code class=\"language-text\">$COMPANY</code>: first <code class=\"language-text\">HEAD</code>, then <code class=\"language-text\">GET</code>.</p>\n<p>That established the basic fact. The phone was not fetching the page. <code class=\"language-text\">$COMPANY</code> was.</p>\n<p>The next question was where it was willing to go.</p>\n<h2 id=\"the-filter-looked-better-than-most\" style=\"position:relative;\"><a href=\"#the-filter-looked-better-than-most\" aria-label=\"the filter looked better than most permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The filter looked better than most</h2>\n<p>I tried the obvious internal destinations directly.</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">http://127.0.0.1/\nhttp://localhost/\nhttp://10.0.0.1/\nhttp://169.254.169.254/</code></pre></div>\n<p>All four failed before a network request was made:</p>\n<div class=\"gatsby-highlight\" data-language=\"json\"><pre class=\"language-json\"><code class=\"language-json\"><span class=\"token punctuation\">{</span>\n  <span class=\"token property\">\"error\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"url_not_allowed\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"message\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"Private or reserved destinations cannot be previewed\"</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>That was good. Better than good, actually. Plenty of preview endpoints still fail at the first sentence of SSRF prevention.</p>\n<p>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.</p>\n<p>For a minute I considered moving on.</p>\n<p>Then I tested a public URL that redirected to another public URL. The preview followed it.</p>\n<p>That is normal behavior for the web. It is also a second URL-validation problem hiding inside the first one.</p>\n<p>The application had answered:</p>\n<blockquote>\n<p>Is the URL the user submitted safe?</p>\n</blockquote>\n<p>It had not yet answered:</p>\n<blockquote>\n<p>Is every URL the server will actually request safe?</p>\n</blockquote>\n<p>I configured my public test endpoint to return one redirect:</p>\n<div class=\"gatsby-highlight\" data-language=\"http\"><pre class=\"language-http\"><code class=\"language-http\"><span class=\"token response-status\"><span class=\"token http-version property\">HTTP/1.1</span> <span class=\"token status-code number\">302</span> <span class=\"token reason-phrase string\">Found</span></span>\n<span class=\"token header\"><span class=\"token header-name keyword\">Location</span><span class=\"token punctuation\">:</span> <span class=\"token header-value\">http://127.0.0.1:3001/</span></span>\n<span class=\"token header\"><span class=\"token header-name keyword\">Cache-Control</span><span class=\"token punctuation\">:</span> <span class=\"token header-value\">no-store</span></span></code></pre></div>\n<p>Then I submitted the public address—not the loopback address—to the mobile preview API.</p>\n<div class=\"gatsby-highlight\" data-language=\"json\"><pre class=\"language-json\"><code class=\"language-json\"><span class=\"token punctuation\">{</span>\n  <span class=\"token property\">\"url\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"https://research.example.test/to-loopback\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"locale\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"en-US\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"appVersion\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"7.42.1\"</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>The request passed the filter.</p>\n<p>My server returned the redirect.</p>\n<p>The preview response came back three seconds later:</p>\n<div class=\"gatsby-highlight\" data-language=\"json\"><pre class=\"language-json\"><code class=\"language-json\"><span class=\"token punctuation\">{</span>\n  <span class=\"token property\">\"title\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"mobile-renderer\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"description\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"internal screenshot service\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"site_name\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"127.0.0.1\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"image_url\"</span><span class=\"token operator\">:</span> <span class=\"token null keyword\">null</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>I had not bypassed the IP parser.</p>\n<p>I had walked around it.</p>\n<p>The original host was public. The destination after the redirect was not. The filter examined the first and the HTTP client trusted the rest.</p>\n<p>In Python-shaped pseudocode, the bug was probably close to this:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\"><span class=\"token keyword\">def</span> <span class=\"token function\">preview</span><span class=\"token punctuation\">(</span>user_url<span class=\"token punctuation\">:</span> <span class=\"token builtin\">str</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">-</span><span class=\"token operator\">></span> Preview<span class=\"token punctuation\">:</span>\n    parsed <span class=\"token operator\">=</span> parse_url<span class=\"token punctuation\">(</span>user_url<span class=\"token punctuation\">)</span>\n    resolved <span class=\"token operator\">=</span> resolve<span class=\"token punctuation\">(</span>parsed<span class=\"token punctuation\">.</span>hostname<span class=\"token punctuation\">)</span>\n\n    <span class=\"token keyword\">if</span> <span class=\"token keyword\">not</span> is_public_address<span class=\"token punctuation\">(</span>resolved<span class=\"token punctuation\">)</span><span class=\"token punctuation\">:</span>\n        <span class=\"token keyword\">raise</span> UrlNotAllowed<span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span>\n\n    <span class=\"token comment\"># requests follows redirects for GET by default</span>\n    response <span class=\"token operator\">=</span> requests<span class=\"token punctuation\">.</span>get<span class=\"token punctuation\">(</span>\n        user_url<span class=\"token punctuation\">,</span>\n        timeout<span class=\"token operator\">=</span><span class=\"token number\">5</span><span class=\"token punctuation\">,</span>\n        headers<span class=\"token operator\">=</span><span class=\"token punctuation\">{</span><span class=\"token string\">\"User-Agent\"</span><span class=\"token punctuation\">:</span> PREVIEW_USER_AGENT<span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span>\n    <span class=\"token punctuation\">)</span>\n\n    <span class=\"token keyword\">return</span> extract_preview<span class=\"token punctuation\">(</span>response<span class=\"token punctuation\">)</span></code></pre></div>\n<p>The validation and the request each looked reasonable in isolation.</p>\n<p>The problem was that <code class=\"language-text\">response</code> might belong to a different host from <code class=\"language-text\">user_url</code>.</p>\n<p>A redirect is not a continuation of the same trust decision. It is a new request.</p>\n<h2 id=\"i-did-not-scan-the-network\" style=\"position:relative;\"><a href=\"#i-did-not-scan-the-network\" aria-label=\"i did not scan the network permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>I did not scan the network</h2>\n<p>Once an application will fetch loopback URLs, it is tempting to turn the preview endpoint into an internal port scanner.</p>\n<p>That is also an efficient way to stop doing research and start creating unnecessary risk.</p>\n<p>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 <code class=\"language-text\">$COMPANY</code> could distinguish my traffic from anything else.</p>\n<p>Port <code class=\"language-text\">3001</code> returned an HTML page with this title:</p>\n<div class=\"gatsby-highlight\" data-language=\"html\"><pre class=\"language-html\"><code class=\"language-html\"><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>title</span><span class=\"token punctuation\">></span></span>mobile-renderer<span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span>title</span><span class=\"token punctuation\">></span></span></code></pre></div>\n<p>The body was a tiny internal status screen:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">mobile-renderer\nbuild: 2023.08.11-4d8c2f1\nmode: production\n\nGET /health\nGET /debug/render?url=&lt;url>&amp;width=&lt;pixels></code></pre></div>\n<p>The interesting word was not <code class=\"language-text\">render</code>.</p>\n<p>It was <code class=\"language-text\">debug</code>.</p>\n<p>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.</p>\n<p>The preview service was software on the same machine.</p>\n<p>And I could tell the preview service where to go.</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">mobile user\n    │\n    │  public URL\n    ▼\nlink-preview API\n    │\n    │  follows 302\n    ▼\n127.0.0.1:3001\n    │\n    ▼\nmobile-renderer debug route</code></pre></div>\n<p>A network boundary had become a permission boundary without anybody writing down that decision.</p>\n<h2 id=\"the-first-render-was-harmless\" style=\"position:relative;\"><a href=\"#the-first-render-was-harmless\" aria-label=\"the first render was harmless permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The first render was harmless</h2>\n<p>I sent the renderer a normal public page through the redirect chain:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">https://research.example.test/to-render\n  └── 302 → http://127.0.0.1:3001/debug/render\n              ?url=https%3A%2F%2Fexample.org%2F\n              &amp;width=800</code></pre></div>\n<p>The mobile API waited several seconds and returned a screenshot URL hosted on <code class=\"language-text\">$COMPANY</code>’s CDN.</p>\n<p>That proved three things:</p>\n<ol>\n<li>The preview worker could reach the renderer.</li>\n<li>The renderer accepted unauthenticated requests from loopback.</li>\n<li>The renderer performed a meaningful action, not merely a health check.</li>\n</ol>\n<p>The generated image showed <code class=\"language-text\">example.org</code>. I deleted the preview from my test workspace and did not request any internal page.</p>\n<p>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.</p>\n<p>But the renderer’s error behavior suggested there was another boundary behind it.</p>\n<p>I changed the <code class=\"language-text\">url</code> parameter from a valid address to one containing a single encoded quotation mark.</p>\n<p>The render failed immediately.</p>\n<p>The mobile response included the first line of stderr:</p>\n<div class=\"gatsby-highlight\" data-language=\"json\"><pre class=\"language-json\"><code class=\"language-json\"><span class=\"token punctuation\">{</span>\n  <span class=\"token property\">\"error\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"preview_generation_failed\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"detail\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"/bin/sh: 1: Syntax error: Unterminated quoted string\"</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>A URL parser does not normally complain about shell quotes.</p>\n<p>A shell does.</p>\n<p>I wrote a second note:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">01:46 — renderer likely builds command string\n01:48 — do not guess broadly\n01:49 — one canary-only proof</code></pre></div>\n<h2 id=\"the-pair-of-quotation-marks\" style=\"position:relative;\"><a href=\"#the-pair-of-quotation-marks\" aria-label=\"the pair of quotation marks permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The pair of quotation marks</h2>\n<p>From the outside, the renderer appeared to launch a headless browser script with a command assembled as text.</p>\n<p>The vulnerable code may have looked like this:</p>\n<div class=\"gatsby-highlight\" data-language=\"javascript\"><pre class=\"language-javascript\"><code class=\"language-javascript\"><span class=\"token keyword\">import</span> <span class=\"token punctuation\">{</span> exec <span class=\"token punctuation\">}</span> <span class=\"token keyword\">from</span> <span class=\"token string\">\"node:child_process\"</span><span class=\"token punctuation\">;</span>\n<span class=\"token keyword\">import</span> <span class=\"token punctuation\">{</span> randomUUID <span class=\"token punctuation\">}</span> <span class=\"token keyword\">from</span> <span class=\"token string\">\"node:crypto\"</span><span class=\"token punctuation\">;</span>\n\napp<span class=\"token punctuation\">.</span><span class=\"token function\">get</span><span class=\"token punctuation\">(</span><span class=\"token string\">\"/debug/render\"</span><span class=\"token punctuation\">,</span> <span class=\"token punctuation\">(</span><span class=\"token parameter\">req<span class=\"token punctuation\">,</span> res</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">=></span> <span class=\"token punctuation\">{</span>\n  <span class=\"token keyword\">const</span> target <span class=\"token operator\">=</span> req<span class=\"token punctuation\">.</span>query<span class=\"token punctuation\">.</span>url<span class=\"token punctuation\">;</span>\n  <span class=\"token keyword\">const</span> width <span class=\"token operator\">=</span> req<span class=\"token punctuation\">.</span>query<span class=\"token punctuation\">.</span>width <span class=\"token operator\">||</span> <span class=\"token string\">\"800\"</span><span class=\"token punctuation\">;</span>\n  <span class=\"token keyword\">const</span> output <span class=\"token operator\">=</span> <span class=\"token template-string\"><span class=\"token template-punctuation string\">`</span><span class=\"token string\">/tmp/</span><span class=\"token interpolation\"><span class=\"token interpolation-punctuation punctuation\">${</span><span class=\"token function\">randomUUID</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token interpolation-punctuation punctuation\">}</span></span><span class=\"token string\">.png</span><span class=\"token template-punctuation string\">`</span></span><span class=\"token punctuation\">;</span>\n\n  <span class=\"token keyword\">const</span> command <span class=\"token operator\">=</span>\n    <span class=\"token template-string\"><span class=\"token template-punctuation string\">`</span><span class=\"token string\">timeout 20s node capture.js </span><span class=\"token template-punctuation string\">`</span></span> <span class=\"token operator\">+</span>\n    <span class=\"token template-string\"><span class=\"token template-punctuation string\">`</span><span class=\"token string\">--url \"</span><span class=\"token interpolation\"><span class=\"token interpolation-punctuation punctuation\">${</span>target<span class=\"token interpolation-punctuation punctuation\">}</span></span><span class=\"token string\">\" </span><span class=\"token template-punctuation string\">`</span></span> <span class=\"token operator\">+</span>\n    <span class=\"token template-string\"><span class=\"token template-punctuation string\">`</span><span class=\"token string\">--width </span><span class=\"token interpolation\"><span class=\"token interpolation-punctuation punctuation\">${</span>width<span class=\"token interpolation-punctuation punctuation\">}</span></span><span class=\"token string\"> </span><span class=\"token template-punctuation string\">`</span></span> <span class=\"token operator\">+</span>\n    <span class=\"token template-string\"><span class=\"token template-punctuation string\">`</span><span class=\"token string\">--output \"</span><span class=\"token interpolation\"><span class=\"token interpolation-punctuation punctuation\">${</span>output<span class=\"token interpolation-punctuation punctuation\">}</span></span><span class=\"token string\">\"</span><span class=\"token template-punctuation string\">`</span></span><span class=\"token punctuation\">;</span>\n\n  <span class=\"token function\">exec</span><span class=\"token punctuation\">(</span>command<span class=\"token punctuation\">,</span> <span class=\"token punctuation\">(</span><span class=\"token parameter\">error</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">=></span> <span class=\"token punctuation\">{</span>\n    <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>error<span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\n      <span class=\"token keyword\">return</span> res<span class=\"token punctuation\">.</span><span class=\"token function\">status</span><span class=\"token punctuation\">(</span><span class=\"token number\">500</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">.</span><span class=\"token function\">send</span><span class=\"token punctuation\">(</span>error<span class=\"token punctuation\">.</span>message<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n    <span class=\"token punctuation\">}</span>\n\n    res<span class=\"token punctuation\">.</span><span class=\"token function\">sendFile</span><span class=\"token punctuation\">(</span>output<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n  <span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n<span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span></code></pre></div>\n<p><a href=\"https://nodejs.org/api/child_process.html\"><code class=\"language-text\">exec()</code></a> does not pass an argument array directly to a program. It starts a shell and gives the shell one command string.</p>\n<p>That distinction is convenient when the command is constant.</p>\n<p>It is dangerous when part of the string came from an HTTP request.</p>\n<p>The renderer placed <code class=\"language-text\">target</code> 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.</p>\n<p>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.</p>\n<p>The proof itself was deliberately small:</p>\n<ul>\n<li>Generate a random token.</li>\n<li>Cause the renderer to make one outbound request to a canary path containing that token.</li>\n<li>Include the numeric process UID in the path.</li>\n<li>Do not read a file, list a directory, print environment variables, or start an interactive shell.</li>\n<li>Reproduce once with a second token.</li>\n<li>Stop.</li>\n</ul>\n<p>The public redirect server never mentioned the canary hostname. The target page never mentioned it. Only the injected fragment did.</p>\n<p>At 2:14:07, the canary request arrived:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">GET /proof/4f6c2/uid-1001 HTTP/1.1\nHost: canary.example.test\nUser-Agent: curl/7.81.0</code></pre></div>\n<p>The source address matched the preview infrastructure.</p>\n<p>The user agent did not match the preview fetcher or headless Chromium.</p>\n<p>The path included the expected one-time token and <code class=\"language-text\">uid-1001</code>.</p>\n<p>I repeated the test with a different token and received a second request.</p>\n<p>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.</p>\n<p>Two different observations now pointed to the same conclusion:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">user-controlled mobile request\n  → server-side redirect to loopback\n  → unauthenticated internal render endpoint\n  → shell command injection\n  → command execution as uid 1001</code></pre></div>\n<p>The preview failed.</p>\n<p>The command did not.</p>\n<h2 id=\"why-this-was-rce-even-without-a-reverse-shell\" style=\"position:relative;\"><a href=\"#why-this-was-rce-even-without-a-reverse-shell\" aria-label=\"why this was rce even without a reverse shell permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Why this was RCE even without a reverse shell</h2>\n<p>“Remote code execution” sometimes gets confused with a cinematic terminal window.</p>\n<p>The terminal is optional.</p>\n<p>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.</p>\n<p>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.</p>\n<p>Those actions would have increased risk without changing the finding.</p>\n<p>The report said exactly what I had confirmed:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">Confirmed:\n- Any ordinary account can invoke the mobile link-preview endpoint.\n- URL validation applies to the initial URL only.\n- Redirects can reach loopback services.\n- A loopback-only renderer interpolates the `url` parameter into a shell command.\n- Arbitrary command execution is possible as the renderer user (uid 1001).\n- The worker has outbound network access.\n\nNot tested:\n- Filesystem contents\n- Environment variables or credentials\n- Access to customer data\n- Access to cloud metadata\n- Container escape\n- Lateral movement\n- Persistence</code></pre></div>\n<p>That separation matters. A report should make the proven impact difficult to dismiss and the untested impact impossible to mistake for a claim.</p>\n<p><code class=\"language-text\">$COMPANY</code> 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.</p>\n<p>I had not accessed any of them.</p>\n<p>The RCE was severe without borrowing impact from secrets I did not need to touch.</p>\n<h2 id=\"the-mobile-part-was-only-the-handle\" style=\"position:relative;\"><a href=\"#the-mobile-part-was-only-the-handle\" aria-label=\"the mobile part was only the handle permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The mobile part was only the handle</h2>\n<p>No command ran on my phone.</p>\n<p>The APK contained the endpoint and supplied the user-controlled URL, but the vulnerable code lived in the backend services supporting the mobile experience.</p>\n<p>That distinction is worth making because “mobile vulnerability” often gets interpreted as memory corruption, a malicious intent, or code execution on a handset.</p>\n<p>This was different:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">Android app\n   │  authenticated API request\n   ▼\nmobile preview backend\n   │  SSRF\n   ▼\nloopback renderer\n   │  command injection\n   ▼\nproduction container</code></pre></div>\n<p>The phone was the handle.</p>\n<p>The backend was the blade.</p>\n<p>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 <code class=\"language-text\">/mobile/v3/link-previews</code> route for latency and compatibility reasons.</p>\n<p>One product had two implementations of the same convenience feature.</p>\n<p>Only one of them had received the security fix.</p>\n<p>This is how old APIs become vulnerability museums: they continue to work, which is mistaken for evidence that they continue to be safe.</p>\n<h2 id=\"the-report\" style=\"position:relative;\"><a href=\"#the-report\" aria-label=\"the report permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The report</h2>\n<p>I sent the report at 02:43 UTC, twenty-nine minutes after the first command-execution proof.</p>\n<p>The title was intentionally boring:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">Critical: mobile link-preview SSRF to loopback renderer leads to OS command execution</code></pre></div>\n<p>The first page contained only the chain and impact:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">Precondition:\n  Any authenticated user account.\n\nEntry point:\n  POST /api/mobile/v3/link-previews\n\nRoot causes:\n  1. Initial URL is validated, but redirect destinations are not.\n  2. Loopback renderer trusts network location instead of authenticating callers.\n  3. Renderer passes a user-controlled URL to child_process.exec().\n\nImpact:\n  Remote command execution on a production preview worker as uid 1001.\n\nSafety:\n  Tested only with owned accounts, controlled hosts, one canary callback,\n  and a benign timing proof. No data or credentials accessed.</code></pre></div>\n<p>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.</p>\n<p>I included three fixes rather than one because the exploit had three independent trust failures:</p>\n<ol>\n<li>Revalidate every redirect hop and block non-public destinations at the network layer.</li>\n<li>Remove or authenticate the loopback debug route.</li>\n<li>Stop invoking a shell with user-controlled text.</li>\n</ol>\n<p>At 03:11, the program acknowledged the report.</p>\n<p>At 03:37, an engineer asked one question:</p>\n<blockquote>\n<p>Does command execution require the renderer to return a successful image?</p>\n</blockquote>\n<p>It did not.</p>\n<p>The command ran before the render callback returned, so the outer API could show an error while the side effect had already happened.</p>\n<p>That explained the title I had not yet written.</p>\n<p>At 04:02, <code class=\"language-text\">$COMPANY</code> disabled preview generation on the mobile endpoint. Pasted links still sent as text, but no card was created.</p>\n<p>At 12:26, the report was marked critical.</p>\n<h2 id=\"three-patches-not-one\" style=\"position:relative;\"><a href=\"#three-patches-not-one\" aria-label=\"three patches not one permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Three patches, not one</h2>\n<p>The permanent fix arrived five days later.</p>\n<h3 id=\"1-every-destination-became-a-new-decision\" style=\"position:relative;\"><a href=\"#1-every-destination-became-a-new-decision\" aria-label=\"1 every destination became a new decision permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>1. Every destination became a new decision</h3>\n<p>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.</p>\n<p>The invariant was written like this in the remediation notes:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">For the initial URL and every redirect hop:\n\nscheme ∈ {http, https}\nresolved address is globally routable\nconnected address equals a validated resolution\ndestination is not loopback, private, link-local, multicast, or reserved\nredirect count ≤ 3\nresponse size and time are bounded</code></pre></div>\n<p>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.</p>\n<p>That second layer matters. URL parsing is full of representations, redirects, DNS behavior, proxies, and disagreement between libraries. The <a href=\"https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html\">OWASP SSRF prevention guidance</a> recommends network controls in addition to application checks for exactly this reason.</p>\n<p>The old code made one policy decision and then delegated navigation to a general-purpose HTTP client.</p>\n<p>The new code kept policy in the loop.</p>\n<h3 id=\"2-localhost-stopped-pretending-to-be-authentication\" style=\"position:relative;\"><a href=\"#2-localhost-stopped-pretending-to-be-authentication\" aria-label=\"2 localhost stopped pretending to be authentication permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>2. Localhost stopped pretending to be authentication</h3>\n<p>The debug renderer route was removed from production.</p>\n<p>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.</p>\n<p>The renderer also moved into a more restricted container:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">read-only root filesystem\nno cloud instance metadata route\nno general outbound internet access\nminimal object-store permission\nno shell package in runtime image\nCPU and wall-clock limits per render</code></pre></div>\n<p>A service should be able to survive the caller making a mistake.</p>\n<p>“Only internal systems can reach this” is useful context. It is not authorization.</p>\n<h3 id=\"3-the-url-became-an-argument-not-a-program\" style=\"position:relative;\"><a href=\"#3-the-url-became-an-argument-not-a-program\" aria-label=\"3 the url became an argument not a program permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>3. The URL became an argument, not a program</h3>\n<p>The renderer replaced <code class=\"language-text\">exec()</code> with a direct process invocation and an argument array:</p>\n<div class=\"gatsby-highlight\" data-language=\"javascript\"><pre class=\"language-javascript\"><code class=\"language-javascript\"><span class=\"token keyword\">import</span> <span class=\"token punctuation\">{</span> spawn <span class=\"token punctuation\">}</span> <span class=\"token keyword\">from</span> <span class=\"token string\">\"node:child_process\"</span><span class=\"token punctuation\">;</span>\n<span class=\"token keyword\">import</span> <span class=\"token punctuation\">{</span> randomUUID <span class=\"token punctuation\">}</span> <span class=\"token keyword\">from</span> <span class=\"token string\">\"node:crypto\"</span><span class=\"token punctuation\">;</span>\n\napp<span class=\"token punctuation\">.</span><span class=\"token function\">post</span><span class=\"token punctuation\">(</span><span class=\"token string\">\"/render\"</span><span class=\"token punctuation\">,</span> requireServiceIdentity<span class=\"token punctuation\">,</span> <span class=\"token punctuation\">(</span><span class=\"token parameter\">req<span class=\"token punctuation\">,</span> res</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">=></span> <span class=\"token punctuation\">{</span>\n  <span class=\"token keyword\">const</span> target <span class=\"token operator\">=</span> <span class=\"token function\">validateRenderUrl</span><span class=\"token punctuation\">(</span>req<span class=\"token punctuation\">.</span>body<span class=\"token punctuation\">.</span>url<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n  <span class=\"token keyword\">const</span> width <span class=\"token operator\">=</span> <span class=\"token function\">validateWidth</span><span class=\"token punctuation\">(</span>req<span class=\"token punctuation\">.</span>body<span class=\"token punctuation\">.</span>width<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n  <span class=\"token keyword\">const</span> output <span class=\"token operator\">=</span> <span class=\"token template-string\"><span class=\"token template-punctuation string\">`</span><span class=\"token string\">/tmp/</span><span class=\"token interpolation\"><span class=\"token interpolation-punctuation punctuation\">${</span><span class=\"token function\">randomUUID</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token interpolation-punctuation punctuation\">}</span></span><span class=\"token string\">.png</span><span class=\"token template-punctuation string\">`</span></span><span class=\"token punctuation\">;</span>\n\n  <span class=\"token keyword\">const</span> child <span class=\"token operator\">=</span> <span class=\"token function\">spawn</span><span class=\"token punctuation\">(</span>\n    process<span class=\"token punctuation\">.</span>execPath<span class=\"token punctuation\">,</span>\n    <span class=\"token punctuation\">[</span>\n      <span class=\"token string\">\"capture.js\"</span><span class=\"token punctuation\">,</span>\n      <span class=\"token string\">\"--url\"</span><span class=\"token punctuation\">,</span> target<span class=\"token punctuation\">,</span>\n      <span class=\"token string\">\"--width\"</span><span class=\"token punctuation\">,</span> <span class=\"token function\">String</span><span class=\"token punctuation\">(</span>width<span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span>\n      <span class=\"token string\">\"--output\"</span><span class=\"token punctuation\">,</span> output<span class=\"token punctuation\">,</span>\n    <span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span>\n    <span class=\"token punctuation\">{</span>\n      <span class=\"token literal-property property\">shell</span><span class=\"token operator\">:</span> <span class=\"token boolean\">false</span><span class=\"token punctuation\">,</span>\n      <span class=\"token literal-property property\">timeout</span><span class=\"token operator\">:</span> <span class=\"token number\">20_000</span><span class=\"token punctuation\">,</span>\n      <span class=\"token literal-property property\">stdio</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">[</span><span class=\"token string\">\"ignore\"</span><span class=\"token punctuation\">,</span> <span class=\"token string\">\"ignore\"</span><span class=\"token punctuation\">,</span> <span class=\"token string\">\"pipe\"</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span>\n    <span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span>\n  <span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n\n  child<span class=\"token punctuation\">.</span><span class=\"token function\">on</span><span class=\"token punctuation\">(</span><span class=\"token string\">\"close\"</span><span class=\"token punctuation\">,</span> <span class=\"token punctuation\">(</span><span class=\"token parameter\">code</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">=></span> <span class=\"token punctuation\">{</span>\n    <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>code <span class=\"token operator\">!==</span> <span class=\"token number\">0</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\n      <span class=\"token keyword\">return</span> res<span class=\"token punctuation\">.</span><span class=\"token function\">status</span><span class=\"token punctuation\">(</span><span class=\"token number\">502</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">.</span><span class=\"token function\">json</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">{</span> <span class=\"token literal-property property\">error</span><span class=\"token operator\">:</span> <span class=\"token string\">\"render_failed\"</span> <span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n    <span class=\"token punctuation\">}</span>\n\n    res<span class=\"token punctuation\">.</span><span class=\"token function\">sendFile</span><span class=\"token punctuation\">(</span>output<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n  <span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n<span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span></code></pre></div>\n<p>The important part is not <code class=\"language-text\">spawn</code> 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.</p>\n<p>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.</p>\n<p>Security patches work better when they do not require one line of code to be perfect.</p>\n<h2 id=\"retesting-the-chain\" style=\"position:relative;\"><a href=\"#retesting-the-chain\" aria-label=\"retesting the chain permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Retesting the chain</h2>\n<p>The patched mobile endpoint returned previews again on August 18.</p>\n<p>The original public test URL still worked.</p>\n<p>A public-to-public redirect worked, up to the new three-hop limit.</p>\n<p>A public URL redirecting to loopback failed before the second request:</p>\n<div class=\"gatsby-highlight\" data-language=\"json\"><pre class=\"language-json\"><code class=\"language-json\"><span class=\"token punctuation\">{</span>\n  <span class=\"token property\">\"error\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"url_not_allowed\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"reason\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"redirect_destination_not_public\"</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>My loopback service received nothing.</p>\n<p>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.</p>\n<p>The old internal <code class=\"language-text\">/debug/render</code> route returned <code class=\"language-text\">404</code> from the renderer image and was unreachable from the preview network anyway.</p>\n<p>Finally, <code class=\"language-text\">$COMPANY</code> 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.</p>\n<p>No canary request arrived.</p>\n<p>No timing shift appeared.</p>\n<p>The preview failed.</p>\n<p>This time the command failed with it.</p>\n<h2 id=\"the-regression-tests-that-describe-the-real-boundary\" style=\"position:relative;\"><a href=\"#the-regression-tests-that-describe-the-real-boundary\" aria-label=\"the regression tests that describe the real boundary permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The regression tests that describe the real boundary</h2>\n<p>The most useful tests were not named after my payload. They were named after the properties the system had forgotten.</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\"><span class=\"token keyword\">def</span> <span class=\"token function\">test_preview_revalidates_every_redirect_destination</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">:</span>\n    public <span class=\"token operator\">=</span> controlled_server<span class=\"token punctuation\">.</span>redirect_to<span class=\"token punctuation\">(</span><span class=\"token string\">\"http://127.0.0.1:3001/\"</span><span class=\"token punctuation\">)</span>\n\n    result <span class=\"token operator\">=</span> create_mobile_preview<span class=\"token punctuation\">(</span>url<span class=\"token operator\">=</span>public<span class=\"token punctuation\">)</span>\n\n    <span class=\"token keyword\">assert</span> result<span class=\"token punctuation\">.</span>status <span class=\"token operator\">==</span> <span class=\"token number\">400</span>\n    <span class=\"token keyword\">assert</span> result<span class=\"token punctuation\">.</span>reason <span class=\"token operator\">==</span> <span class=\"token string\">\"redirect_destination_not_public\"</span>\n    <span class=\"token keyword\">assert</span> controlled_loopback_service<span class=\"token punctuation\">.</span>requests <span class=\"token operator\">==</span> <span class=\"token punctuation\">[</span><span class=\"token punctuation\">]</span></code></pre></div>\n<div class=\"gatsby-highlight\" data-language=\"javascript\"><pre class=\"language-javascript\"><code class=\"language-javascript\"><span class=\"token function\">test</span><span class=\"token punctuation\">(</span><span class=\"token string\">\"renderer passes URL as one argument without a shell\"</span><span class=\"token punctuation\">,</span> <span class=\"token keyword\">async</span> <span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">=></span> <span class=\"token punctuation\">{</span>\n  <span class=\"token keyword\">const</span> suspicious <span class=\"token operator\">=</span> <span class=\"token function\">makeStringContainingShellSyntax</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n\n  <span class=\"token keyword\">await</span> <span class=\"token function\">expect</span><span class=\"token punctuation\">(</span><span class=\"token function\">render</span><span class=\"token punctuation\">(</span>suspicious<span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">.</span>rejects<span class=\"token punctuation\">.</span><span class=\"token function\">toMatchObject</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">{</span>\n    <span class=\"token literal-property property\">code</span><span class=\"token operator\">:</span> <span class=\"token string\">\"INVALID_RENDER_URL\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n\n  <span class=\"token function\">expect</span><span class=\"token punctuation\">(</span>spawn<span class=\"token punctuation\">)</span><span class=\"token punctuation\">.</span>not<span class=\"token punctuation\">.</span><span class=\"token function\">toHaveBeenCalledWith</span><span class=\"token punctuation\">(</span>\n    expect<span class=\"token punctuation\">.</span><span class=\"token function\">anything</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span>\n    expect<span class=\"token punctuation\">.</span><span class=\"token function\">anything</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span>\n    expect<span class=\"token punctuation\">.</span><span class=\"token function\">objectContaining</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">{</span> <span class=\"token literal-property property\">shell</span><span class=\"token operator\">:</span> <span class=\"token boolean\">true</span> <span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span>\n  <span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n<span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span></code></pre></div>\n<p>And the infrastructure test mattered as much as either unit test:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">preview-worker → public HTTP/HTTPS: allowed through controlled egress\npreview-worker → loopback/private/link-local/metadata: denied\nrenderer       → arbitrary internet: denied\nrenderer       → preview object bucket: write-only, scoped prefix</code></pre></div>\n<p>A fixed payload is one regression test.</p>\n<p>A fixed trust model is a system of them.</p>\n<h2 id=\"the-three-assumptions-in-the-exploit\" style=\"position:relative;\"><a href=\"#the-three-assumptions-in-the-exploit\" aria-label=\"the three assumptions in the exploit permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The three assumptions in the exploit</h2>\n<p>No individual mistake felt large enough to become critical.</p>\n<p>The URL validator blocked private addresses.</p>\n<p>The renderer listened only on loopback.</p>\n<p>The command put the URL inside quotes.</p>\n<p>Each sentence sounds like a mitigation.</p>\n<p>Together they produced RCE.</p>\n<h3 id=\"we-block-internal-urls\" style=\"position:relative;\"><a href=\"#we-block-internal-urls\" aria-label=\"we block internal urls permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>“We block internal URLs”</h3>\n<p>The service blocked the URL the user supplied. It did not block the URL the HTTP client eventually fetched.</p>\n<p>Validation attached to a string instead of to the network action.</p>\n<h3 id=\"the-endpoint-is-only-on-localhost\" style=\"position:relative;\"><a href=\"#the-endpoint-is-only-on-localhost\" aria-label=\"the endpoint is only on localhost permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>“The endpoint is only on localhost”</h3>\n<p>That was true from the network’s point of view.</p>\n<p>It was false from the attacker’s point of view because another reachable service could be instructed to make the request.</p>\n<p>Reachability composes.</p>\n<h3 id=\"the-value-is-quoted\" style=\"position:relative;\"><a href=\"#the-value-is-quoted\" aria-label=\"the value is quoted permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>“The value is quoted”</h3>\n<p>Quoting is part of shell grammar, not an alternative to it.</p>\n<p>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.</p>\n<p>The exploit did not defeat three strong defenses.</p>\n<p>It passed through three defenses that protected different objects from the ones developers thought they protected.</p>\n<h2 id=\"the-report-closed\" style=\"position:relative;\"><a href=\"#the-report-closed\" aria-label=\"the report closed permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The report closed</h2>\n<p><code class=\"language-text\">$COMPANY</code> closed the report on August 24 after the mobile clients had resumed preview generation and the infrastructure changes had reached production.</p>\n<p>The reward was <code class=\"language-text\">$BOUNTY</code>. The amount matters less than the severity classification, and the severity matters less than the patch surviving the feature being turned back on.</p>\n<p>The engineer who handled the report sent one final note:</p>\n<blockquote>\n<p>We originally treated the redirect check, renderer authentication, and shell invocation as separate issues. The incident review now models them as one request path.</p>\n</blockquote>\n<p>That is the right lesson.</p>\n<p>Vulnerabilities are often reviewed one function at a time because code is stored one function at a time. Attackers get the whole request path.</p>\n<p>In February, <a href=\"/how-a-pdf-button-became-remote-code-execution-and-paid-for-my-year/\">a PDF button reminded me that reachability is not authorization</a>.</p>\n<p>In June, <a href=\"/the-signature-was-valid-the-company-was-wrong/\">SAML reminded me that authentication is not tenancy</a>.</p>\n<p>This one added a smaller rule:</p>\n<p><strong>A redirect is a new request, not a footnote to the old one.</strong></p>\n<p>The first URL was public.</p>\n<p>The second URL was localhost.</p>\n<p>The third value became shell syntax.</p>\n<p>Every component did something it had been designed to do:</p>\n<ul>\n<li>The mobile app asked for a preview.</li>\n<li>The previewer followed a redirect.</li>\n<li>The renderer accepted a local request.</li>\n<li>The shell interpreted a command.</li>\n</ul>\n<p>The vulnerability lived in the word <em>therefore</em> between those sentences.</p>\n<p>No command ran on the phone.</p>\n<p>No firewall port opened to the internet.</p>\n<p>No cryptography failed.</p>\n<p>I pasted a link into a mobile app.</p>\n<p>The app said the preview failed.</p>\n<p>The server ran the rest.</p>","timeToRead":18,"frontmatter":{"title":"The preview failed. The command did not.","date":"August 26, 2023","description":"A mobile app's link-preview endpoint followed one redirect into localhost, reached an internal screenshot service, and turned a blocked SSRF into remote command execution."}}},"pageContext":{"slug":"/the-preview-failed-the-command-did-not/","previous":{"fields":{"slug":"/the-signature-was-valid-the-company-was-wrong/"},"frontmatter":{"title":"The signature was valid. The company was wrong"}},"next":{"fields":{"slug":"/the-cookie-was-httponly-the-session-was-not/"},"frontmatter":{"title":"The cookie was HttpOnly. The session was not."}}}},
    "staticQueryHashes": ["3037286347","63159454"]}