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:
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.