CTF WriteUp: 33C3/list0r

CTF WriteUp: 33C3 CTF 2016 / list0r / web / 400 points

We are presented with a basic Bootstrap/PHP CTF page. It allows to create TODO lists and also to set your own profile. The interesting part here is that in the profile you can give a link to a picture which the server will then fetch. This will be useful later on. First thing we notice is that the pages are of the form:

http://78.46.224.80/?page=list

This allows for a classic PHP attack to leak the source by running the following query

http://78.46.224.80/?page=php://filter/convert.base64-encode/resource=list

This allowed us to dump the full source.

Finding the flag

After dumping the full source the most interesting file is functions.php which contains most of the logic regarding database access (which is Redis in this case) and the aforementioned picture fetching logic.

An interesting bit is the login password verification logic:

<?php
function verify_password($username, $password) {
    global $redis;

    $user_id = $redis->hget("users", $username);
    if ($user_id) {
        $real_pass = $redis->hget("user:$user_id", "password");
        return $user_id;
    }
    return FALSE;
}

We see that the password is actually never verified (this was probably not intended but here we go). Logging in with username admin and password foo gives access to the Admin account. Which presents an interesting note:

4	cool flag	i love flags. keep my favorite one at '/reeeaally/reallyy/c00l/and_aw3sme_flag'

So now we know where the flag is. However, querying the url gives:

403 - Sorry, but this is only accessible from 127.0.0.1

Sadface, so how do we get it?

Extracting the flag

As mentioned in the beginning, there is a feature to load a profile picture from a remote address. Trying:

http://localhost/reeeaally/reallyy/c00l/and_aw3sme_flag

gives

That IP is a blacklisted cidr (127.0.0.1/24)!

So there is a blacklist, which is implemented by the following function (shortened for brevity):

<?php
function get_contents($url) {
    $disallowed_cidrs = [ "127.0.0.1/24", "169.254.0.0/16", "0.0.0.0/8" ];

    $url_parts = parse_url($url);
    ...
    $host = $url_parts["host"]; // [3]

    if (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
        $ip = $host;
    } else {
        $ip = dns_get_record($host, DNS_A);
        $ip = $ip[0]["ip"]; // [3]
        ... // result checking here
    }

    // [1] disallowed IP checks here
    foreach ($disallowed_cidrs as $cidr) {
        if (in_cidr($cidr, $ip)) {
            die("<p><h3 style=color:red>That IP is a blacklisted cidr ({$cidr})!</h3></p>");
        }
    }

    $curl = curl_init();
    ... // some unimportant curl opts here 
    // [2] fix resolve 
    curl_setopt($curl, CURLOPT_RESOLVE, array($host.":".$ip)); // no dns rebinding plzzz

    $data = curl_exec($curl);

    // check for redirects
    $status = curl_getinfo($curl, CURLINFO_HTTP_CODE);

    ... // redirect checks here

We see that it basically works by first resolving the host, then checking for local ips and finally query the url with a fixed resolve for the given host. The whole thing is also contained in a loop to work for redirects.

I first tried to attack the DNS resolution by trying stuff with CNAMEs but they get resolved immediately so no success there.

Another thing struck my eye. Everybody that has used the curl --resolve option knows that it has the weird --resolve=example.com:80:127.0.0.1 syntax with the port in the middle. However, in [2] we just concatenate $host and $ip. What is host? As the name suggests it is in fact only the host and doesn’t contain the port which means that the entry we insert in the curl cache is actually an incorrect one.

// no dns rebinding plzzz // Try harder next time! 

So how can we exploit this DNS rebinding? Changing the IP for a record between those calls would be pretty hard to race. Instead, we simply create two records for our test domain, one with 127.0.0.1 and one with a different IP. Our DNS server will then load balance the records and the first record in the DNS reply will then change on a per request basis. As the verification logic only checks the first entry ([3]) we can exploit this.

As a result, if in the first DNS query the first result is the IP of a different host, we will pass the checks. In the second query, if we hit the localhost one, then we can successfully load the flag. There is another part of the challenge that we need. If in the profile loading process the queried object is not an object it will just print the contents of the queried file. This means that when it fetches the flag it will just display it.

With the following DNS settings:

DNS

and the url:

http://ctf.example.com/reeeaally/reallyy/c00l/and_aw3sme_flag

we get the flag after a few tries:

33C3_w0w_is_th3r3_anything_that_php_actually_gets_right?!???

This was a pretty cool challenge. I am not sure whether this was the intended solution as I don’t understand the flag text but it definitely works.