Zion Boggan
repos/Security Portfolio/security-research-notebook/ssrf-via-image-pipeline/index.html
zionboggan.com ↗
516 lines · html
History for this file →
1
<!doctype html>
2
<html lang="en"><head><meta charset="utf-8">
3
<meta name="viewport" content="width=device-width, initial-scale=1.0">
4
<title>How I Found Two SSRF Vulnerabilities in a Major Cloud Platform&amp;#x27;s Image Pipeline | Zion Boggan</title>
5
<meta name="description" content="Two SSRFs in the same platform via different code paths (webhook callbacks + image processor). The systemic pattern matters more than either finding.">
6
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%230c0e12'/%3E%3Ctext x='16' y='22' font-family='monospace' font-size='15' fill='%236cc7b8' text-anchor='middle'%3Ezb%3C/text%3E%3C/svg%3E">
7
<style>
8
  :root{
9
    --bg:#0c0e12; --bg2:#0f1217; --panel:#14181f; --panel2:#171c24;
10
    --line:#222936; --line2:#2c3543;
11
    --ink:#e8eaed; --soft:#c3cad4; --muted:#8a94a3; --faint:#5d6675;
12
    --accent:#6cc7b8; --accent-dim:#274b47;
13
    --maxw:1020px;
14
  }
15
  *{box-sizing:border-box;}
16
  html{scroll-behavior:smooth;}
17
  body{margin:0;background:var(--bg);color:var(--ink);
18
    font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
19
    font-size:16px;line-height:1.65;-webkit-font-smoothing:antialiased;}
20
  .mono{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;}
21
  a{color:var(--accent);text-decoration:none;}
