Abstract
An SVG is not a bitmap. It is an XML document that a browser parses, and the specification lets that document run script, install event handlers, embed foreign markup, and fetch remote resources. The file extension and the declared content type both say "image", which is exactly why SVG is the format most often accepted by upload endpoints that are not prepared for it.
We generated a synthetic corpus of 112 SVG files: 100 exercising seven classes of capability that let a drawing execute code or reach the network, and 12 benign controls covering ordinary artwork. We put the hostile set through three checks that upload endpoints commonly deploy, and then through an allowlist rebuild. All three naive checks accepted all 100 files. 95 of the 100 rendered as a perfectly valid image. The rebuild neutralised 99 of 100 on the first pass.
The single survivor is the most useful result in this study. It was not a gap in the general approach but a gap in our own implementation of it, and finding it is the reason we run these corpora at all. It is described in full below, along with the fix and the re-measurement.
Background
Most upload handling is built around raster images. A JPEG or a PNG is a container of pixels: a decoder reads it, produces a bitmap, and nothing in the file asks the host application to do anything else. Treating those formats as inert data is broadly correct.
SVG breaks that assumption because it is a document format that happens to describe pictures. The same parser that draws a rectangle also honours a <script> element, an onload attribute, a <foreignObject> containing HTML, and a reference to a resource on another host. None of that is a bug in SVG. It is the feature set, and it is why an SVG upload is closer to an HTML upload than to a photo upload.
What makes this operationally awkward is that the signals an endpoint usually trusts do not separate the two cases. A file carrying a script and a file carrying a logo have the same extension, declare the same content type, and both parse as valid SVG.
The Challenge
Two questions decide how exposed an endpoint that accepts SVG really is:
- Do the deployed checks discriminate? Given a file that carries active content, do extension checks, content-type checks, or a "does this actually render as an image" check reject it?
- Does removal cost fidelity? If capability is stripped out, does the artwork still look the same? A defence that visibly damages legitimate files does not survive contact with users.
Method
We wrote a generator that emits SVG files programmatically. Every input in this study came out of that generator. Nothing was collected, sampled, or derived from anyone's uploads.
The hostile set covers seven capability classes. Within each class we varied spelling, casing, namespace prefix, nesting depth, and which element carried the payload, because a defence that matches on one literal spelling tends to miss the others:
| Capability class | What it gives the file | Files |
|---|---|---|
| Scripting | A <script> element, including namespaced and nested spellings | 9 |
| Event handlers | Nine handler attributes across six host elements | 54 |
| Embedded content | Foreign markup, frames, plugin and media elements | 9 |
| External references | Remote targets that fetch when the drawing is displayed | 12 |
| URI schemes | Script and document URIs in navigable positions | 6 |
| Animation injection | Animation elements that rewrite an attribute after load | 6 |
| XML layer | Entity declarations for file read and expansion | 4 |
| Total | 100 |
The 12 benign controls cover shapes, paths, text, linear and radial gradients, filters, declarative animation, transforms, clipping and masking, internal symbol reuse, an inline stylesheet, an embedded raster image, and markers.
Against the hostile set we measured three checks:
- Extension check. Is the filename an accepted image extension?
- Content-type check. Does the declared type match an accepted image type?
- Render check. Does a real renderer parse the file and produce an image? This is the strongest of the three, and the one people reach for when they want to be sure a file "really is" an image.
We then rebuilt every file from an allowlist: parse the document, keep only known-safe elements and attributes, and discard everything else rather than trying to detect and delete the bad parts. For the benign controls we rasterised each file before and after the rebuild at identical dimensions and compared the output pixel by pixel.
Results
Naive checks do not discriminate at all
| Check | Accepted | Discriminating power |
|---|---|---|
| Extension check | 100 / 100 | None. Every file is named .svg. |
| Content-type check | 100 / 100 | None. The declared type is attacker-controlled. |
| Render check | 95 / 100 | Near none. Active content does not stop a file from drawing. |
The render check is worth dwelling on, because it feels like a real defence. The reasoning goes: if a renderer can parse this and produce a picture, it is a genuine image. The reasoning is sound and the conclusion is still wrong. Carrying a script does not prevent an SVG from also describing a valid drawing, so 95 of our 100 hostile files passed. The five that did not were the malformed XML cases, which failed for being broken rather than for being hostile.
The lesson is that none of these checks are measuring capability. They are measuring shape, type, and parseability, and a capable file satisfies all three.
The allowlist rebuild, and the one file that got through
| Capability class | Files | Neutralised, first pass | Survived |
|---|---|---|---|
| Scripting | 9 | 9 | 0 |
| Event handlers | 54 | 54 | 0 |
| Embedded content | 9 | 9 | 0 |
| External references | 12 | 11 | 1 |
| URI schemes | 6 | 6 | 0 |
| Animation injection | 6 | 6 | 0 |
| XML layer | 4 | 4 | 0 |
| Total | 100 | 99 | 1 |
One file in the external-reference class came out the other side with its remote target intact. The rebuild was handling references in the places references normally live, which is a link attribute or a stylesheet. It was not handling a reference expressed inside a paint attribute value, where a fill or a stroke or a filter names its target with a url() reference. Those attributes are legitimately on the allowlist, because artwork needs them to point at gradients and patterns defined inside the same file. Their values were being carried through as written.
The consequence. A drawing could name a remote host in a paint attribute and reach it at render time, which is the same outcome as an embedded tracking pixel: it discloses that the file was opened, and it discloses the address of the viewer to whoever controls that host.
Having found one instance, we measured how wide it was rather than patching the single case. We tested every presentation attribute that accepts a url() reference, in every URL spelling we could construct:
| Scope of the gap | Result |
|---|---|
| Presentation attributes that carried a remote reference through | 11 of 14 |
| URL spellings affected | All tested |
| Stylesheets and link attributes | Already handled |
The fix applies one rule uniformly instead of maintaining a list of attributes to remember: any attribute value that contains a reference is resolved against a same-document allowlist, and a reference that points anywhere other than inside the file itself is replaced with a value that paints nothing. This is deliberately general, so an attribute nobody thought of is covered by default rather than by having been listed.
After the fix we re-ran the entire corpus:
| Capability class | Files | Neutralised |
|---|---|---|
| Scripting | 9 | 9 |
| Event handlers | 54 | 54 |
| Embedded content | 9 | 9 |
| External references | 12 | 12 |
| URI schemes | 6 | 6 |
| Animation injection | 6 | 6 |
| XML layer | 4 | 4 |
| Total | 100 | 100 |
Fidelity: removal did not cost anything visible
A defence that quietly degrades legitimate files gets switched off, so the benign controls matter as much as the hostile set. Of the 12 controls, 11 rasterised to output that was identical to the original at the pixel level, with a mean absolute difference of zero across all channels. Internal references were the case we watched most closely, since the fix above operates on exactly the syntax that gradients, patterns, filters, masks, clipping paths and markers use to find their targets. All six of those reference types survived intact and reported clean.
The twelfth control, an SVG wrapping an embedded raster image, could not be rasterised by our measurement harness. We verified that it fails to rasterise identically before and after the rebuild, so this is a limitation of the harness rather than a change introduced by the rebuild; the file itself came through structurally unchanged with its embedded image intact.
Why the Weak Checks Are Popular
All three of the checks we measured share a shape: they ask a question about the file's identity rather than about its capability. Is it named like an image, does it claim to be an image, does it behave like an image when drawn. Every one of those questions has a yes answer for a file that also carries a script, because carrying a script is not mutually exclusive with being a valid drawing.
Capability is a property of what the document is allowed to do, and the only way to bound it is to decide what the document is permitted to contain. That is the difference between filtering and rebuilding. Filtering starts from the file and tries to enumerate what is dangerous, which means every spelling you did not think of is a bypass. Rebuilding starts from an empty document and adds back only what is known to be safe, which means anything you did not think of is absent by default.
Our own gap is a fair illustration of the difference. The rebuild was already correct in its architecture; what it got wrong was treating a class of attribute values as opaque strings instead of as references. The failure was contained to one capability class out of seven, and the corrective action was to generalise the rule rather than add a case.
Implications for PrivConvert
PrivConvert accepts SVG on several tools, and exposes the rebuild directly as a sanitiser. Three things follow from this study:
- The rebuild reports what it removed. A sanitiser that silently returns a file leaves the caller unable to distinguish "this was already clean" from "this had five handlers stripped out of it". Our sanitise responses carry a summary of what was taken out, so an integration can log, alert, or reject based on it.
- Corpora are a standing test, not a one-off. This gap was found by generating variants rather than by reading the code, and it existed in an implementation that already handled the six other capability classes correctly. The corpus now runs as a regression test.
- Serving matters as much as storing. Rebuilding a file bounds what the file can do. It does not change the fact that where and how a document is served decides what it is able to affect, which is the part an application owns.
The corpus generator, the full allowlist, and the internal reporting format are not published. The findings above are complete as stated; the implementation that produces them is not disclosed.
Limitations
A generated corpus is not a census. It covers capability classes that are documented behaviour of the format, and variants we thought to construct. It cannot demonstrate the absence of a class nobody has described, which is the reason the corpus is treated as a standing regression test rather than a certificate.
This study measures what a document is able to express, not what any particular renderer will do with it. Whether a given capability activates depends on the version, the configuration, and above all the context a file is displayed in, and we did not enumerate browser behaviour. The rebuild is therefore a control on what leaves our service, and it is not a claim about every consumer downstream of it.
The fidelity measurement compares rasterised output at a fixed size on a single renderer. It is good evidence that ordinary artwork is preserved and it is not a guarantee for every document a renderer might disagree about.
Test data statement. All 112 files in this study were produced by a generator written for it, and every measurement ran in an isolated environment. We did not observe, sample, or analyse any real upload, file, or person. By design the files converted on our services are not retained, so there is nothing for us to study in the first place.