As part of Canva's ongoing mission to build the world's most trusted platform, we continuously evaluate the security of our software dependencies. Identifying and resolving vulnerabilities in third-party dependencies helps improve the security of Canva, as well as the wider internet. Coupled with security controls like sandboxing, we continue to make it increasingly difficult for attackers to reach their objectives by exploiting third-party dependencies.
One such dependency Canva uses is librsvg (via libvips). We use librsvg to quickly render user-provided SVGs into thumbnails later displayed as PNGs. By exploiting differences in URL parsers when rendering an SVG with librsvg, we showed it's possible to include arbitrary files from disk in the resulting image. The librsvg maintainers quickly patched the issue and issued a security vulnerability (CVE-2023-38633).
We're sharing this research as another example of the dangers of mixing URL parsers, especially because the example we discovered is very subtle.
A special thanks to Federico (librsvg maintainer), John (libvips maintainer), and Lovell (Sharp maintainer) for their work and excellent coordinated response.
The XML Parsing Issues in Inkscape in CLI write-up from Elttam's Victor Kahan shows how Inkscape is vulnerable to path traversal when rendering SVGs. Extending Victor's research, we found that while XInclude wasn't directly supported in Inkscape 0.9, it exhibited some interesting behavior when an SVG was nested in another SVG.
For example, consider the following inner SVG.
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="300" height="300" xmlns:xi="http://www.w3.org/2001/XInclude">
<rect width="300" height="300" style="fill:rgb(255,204,204);" />
<text x="0" y="100">
<xi:include href="/etc/passwd" parse="text" encoding="ASCII">
<xi:fallback>file not found</xi:fallback>
</xi:include>
</text>
</svg>
We encoded it as a URI and placed it inside another SVG, outer.svg
.
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="300" height="300" xmlns:xlink="http://www.w3.org/1999/xlink">
<image xlink:href="data:image/svg;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+Cjxzdmcgd2lkdGg9IjMwMCIgaGVpZ2h0PSIzMDAiIHhtbG5zOnhpPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hJbmNsdWRlIj4KICA8cmVjdCB3aWR0aD0iMzAwIiBoZWlnaHQ9IjMwMCIgc3R5bGU9ImZpbGw6cmdiKDI1NSwyMDQsMjA0KTsiIC8+CiAgPHRleHQgeD0iMCIgeT0iMTAwIj4KICAgIDx4aTppbmNsdWRlIGhyZWY9Ii9ldGMvcGFzc3dkIiBwYXJzZT0idGV4dCIgZW5jb2Rpbmc9IkFTQ0lJIj4KICAgICAgPHhpOmZhbGxiYWNrPmZpbGUgbm90IGZvdW5kPC94aTpmYWxsYmFjaz4KICAgIDwveGk6aW5jbHVkZT4KICA8L3RleHQ+Cjwvc3ZnPg==" />
</svg>
When run with Inkscape 0.92.4, it produced an image where the XInclude fallback was triggered.
$ inkscape -f test.svg -e out.png -w 300
The fact that Xinclude was supported at all was surprising because it can often lead to security vulnerabilities. While Inkscape isn't used by the Canva product, digging into the Inkscape code path showed that nested images are loaded with GdkPixbuf, which itself delegates SVG loading to librsvg. This was of great interest because librsvg is something that Canva does use.
XInclude is a mechanism for merging XML documents, which can lead to security vulnerabilities when a user-provided XML document (like an SVG) is assembled or rendered on a server.
There are two standout elements in XInclude:
xi:include
to include contents of a referenced URL, such as a file or HTTP request. The content being included can be plaintext or XML.xi:fallback
to nominate content that should be rendered when the referenced content can't be loaded by xi:Include
.Security checks aside, the following XML document loads the contents of /etc/passwd
when processed.
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<example xmlns:xi="http://www.w3.org/2001/XInclude">
<xi:include href="/etc/passwd" parse="text" encoding="ASCII">
<xi:fallback>file not found</xi:fallback>
</xi:include>
</example>
librsvg is a Rust library to render SVG images to Cairo surfaces. Most of the heavy lifting is now implemented in Rust, but the library relies on the Cairo and GNOME glib C libraries.
From the prequel, we knew librsvg supported at least some of the XInclude standards. To understand how much, we dug into its implementation. As it turns out, every external URL reference in an SVG passes through a single method for validation. This includes references such as:
<image href="file:///something.png" />
<rect filter="url('file-with-filters.svg#my_filter')" />
<xi:include href="/etc/passwd" ... />
The librsvg url_resolver.resolve_href method implements some strict security checks to restrict what references can be loaded when processing an SVG document:
data:
URLs are permitted because they can't reference external files by design.file:///foo/bar/example.svg
, any encountered URL must be of the file:
scheme.http:
.These strict rules are the reason our earlier naive XInclude tests failed. But we were very interested to see if we could bypass the rules. This could result in path traversal when processing an SVG, for example, being able to include files like /etc/passwd
in the contents of the rendered SVG to PNG.
Resolving a URL within an SVG document has two steps:
Excerpts from mod.rs and io.rs are as follows.
fn acquire(&self, href: Option<&str>, ) -> Result<(), AcquireError> {
let aurl = self.url_resolver.resolve_href(href)
self.acquire_text(&aurl, encoding);
}
fn acquire_text(&self, aurl: &AllowedUrl, encoding: Option<&str>) -> Result<(), AcquireError> {
let binary = io::acquire_data(aurl, None);
return result;
}
pub fn acquire_data(aurl: &AllowedUrl, ) -> Result<BinaryData, IoError> {
let uri = aurl.as_str();
let file = GFile::for_uri(uri);
let (contents, _etag) = file.load_contents(cancellable)?;
return contents;
}
Knowing there were two URL parsers at play (one to validate the URL and one to load the contents), to bypass the security checks, we needed to find URLs where the parsers disagreed.
With some quick tests, we mapped out how the URL parsers process different URLs.
Gio doesn't expose generic URL parsing (aside from GUri, which isn't on the callpath). But g_filename_from_uri
returns on some examples.
Given this understanding of where the URL parsers were at, we took the relevant parts from librsvg and set up a fuzzing harness ("resolve") to run the same logic as the resolve URL logic when encountering a reference (href, XInclude, etc.) from an on-disk "current.svg" file. This allowed us to quickly test and fuzz inputs to see how the parsers and validation logic were evaluated. Some interesting outputs from fuzzing were as follows:
resolve 'current.svg'
: Passes as expected.resolve run '../../../../../../../etc/passwd'
: Canonicalization fails with 'No such file or directory'.resolve 'current.svg?../../../../../../../etc/passwd'
: Passes.resolve 'none/../current.svg'
: Passes as expected.The last 2 results showed us that GFile::for_uri
happily resolves path traversals, including path traversals in the query string. However, the second result, ../../../../../../../etc/passwd
, failed because of the canonicalization check.
Part of librsvg's URL validation is to canonicalize the built URL to replace .. and . segments per regular filesystem path rules. It does this using Rust's std::fs::canonicalize (calling realpath), which throws an error if:
Because we don't always know the name of the 'current' SVG on disk, we needed to bypass canonicalization if we wanted to have a URL pass librsvg's validation. After some rapid testing, it turns out this is relatively straightforward.
$ realpath current.svg
/home/zsims/projects/librsvg-poc/current.svg
$ realpath .
/home/zsims/projects/librsvg-poc/
As it turns out, realpath(".")
and std::fs::canonicalize(".")
both return the "current directory". We can use this as a placeholder in our PoC instead of current.svg
.
Knowing how the URL parsers mismatch, and how we can bypass canonicalization without knowing the current file name, we can build a payload to include /etc/passwd
.
.?../../../../../../../etc/passwd
Within a poc.svg
SVG, this looks like the following.
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="300" height="300" xmlns:xi="http://www.w3.org/2001/XInclude">
<rect width="300" height="300" style="fill:rgb(255,204,204);" />
<text x="0" y="100">
<xi:include href=".?../../../../../../../etc/passwd" parse="text" encoding="ASCII">
<xi:fallback>file not found</xi:fallback>
</xi:include>
</text>
</svg>
And produces the following result.
$ rsvg-convert poc.svg > poc.png
We get a similar result when running through vipsthumbnail.
Sensitive files within /proc
, such as /proc/self/environ
, failed because of character encoding.
$ rsvg-convert proc-poc.svg > proc-poc.png
thread 'main' panicked at 'str::ToGlibPtr<*const c_char>: unexpected '' character: NulError(21...
Note that this PoC only works where the SVG is loaded from a file://
. SVGs loaded through data:
or resource:
schemes are not vulnerable.
Following the report to librsvg's maintainer (see Issue 996), Federico patched the issue to add improved URL validation and use the validated URL as input into GFile. Part of the response from Federico included a heads-up to maintainers of Sharp and libvips to upgrade before the issue was disclosed publicly as CVE-2023-38633.
The issue also prompted some discussion about file URL parsing in glib, resulting in some additional validation in the library.
There were a few standouts from discovery and patching:
file://
URLs and in-process use as they are for networked services and http://
URLs.file://
URLs are special. For example, the URL specification highlights support for query strings in file://
URLs, but the support in the implementations we reviewed varied greatly.xi:include
elements are rejected before the SVG reaches librsvg.Interested in securing Canva systems? Join Us!
By submitting this form, you agree to receive Canva Engineering Blog updates. Read our Privacy Policy.