22
  a:hover{color:#8fe0d2;}
23
  .wrap{max-width:var(--maxw);margin:0 auto;padding:0 24px;}
24
 
25
  /* nav */
26
  nav{position:sticky;top:0;z-index:20;background:rgba(12,14,18,.82);
27
    backdrop-filter:blur(10px);border-bottom:1px solid var(--line);}
28
  nav .wrap{display:flex;align-items:center;justify-content:space-between;height:58px;}
29
  nav .brand{font-weight:600;letter-spacing:.2px;}
30
  nav .brand .dot{color:var(--accent);}
31
  nav .links{display:flex;gap:26px;font-size:13.5px;}
32
  nav .links a{color:var(--muted);}
33
  nav .links a:hover{color:var(--ink);}
34
  @media(max-width:680px){nav .links{display:none;}}
35
 
36
  /* hero */
37
  header.hero{padding:74px 0 54px;border-bottom:1px solid var(--line);
38
    background:radial-gradient(900px 380px at 78% -10%, #11201e 0%, transparent 60%);}
39
  .avail{font-size:12.5px;letter-spacing:1.5px;text-transform:uppercase;color:var(--accent);
40
    display:flex;align-items:center;gap:9px;margin-bottom:20px;}
41
  .avail .pulse{width:7px;height:7px;border-radius:50%;background:var(--accent);
42
    box-shadow:0 0 0 0 rgba(108,199,184,.5);animation:p 2.4s infinite;}
43
  @keyframes p{0%{box-shadow:0 0 0 0 rgba(108,199,184,.45)}70%{box-shadow:0 0 0 8px rgba(108,199,184,0)}100%{box-shadow:0 0 0 0 rgba(108,199,184,0)}}
44
  h1{font-size:clamp(34px,6vw,52px);line-height:1.05;margin:0 0 8px;letter-spacing:-1px;font-weight:680;}
45
  .hero .sub{font-size:clamp(16px,2.4vw,20px);color:var(--soft);margin:0 0 24px;font-weight:500;}
46
  .hero .lede{max-width:660px;color:var(--soft);font-size:17px;margin:0 0 28px;}
47
  .hero .lede b{color:var(--ink);font-weight:600;}
48
  .cta{display:flex;flex-wrap:wrap;gap:12px;align-items:center;}
49
  .btn{display:inline-flex;align-items:center;gap:8px;padding:10px 18px;border-radius:8px;
50
    font-size:14.5px;font-weight:550;border:1px solid var(--line2);color:var(--ink);background:var(--panel);}
51
  .btn:hover{border-color:var(--accent-dim);background:var(--panel2);color:var(--ink);}
52
  .btn.primary{background:var(--accent);color:#06231f;border-color:var(--accent);font-weight:650;}
53
  .btn.primary:hover{background:#8fe0d2;color:#06231f;}
54
  .meta{margin-top:26px;display:flex;flex-wrap:wrap;gap:8px 22px;font-size:13px;color:var(--muted);}
55
  .meta .mono{color:var(--faint);}
56
 
57
  /* sections */
58
  section{padding:64px 0;border-bottom:1px solid var(--line);}
59
  .shead{display:flex;align-items:baseline;gap:14px;margin-bottom:30px;}
60
  .shead .idx{font-size:13px;color:var(--accent);letter-spacing:1px;}
61
  .shead h2{font-size:14px;letter-spacing:2px;text-transform:uppercase;color:var(--muted);margin:0;font-weight:600;}
62
  .shead .rule{flex:1;height:1px;background:var(--line);}
63
 
64
  /* flagship */
65
  .flag{background:linear-gradient(180deg,var(--panel) 0%,var(--bg2) 100%);
66
    border:1px solid var(--line2);border-radius:14px;overflow:hidden;}
67
  .flag .top{padding:30px 32px 8px;}
68
  .flag .tag{font-size:12px;letter-spacing:1.5px;text-transform:uppercase;color:var(--accent);margin-bottom:12px;}
69
  .flag h3{font-size:27px;margin:0 0 6px;letter-spacing:-.4px;}
70
  .flag h3 .v{font-size:13px;color:var(--muted);font-weight:500;margin-left:8px;letter-spacing:0;}
71
  .flag .grid{display:grid;grid-template-columns:1.25fr 1fr;gap:30px;padding:14px 32px 30px;}
72
  .flag p{color:var(--soft);margin:0 0 16px;}
73
  .flag .stats{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:6px;}
74
  .stat{background:var(--bg);border:1px solid var(--line);border-radius:9px;padding:13px 15px;}
75
  .stat .n{font-size:21px;font-weight:680;color:var(--ink);}
76
  .stat .k{font-size:12px;color:var(--muted);margin-top:2px;}
77
  .spec{background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:18px 18px;}
78
  .spec .sk{font-size:11px;letter-spacing:1.5px;text-transform:uppercase;color:var(--faint);margin-bottom:10px;}
79
  .spec ul{margin:0;padding:0;list-style:none;font-size:13.5px;}
80
  .spec li{padding:6px 0;border-top:1px solid var(--line);color:var(--soft);display:flex;justify-content:space-between;gap:14px;}
81
  .spec li:first-child{border-top:none;}
82
  .spec li span{color:var(--muted);}
83
  .flag .foot{padding:0 32px 28px;display:flex;gap:18px;flex-wrap:wrap;font-size:14px;}
84
  @media(max-width:720px){.flag .grid{grid-template-columns:1fr;}}
85
 
86
  /* lab cards */
87
  .cards{display:grid;grid-template-columns:1fr 1fr;gap:20px;}
88
  @media(max-width:680px){.cards{grid-template-columns:1fr;}}
89
  .card{border:1px solid var(--line);border-radius:12px;overflow:hidden;background:var(--panel);
90
    display:flex;flex-direction:column;transition:border-color .15s,transform .15s;}
91
  .card:hover{border-color:var(--accent-dim);transform:translateY(-2px);}
92
  .card .thumb{height:172px;overflow:hidden;border-bottom:1px solid var(--line);background:#fff;}
93
  .card .thumb img{width:100%;height:100%;object-fit:cover;object-position:top left;display:block;}
94
  .card .body{padding:18px 20px 20px;display:flex;flex-direction:column;flex:1;}
95
  .card h3{margin:0 0 9px;font-size:17px;}
96
  .card p{margin:0 0 14px;font-size:14px;color:var(--soft);flex:1;}
97
  .tags{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:14px;}
98
  .tags span{font-size:11.5px;color:var(--muted);background:var(--bg);border:1px solid var(--line);
99
    border-radius:5px;padding:3px 8px;}
100
  .card .lnk{font-size:13.5px;font-family:ui-monospace,Menlo,monospace;}
101
  .card .lnk::after{content:" โ†’";}
102
 
103
  /* research */
104
  .rlede{color:var(--soft);max-width:680px;margin:-6px 0 26px;}
105
  .research{display:flex;flex-direction:column;gap:0;border:1px solid var(--line);border-radius:12px;overflow:hidden;}
106
  .ritem{display:grid;grid-template-columns:120px 1fr auto;gap:18px;align-items:center;
107
    padding:18px 22px;border-top:1px solid var(--line);}
108
  .ritem:first-child{border-top:none;}
109
  .ritem:hover{background:var(--panel);}
110
  .ritem .cls{font-size:11px;letter-spacing:.5px;text-transform:uppercase;color:var(--accent);}
111
  .ritem h3{margin:0 0 3px;font-size:16px;}
112
  .ritem p{margin:0;font-size:13.5px;color:var(--muted);}
113
  .ritem .go{font-family:ui-monospace,Menlo,monospace;font-size:13px;white-space:nowrap;}
114
  @media(max-width:680px){.ritem{grid-template-columns:1fr;gap:6px;}.ritem .go{margin-top:4px;}}
115
  .progs{margin-top:22px;}
116
  .progs .sk{font-size:11px;letter-spacing:1.5px;text-transform:uppercase;color:var(--faint);margin-bottom:11px;}
117
  .progs .row{display:flex;flex-wrap:wrap;gap:7px;}
118
  .progs .row span{font-size:12.5px;color:var(--soft);background:var(--panel);border:1px solid var(--line);
119
    border-radius:6px;padding:4px 10px;}
120
 
121
  /* credentials */
122
  .cred{display:grid;grid-template-columns:1.1fr 1fr;gap:28px;}
123
  @media(max-width:680px){.cred{grid-template-columns:1fr;}}
124
  .cred p{color:var(--soft);margin:0 0 14px;}
125
  .cred .role{font-size:14px;color:var(--muted);}
126
  .cred .role b{color:var(--ink);font-weight:600;}
127
  .certs{list-style:none;margin:0;padding:0;}
128
  .certs li{padding:9px 0;border-top:1px solid var(--line);font-size:14px;color:var(--soft);
129
    display:flex;gap:10px;align-items:baseline;}
130
  .certs li:first-child{border-top:none;}
131
  .certs li .c{color:var(--accent);font-family:ui-monospace,Menlo,monospace;font-size:12px;}
132
 
133
  footer{padding:46px 0 64px;}
134
  footer .row{display:flex;flex-wrap:wrap;justify-content:space-between;gap:18px;align-items:center;}
135
  footer .links a{color:var(--soft);margin-right:20px;font-size:14px;}
136
  footer .note{color:var(--faint);font-size:12.5px;max-width:520px;}
137
 
138
  .detail-hero{padding:40px 0 26px;}
139
  .back{display:inline-block;font-size:13px;color:var(--muted);margin-bottom:20px;font-family:ui-monospace,Menlo,monospace;}
140
  .back:hover{color:var(--ink);}
141
  .kicker{font-size:12px;letter-spacing:2px;text-transform:uppercase;color:var(--accent);margin-bottom:13px;font-family:ui-monospace,Menlo,monospace;}
142
  .detail-hero h1{font-size:clamp(26px,4.6vw,38px);margin:0 0 12px;letter-spacing:-.5px;}
143
  .detail-hero .tagline{font-size:clamp(15px,2vw,18px);color:var(--soft);max-width:800px;margin:0 0 16px;}
144
  .facts{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin-top:22px;}
145
  .content{padding:8px 0 0;max-width:840px;}
146
  .content h1{font-size:24px;margin:40px 0 14px;letter-spacing:-.4px;color:var(--ink);}
147
  .content h2{font-size:13px;letter-spacing:2px;text-transform:uppercase;color:var(--muted);margin:42px 0 15px;font-weight:600;border-top:1px solid var(--line);padding-top:28px;}
148
  .content h3{font-size:17px;margin:28px 0 10px;color:var(--ink);font-weight:600;}
149
  .content h4{font-size:14px;margin:22px 0 8px;color:var(--soft);font-weight:600;text-transform:uppercase;letter-spacing:.5px;}
150
  .content p{color:var(--soft);margin:0 0 15px;}
151
  .content ul,.content ol{color:var(--soft);margin:0 0 15px;padding-left:22px;}
152
  .content li{margin:5px 0;}
153
  .content strong{color:var(--ink);font-weight:600;}
154
  .content a{color:var(--accent);}
155
  .content code{font-family:ui-monospace,Menlo,monospace;font-size:12.8px;background:var(--panel2);border:1px solid var(--line);border-radius:4px;padding:1px 5px;color:var(--soft);}
156
  .content pre{background:var(--bg2);border:1px solid var(--line2);border-radius:10px;padding:15px 18px;overflow-x:auto;margin:0 0 18px;}
157
  .content pre code{background:none;border:none;padding:0;font-size:12.4px;color:var(--soft);line-height:1.6;white-space:pre;}
158
  .content table{width:100%;border-collapse:collapse;margin:2px 0 20px;font-size:13.3px;}
159
  .content th{text-align:left;color:var(--muted);font-weight:600;border-bottom:1px solid var(--line2);padding:9px 12px;font-size:11px;letter-spacing:.6px;text-transform:uppercase;}
160
  .content td{color:var(--soft);border-bottom:1px solid var(--line);padding:9px 12px;vertical-align:top;}
161
  .content blockquote{border-left:3px solid var(--accent-dim);margin:0 0 16px;padding:2px 0 2px 18px;color:var(--muted);}
162
  .content hr{border:none;border-top:1px solid var(--line);margin:30px 0;}
163
  /* notebook index */
164
  .nbgroup{margin:40px 0 0;}
165
  .nbgroup h2{font-size:13px;letter-spacing:2px;text-transform:uppercase;color:var(--accent);margin:0 0 4px;font-weight:600;}
166
  .nbgroup .gd{color:var(--faint);font-size:13px;margin:0 0 14px;}
167
  .nbtable{width:100%;border-collapse:collapse;font-size:14px;border:1px solid var(--line);border-radius:12px;overflow:hidden;}
168
  .nbtable tr{border-top:1px solid var(--line);}
169
  .nbtable tr:first-child{border-top:none;}
170
  .nbtable tr:hover{background:var(--panel);}
171
  .nbtable td{padding:14px 16px;vertical-align:top;}
172
  .nbtable .cls{white-space:nowrap;color:var(--accent);font-family:ui-monospace,Menlo,monospace;font-size:11.5px;text-transform:uppercase;letter-spacing:.5px;width:150px;}
173
  .nbtable .ti a{font-weight:600;color:var(--ink);}
174
  .nbtable .ti a:hover{color:var(--accent);}
175
  .nbtable .ol{color:var(--muted);font-size:13px;margin-top:3px;}
176
  @media(max-width:680px){.nbtable .cls{width:auto;display:block;}}
177
</style>
178
<link rel="canonical" href="https://zionboggan.com/security-research-notebook/ssrf-via-image-pipeline/">
179
<meta name="author" content="Zion Boggan">
180
<meta name="robots" content="index, follow, max-image-preview:large">
181
<meta property="og:type" content="article">
182
<meta property="og:site_name" content="Zion Boggan">
183
<meta property="og:title" content="How I Found Two SSRF Vulnerabilities in a Major Cloud Platform&amp;#x27;s Image Pipeline | Zion Boggan">
184
<meta property="og:description" content="Two SSRFs in the same platform via different code paths (webhook callbacks + image processor). The systemic pattern matters more than either finding.">
185
<meta property="og:url" content="https://zionboggan.com/security-research-notebook/ssrf-via-image-pipeline/">
186
<meta property="og:image" content="https://zionboggan.com/images/ssrf-textrender-proof.png">
187
<meta name="twitter:card" content="summary_large_image">
188
<meta name="twitter:title" content="How I Found Two SSRF Vulnerabilities in a Major Cloud Platform&amp;#x27;s Image Pipeline | Zion Boggan">
189
<meta name="twitter:description" content="Two SSRFs in the same platform via different code paths (webhook callbacks + image processor). The systemic pattern matters more than either finding.">
190
<meta name="twitter:image" content="https://zionboggan.com/images/ssrf-textrender-proof.png">
191
<script type="application/ld+json">{"@context":"https://schema.org","@type":"TechArticle","headline":"How I Found Two SSRF Vulnerabilities in a Major Cloud Platform&amp;#x27;s Image Pipeline","description":"Two SSRFs in the same platform via different code paths (webhook callbacks + image processor). The systemic pattern matters more than either finding.","url":"https://zionboggan.com/security-research-notebook/ssrf-via-image-pipeline/","image":"https://zionboggan.com/images/ssrf-textrender-proof.png","author":{"@type":"Person","name":"Zion Boggan","url":"https://zionboggan.com"},"publisher":{"@type":"Person","name":"Zion Boggan"}}</script>
192
</head><body>
193
<nav><div class="wrap">
194
  <a class="brand mono" href="/" style="color:var(--ink)">zion_boggan<span class="dot">.</span></a>
195
  <span class="links"><a href="/#oversight">Oversight</a><a href="/#labs">Labs</a><a href="/#research">Research</a><a href="/security-research-notebook/">Notebook</a><a href="/">Home</a></span>
196
</div></nav>
197
<header class="hero detail-hero"><div class="wrap">
198
  <a class="back" href="/security-research-notebook/">&larr; Research notebook</a>
199
  <div class="kicker">SSRF</div>
200
  <h1>How I Found Two SSRF Vulnerabilities in a Major Cloud Platform&#x27;s Image Pipeline</h1>
201
</div></header>
202
<section><div class="wrap"><div class="content">
203
<blockquote>
204
<p>Author: HackerOne <code>artemispwns1</code> / Bugcrowd Researcher<br />
205
Disclosure: under the program&rsquo;s responsible disclosure policy; vendor and
206
specific technical details intentionally omitted per policy.</p>
207
</blockquote>
208
<h2>Introduction</h2>
209
<p>I have been doing independent security research for a few months now, working
210
through bug bounty programs on HackerOne and Bugcrowd. What started as
211
curiosity about how web applications handle trust boundaries has turned into
212
a genuine discipline, and the findings keep getting more interesting as I
213
learn to think at the architectural level rather than just scanning for
214
low-hanging fruit.</p>
215
<p>Recently I completed an audit of a major cloud-based media processing
216
platform through their bug bounty program. The engagement produced two
217
distinct Server-Side Request Forgery (SSRF) vulnerabilities in separate
218
code paths, both stemming from the same root cause: inconsistent URL
219
validation across different parts of the application. I cannot name the
220
vendor or disclose specific technical details per the program&rsquo;s disclosure
221
policy, but I can share the methodology, the thought process, and what I
222
learned about how these kinds of gaps form in production systems.</p>
223
<p>This writeup is for anyone getting into bug bounty research or application
224
security who wants to understand how to move beyond surface-level recon and
225
start thinking about systemic vulnerabilities.</p>
226
<h2>Background: What is SSRF and Why Does It Matter?</h2>
227
<p>Server-Side Request Forgery is a vulnerability class where an attacker can
228
make a server issue HTTP requests on their behalf to destinations the
229
attacker should not be able to reach. The classic example is tricking a
230
server into calling the AWS metadata endpoint at <code>169.254.169.254</code>, which
231
can return temporary IAM credentials and expose the entire cloud environment.</p>
232
<p>SSRF has been climbing the OWASP Top 10 and sits in every serious attacker&rsquo;s
233
playbook because cloud infrastructure made internal network access
234
exponentially more valuable. A single SSRF in the right place can pivot
235
from &ldquo;I can make a server fetch a URL&rdquo; to &ldquo;I now have AWS keys to the
236
production S3 buckets.&rdquo; The impact scales with the trust the server has on
237
the internal network.</p>
238
<h2>The Methodology: Thinking in Code Paths</h2>
239
<p>When I started this audit, my first instinct was to test the obvious entry
240
points. The platform had a URL-based fetch feature where you supply an
241
external URL and the server retrieves the resource. Standard stuff. I tested
242
internal IPs against this endpoint and got exactly what I expected: a clean
243
403 Forbidden. They had SSRF protections in place.</p>
244
<p>A less experienced version of me would have stopped there. Good filter,
245
move on.</p>
246
<p>But I had been reading about how large platforms evolve over time. Features
247
get built by different teams, at different times, with different security
248
assumptions. The upload API team probably thought carefully about SSRF. The
249
question is whether every other team that added URL-accepting functionality
250
did the same.</p>
251
<p>So I mapped every parameter and feature in the platform that could potentially
252
cause the server to make an outbound HTTP request. Not just the obvious ones
253
like &ldquo;fetch this URL&rdquo; but also webhook callbacks, notification endpoints,
254
asynchronous processing triggers, and file format features that might resolve
255
external references during server-side rendering.</p>
256
<p>This is where the shift from scanning to auditing happens. You stop testing
257
individual inputs and start testing trust boundaries across the entire
258
application surface.</p>
259
<h2>Finding 1: Blind SSRF via Webhook Callbacks</h2>
260
<p>The platform had a notification system where you could specify a callback URL
261
that receives a POST request after certain operations complete. Think of it
262
like a webhook: &ldquo;when the job is done, POST the results to this URL.&rdquo;</p>
263
<p>The interesting thing was that the main upload parameter had solid SSRF
264
filtering. Internal IPs were blocked, RFC1918 ranges were rejected, the
265
metadata endpoint was explicitly denied. But the notification URL parameter,
266
which also causes the server to make an outbound HTTP request, had zero
267
filtering.</p>
268
<p>Same server. Same outbound HTTP client (presumably). Completely different
269
security posture depending on which parameter you used.</p>
270
<p>Here is a generic representation of what that comparison looked like:</p>
271
<pre><code class="language-bash"># The upload/fetch parameter correctly blocks internal IPs:
272
curl -X POST &quot;https://api.example.com/v1/upload&quot; \
273
  -d &quot;file=http://169.254.169.254/latest/meta-data/&quot; \
274
  -d &quot;api_key=${API_KEY}&quot; \
275
  -d &quot;timestamp=${TIMESTAMP}&quot; \
276
  -d &quot;signature=${SIGNATURE}&quot;
277
# Response: 403 Forbidden - blocked by SSRF filter
278
 
279
# The notification/callback parameter accepts the EXACT same internal address:
280
curl -X POST &quot;https://api.example.com/v1/upload&quot; \
281
  -d &quot;file=https://normal-image.png&quot; \
282
  -d &quot;callback_url=http://169.254.169.254/latest/meta-data/&quot; \
283
  -d &quot;api_key=${API_KEY}&quot; \
284
  -d &quot;timestamp=${TIMESTAMP}&quot; \
285
  -d &quot;signature=${SIGNATURE}&quot;
286
# Response: 200 OK - upload succeeds, server POSTs to metadata endpoint
287
</code></pre>
288
<p>That contrast is the entire finding in two commands. Same API, same server,
289
same internal target, two completely different outcomes depending on which
290
parameter carries the URL.</p>
291
<p>I tested this across the full range of internal targets to confirm it was
292
not limited to a single address:</p>
293
<table>
294
<thead>
295
<tr>
296
<th>Target</th>
297
<th>Description</th>
298
<th>Result</th>
299
</tr>
300
</thead>
301
<tbody>
302
<tr>
303
<td><code>http://169.254.169.254/latest/meta-data/</code></td>
304
<td>AWS metadata</td>
305
<td>Accepted</td>
306
</tr>
307
<tr>
308
<td><code>http://169.254.170.2/v2/credentials</code></td>
309
<td>ECS credentials</td>
310
<td>Accepted</td>
311
</tr>
312
<tr>
313
<td><code>http://127.0.0.1:6379/</code></td>
314
<td>Localhost Redis</td>
315
<td>Accepted</td>
316
</tr>
317
<tr>
318
<td><code>http://127.0.0.1:9200/</code></td>
319
<td>Localhost Elastic</td>
320
<td>Accepted</td>
321
</tr>
322
<tr>
323
<td><code>http://REDACTED-IP:80/</code></td>
324
<td>RFC1918 Class A</td>
325
<td>Accepted</td>
326
</tr>
327
<tr>
328
<td><code>http://172.16.0.1:80/</code></td>
329
<td>RFC1918 Class B</td>
330
<td>Accepted</td>
331
</tr>
332
</tbody>
333
</table>
334
<p>Every single internal address that the upload parameter correctly blocked
335
was silently accepted through the notification parameter.</p>
336
<p>The severity escalation came from discovering that this gap existed not just
337
in authenticated API calls but also in a feature that allowed preconfigured
338
notification URLs to be triggered without authentication. This meant that
339
once the configuration was set, anyone who knew the right identifiers could
340
trigger the SSRF repeatedly with no API key, no signature, and no rate
341
limiting.</p>
342
<p>The impact profile: blind SSRF to AWS metadata endpoints, localhost services
343
on arbitrary ports, and RFC1918 internal networks. The POST body contained
344
JSON with several attacker-controllable fields, meaning internal services
345
that parse incoming webhooks could receive partially crafted payloads.</p>
346
<p>I rated this as High severity. The platform&rsquo;s existing SSRF protections on
347
the upload parameter proved they understood the risk. The gap in the
348
notification parameter was a systemic oversight, not a design choice.</p>
349
<h2>Finding 2: SSRF via Image Format Processing</h2>
350
<p>The second finding came from a completely different angle. The platform
351
supports server-side image transformation, converting between formats,
352
resizing, applying effects. One of the supported input formats allows
353
embedded references to external URLs as part of the file specification.</p>
354
<p>When the server processes this file format and performs a transformation
355
(for example, converting it to PNG), the underlying image processing
356
library resolves those external references by making its own HTTP requests.
357
These requests happen inside the rendering engine, completely outside the
358
URL validation layer that protects the upload and fetch endpoints.</p>
359
<p>The crafted input file looked something like this (generalized):</p>
360
<pre><code class="language-xml">&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;
361
&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot;
362
     xmlns:xlink=&quot;http://www.w3.org/1999/xlink&quot;
363
     width=&quot;500&quot; height=&quot;500&quot;&gt;
364
  &lt;image xlink:href=&quot;https://attacker-controlled-url.com/test&quot;
365
         width=&quot;500&quot; height=&quot;500&quot;/&gt;
366
&lt;/svg&gt;
367
</code></pre>
368
<p>When the server converts this to a raster format, the image processing
369
library follows that external reference and fetches whatever URL is specified.
370
The fetched content gets rendered into the output image as pixel data.</p>
371
<p>I tested several targets to map the behavior:</p>
372
<table>
373
<thead>
374
<tr>
375
<th>Target</th>
376
<th>Result</th>
377
</tr>
378
</thead>
379
<tbody>
380
<tr>
381
<td>External URL returning an image</td>
382
<td>Full content rendered into output (101KB PNG)</td>
383
</tr>
384
<tr>
385
<td>External URL returning text / JSON</td>
386
<td>Broken image icon rendered (139KB PNG)</td>
387
</tr>
388
<tr>
389
<td>AWS metadata endpoint (internal)</td>
390
<td>Blank output, 12+ second response time</td>
391
</tr>
392
<tr>
393
<td>Baseline with no external ref</td>
394
<td>Blank output, ~300 bytes, sub-second</td>
395
</tr>
396
</tbody>
397
</table>
398
<p>Two illustrative outputs from the engagement (vendor identifiers cropped /
399
omitted; the platform&rsquo;s image processor faithfully renders fetched content
400
into its output pixel data):</p>
401
<p>External URL returning text:
402
<img alt="SSRF text render proof" src="images/ssrf-textrender-proof.png" /></p>
403
<p>External URL returning an image:
404
<img alt="SSRF external image fetch proof" src="images/ssrf-external-fetch-proof.png" /></p>
405
<p>The timing differential was the key evidence for internal network access.
406
Normal transformations completed in under one second. Transformations
407
referencing the metadata endpoint consistently took 12+ seconds before
408
returning, indicating the server attempted the connection and waited for a
409
response that never came.</p>
410
<p>I confirmed this by uploading a specially crafted file with an external URL
411
reference, then triggering a format conversion through the delivery API.
412
The output image contained rendered content from the external URL, proving
413
the server had fetched it. When I tested with the AWS metadata endpoint,
414
the transformation took 12+ seconds (versus less than one second for normal
415
transforms), confirming the connection attempt through timing analysis.</p>
416
<p>The key insight: this SSRF vector exists in a completely different code path
417
from the first finding. The upload filter, the fetch filter, and the
418
notification filter are all irrelevant here because the image processing
419
library makes its own HTTP requests that none of those filters touch.</p>
420
<p>The impact was more constrained than the first finding. Responses are
421
rendered as pixel data rather than returned as raw text, so text-based
422
secrets (like AWS credentials in JSON format) are not directly readable.
423
But image-format responses from internal services (monitoring dashboards,
424
status pages, cached graphics) would be fully exfiltrated. And the timing
425
differential still enables internal network mapping and port scanning.</p>
426
<p>I rated this as Medium severity. Confirmed external URL resolution with
427
content rendered into output, confirmed internal IP connection attempts via
428
timing analysis, but limited by the pixel-rendering constraint on data
429
extraction.</p>
430
<p>For anyone defending against this class of vulnerability, the fix for the
431
image processing path is well-documented and straightforward. Most image
432
processing libraries support policy configuration that disables external
433
URL resolution entirely:</p>
434
<pre><code class="language-xml">&lt;!-- Restrict the image processing library from resolving external URLs --&gt;
435
&lt;policy domain=&quot;coder&quot; rights=&quot;none&quot; pattern=&quot;URL&quot; /&gt;
436
&lt;policy domain=&quot;coder&quot; rights=&quot;none&quot; pattern=&quot;HTTPS&quot; /&gt;
437
&lt;policy domain=&quot;coder&quot; rights=&quot;none&quot; pattern=&quot;HTTP&quot; /&gt;
438
</code></pre>
439
<p>For the webhook/callback path, the remediation is to apply the same IP/URL
440
validation that already exists on the upload path to every other parameter
441
that triggers outbound requests. Block RFC1918, loopback, link-local, and
442
validate resolved DNS before making callbacks to prevent DNS rebinding.</p>
443
<p>These are not novel recommendations. The fact that they need to be applied
444
separately to each code path is exactly why these gaps exist in the first
445
place.</p>
446
<h2>The Systemic Pattern</h2>
447
<p>What makes these two findings interesting together is the pattern they
448
reveal. The platform had invested in SSRF protections, and those protections
449
worked correctly where they were applied. The problem was coverage.</p>
450
<p>There were at least four separate code paths that cause the server to make
451
outbound HTTP requests:</p>
452
<ol>
453
<li>The file upload/fetch path (protected)</li>
454
<li>The notification/webhook callback path (unprotected)</li>
455
<li>The image processing/rendering path (unprotected)</li>
456
<li>Other async processing paths (untested but likely similar)</li>
457
</ol>
458
<p>Each of these was probably built by a different team or at a different time.
459
The team that built the upload path thought about SSRF and implemented
460
filtering. The teams that built the notification system and the image
461
processing pipeline either did not think about SSRF or assumed it was
462
handled elsewhere.</p>
463
<p>This is the pattern I see most often in mature platforms. The vulnerability
464
is not that they do not know about SSRF. It is that their SSRF defenses are
465
applied per-feature rather than per-network-boundary. Every outbound HTTP
466
request from the server should pass through the same validation layer,
467
regardless of which feature triggered it.</p>
468
<h2>What I Learned</h2>
469
<p><strong>Map the full request surface, not just the obvious inputs.</strong> The most
470
interesting SSRF vectors are rarely in the parameter named &ldquo;url.&rdquo; They are
471
in webhook callbacks, file format parsers, async job configurations, and
472
anywhere else the server might make an outbound request as a side effect.</p>
473
<p><strong>Timing analysis is underrated.</strong> When you cannot see the response body
474
(blind SSRF), response timing becomes your primary signal. A 12-second
475
timeout versus a sub-second response tells you everything you need to know
476
about whether a connection attempt was made.</p>
477
<p><strong>Compare security controls across features.</strong> If one parameter blocks
478
internal IPs and another parameter in the same API does not, that is not
479
just a bug. It is evidence of a systemic gap in how security controls are
480
applied. Document the comparison explicitly in your report because it makes
481
the remediation path obvious.</p>
482
<p><strong>Think about chaining, not just individual findings.</strong> A blind SSRF by
483
itself might be Medium. A blind SSRF with attacker-controlled POST body
484
fields targeting internal services with no authentication is High. Context
485
matters more than the individual primitive.</p>
486
<p><strong>Write clean reports.</strong> I spent almost as much time on the reports as I did
487
on the research. Clear reproduction steps, evidence tables, severity
488
justification, and specific remediation recommendations. Triagers are busy.
489
Make their job easy and your findings get taken seriously.</p>
490
<h2>Growth Trajectory</h2>
491
<p>Six months ago I was running automated scanners and hoping something
492
interesting would fall out. Today I am reading RFC specifications, studying
493
image processing library internals, and mapping application architecture
494
before I test a single input.</p>
495
<p>The shift from tool-driven recon to methodology-driven auditing is where the
496
real growth happens in bug bounty. Tools find the easy stuff. Understanding
497
how systems are built, where trust boundaries break down, and how security
498
controls fail to scale across features is what finds the stuff that matters.</p>
499
<p>I am still early in this journey. But findings like these, where I can
500
identify a systemic pattern across multiple code paths in a production
501
platform used by thousands of companies, tell me I am heading in the right
502
direction.</p>
503
<p>If you are getting into bug bounty or offensive security research, my advice
504
is simple: stop looking for bugs and start understanding systems. The bugs
505
will find you.</p>
506
<hr />
507
<p><em>All research was conducted through authorized bug bounty programs with
508
responsible disclosure. No customer data was accessed or exfiltrated. Test
509
artifacts were cleaned up after submission.</em></p>
510
<hr><p style="color:var(--faint);font-size:12.5px;font-family:ui-monospace,Menlo,monospace">Source &middot; github.com/zionboggan/security-research-notebook &middot; writeups/ssrf-via-image-pipeline.md</p>
511
</div></div></section>
512
<footer><div class="wrap row">
513
  <div class="links"><a href="/">Portfolio</a><a href="https://www.linkedin.com/in/zion-boggan">LinkedIn</a><a href="/security-research-notebook/">Notebook</a><a href="mailto:zionboggan0@gmail.com">Email</a></div>
514
  <div class="note">Coordinated-disclosure research. Findings appear here only after the program's disclosure window closed, the patch shipped, or a CVE was published. No customer data was accessed.</div>
515
</div></footer>
516
</body></html>