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:

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 classWhat it gives the fileFiles
ScriptingA <script> element, including namespaced and nested spellings9
Event handlersNine handler attributes across six host elements54
Embedded contentForeign markup, frames, plugin and media elements9
External referencesRemote targets that fetch when the drawing is displayed12
URI schemesScript and document URIs in navigable positions6
Animation injectionAnimation elements that rewrite an attribute after load6
XML layerEntity declarations for file read and expansion4
Total100

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:

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

CheckAcceptedDiscriminating power
Extension check100 / 100None. Every file is named .svg.
Content-type check100 / 100None. The declared type is attacker-controlled.
Render check95 / 100Near 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 classFilesNeutralised, first passSurvived
Scripting990
Event handlers54540
Embedded content990
External references12111
URI schemes660
Animation injection660
XML layer440
Total100991

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 gapResult
Presentation attributes that carried a remote reference through11 of 14
URL spellings affectedAll tested
Stylesheets and link attributesAlready 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 classFilesNeutralised
Scripting99
Event handlers5454
Embedded content99
External references1212
URI schemes66
Animation injection66
XML layer44
Total100100

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:

Withheld

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.