Advent ctf 2019 overthewire - day2 writeup

The advent ctf organized by overthewire proposed various challenges that would unlock on a daily basis (like an advent calendar). I found day number 2 (made by hpmv) quite challenging and super fun to solve! It involved crypto, network and rev in a blackbox environment.

The full source code used to solve this challenge is available here https://github.com/majin42/adventctf_otw_day2

Challenge analysis

An elf is tired of snow and wanted to visit summer... in an online RPG at least. Could you help him beat the game? Service: http://3.93.128.89:12020 Extra clarification, please read this: https://docs.google.com/document/d/1wYlM2ideh5R5I7KDTLFTu_NLQmAJAmV-hVjNlmAOIEY If you connect to 12022 to create a fake server, see nothing but a server ID, and use that fake server to login on the UI but the UI does nothing, this is intended behavior. After seeing the server ID, the TCP connection to 12022 serves as the games connection, and you must act as the real server at that point. There is no default implementation for the fake server; you must do everything the client expects from the server in order for it to work.

By accessing http://3.93.128.89:12020 we are given more indications about the challenge's setup:

day2 main screen

Using the connect to real server button yields a login page:

day2 login

After registering an account and login, we are presented with a classic, minimalistic, top-down RPG. We can move around the map, fight mobs by stepping on them, earn money and experience etc...

day2 game

We can store 32 items in a stash for later retrieval

day2 stash

And we can buy and sell items.

  • some items improve your monster killing potential by having them in your equipment (clubs, swords ...)
  • others can be used manually (the potion restores all your hp)

day2 shop

We can already assume that the goal is to obtain the Scroll of Secrets in order to retrieve the flag (and if it's not the case, possessing unlimited power is also cool I guess)


Returning to the main screen, what we immediately notice is that the game can connect to a Fake server.

By establishing a TCP connection on the server to port 12022 we receive a message containing an ID:

Server ID: Qm9+F6TH2MpeoIpwB9LNFtoKba8jokkN
Enter this ID on the game client to connect to this custom server.

Once we use the connect to a Fake server feature with this ID, the game client reads and writes using our our freshly opened TCP stream.

We can place ourselves as a man in the middle between the game client and the server:

use std::error::Error;
use std::io::{Read, Write};
use std::net::TcpStream;
use std::str::from_utf8;
use std::thread;
use std::thread::{JoinHandle};

fn create_thread(name: &'static str, mut read_stream: TcpStream, mut write_stream: TcpStream) -> Result<JoinHandle<()>, Box<dyn Error>> {
    let handle = thread::spawn(move || {

        println!("[+] {} thread started", name);
        let mut data = [0 as u8; 2048];

        while match read_stream.read(&mut data) {
            Ok(size) => {
                if size == 0 {
                    println!("[!] {} received size 0, exiting", name);
                    return;
                }

                println!("[*] {} sends {} bytes", name, size);
                hexdump::hexdump(&data[..size]);
                write_stream.write_all(&data[..size]).unwrap();

                true
            }
            Err(e) => {
                println!("[!] {}: failed to receive data: {}", name, e);
                false
            }
        } {}
    });

    Ok(handle)
}

fn adventure_game() -> Result<(), Box<dyn Error>> {
    let stream_srv = TcpStream::connect("3.93.128.89:12021")?;
    println!("[+] connected to server 3.93.128.89:12021");
    let mut stream_client = TcpStream::connect("3.93.128.89:12022")?;
    println!("[+] connected to client 3.93.128.89:12022");

    let stream_srv_clone = stream_srv.try_clone()?;
    let stream_client_clone = stream_client.try_clone()?;

    // read from the fake server to retrieve the id
    let mut initial_buffer = [0 as u8; 2048];
    stream_client.read(&mut initial_buffer)?;
    let id_response = from_utf8(&initial_buffer).unwrap();
    println!("{}", id_response);

    println!("[*] launching threads");
    // read from the server, write to the client
    let handle_srv = create_thread("server", stream_srv, stream_client)?;
    // and vice versa
    let handle_client = create_thread("client", stream_client_clone, stream_srv_clone)?;

    handle_srv.join().unwrap();
    handle_client.join().unwrap();
    Ok(())
}

fn main() -> Result<(), Box<dyn Error>> {
    adventure_game()?;
    Ok(())
}

Using this simple rust program, we successfully read communications between the client and the server.

cargo run

[+] connected to server 3.93.128.89:12021
[+] connected to client 3.93.128.89:12022

Server ID: tq2ieraN9kuxa9ahOa2/P/CfA8+4pGLA
Enter this ID on the game client to connect to this custom server.

[*] launching threads
[+] server thread started
[+] client thread started

[*] server sends 16 bytes
|3f8315bd 4fc589b2 6accb6b0 de6ff93e| ?...O...j....o.> 00000000
                                                       00000010

// login request
// username: AAAA
// password: AAAA
[*] client sends 2 bytes
|c80f|                                ..               00000000
                                                       00000002

[*] client sends 14 bytes
|64dccdb0 852e9b27 5b834cfe 78ca|     d......'[.L.x.   00000000
                                                       0000000e

[*] server sends 2 bytes
|760b|                                v.               00000000
                                                       00000002

[*] server sends 1200 bytes
|7c7dcebc e55ef44b 489f49bc 1a86a557| |}...^.KH.I....W 00000000
|7b4de5be 65d75748 000bdb27 ff7f0a5e| {M..e.WH...'...^ 00000010
|72e2b19c b8b67302 a665894e 2b93a824| r.....s..e.N+..$ 00000020
|03f89b2f 4678fcdd edd314d2 3c51f887| .../Fx......<Q.. 00000030
|18870f8e d624fa62 27df5a1e cd811628| .....$.b'.Z....( 00000040
|8c2d78db 7cc0dc0f 253679c1 5edcf969| .-x.|...%6y.^..i 00000050
|8b381772 7fc453cb a137f859 96029c88| .8.r..S..7.Y.... 00000060
|11558857 d6a59734 ebd8643e 2eb2ca13| .U.W...4..d>.... 00000070
|33c73fae ca3d420c 8bbe93bd 4f684dfc| 3.?..=B.....OhM. 00000080
|29641ec1 13b67c1c a9bb01d8 fcb2bdc6| )d....|......... 00000090
|43cf62e6 a89a9067 91303154 31e69248| C.b....g.01T1..H 000000a0
|81436c33 27143e51 c3fb0283 1563a770| .Cl3'.>Q.....c.p 000000b0
|8f1d5999 24c04bbf 7d42b695 e2cab7be| ..Y.$.K.}B...... 000000c0
|ff75089d 4423e153 360faef0 944dcbe1| .u..D#.S6....M.. 000000d0
|0d6b7bea 1d0b7c2b a0cfa489 6d1b9a17| .k{...|+....m... 000000e0
|559bda52 8e474b44 6c7a3e20 76820411| U..R.GKDlz> v... 000000f0
|17b1c8f6 1308ba22 378eb5fe 72dac09e| ......."7...r... 00000100
|b34b975b 0e35baa7 c2965258 16e8604a| .K.[.5....RX..`J 00000110
|5e76293d c3f18d63 a15c006a 45dd0bd9| ^v)=...c.\.jE... 00000120
|baa586da 1763acd5 c090072a 0d7fb5f2| .....c.....*.... 00000130
|68500fe4 39efea19 c756b567 aa3351fc| hP..9....V.g.3Q. 00000140
|86d5eaba ee2f150f bfb42644 728f13b5| ...../....&Dr... 00000150
|f115e59a bdc2d87f bdb7e90e aa58b77d| .............X.} 00000160
|4f7b1228 af64f67d 04f53685 a4dc64e2| O{.(.d.}..6...d. 00000170
|21e51509 364549e6 1d06d02a 65a80a1f| !...6EI....*e... 00000180
|aff00f60 0f63bb2b 65a41ca9 0b250680| ...`.c.+e....%.. 00000190
|ea5b819a 1218f5c7 8aa2e854 474e8286| .[.........TGN.. 000001a0
|e258bf2a 5a13b841 7e34d009 67ac44f3| .X.*Z..A~4..g.D. 000001b0
|f6168eee 90ab904f b0147ca4 d07d4e37| .......O..|..}N7 000001c0
|4a3a8431 7c9a2e82 b339e564 afc2115c| J:.1|....9.d...\ 000001d0
|f36f8ae4 73acee1a 5ec27e92 ab2f6a90| .o..s...^.~../j. 000001e0
|9641800b 38d29488 887bb47c 29490fb6| .A..8....{.|)I.. 000001f0
|9771c3d2 6e4e811d 0ebe3613 93980d07| .q..nN....6..... 00000200
|2c3d89eb 912761a1 9cda7d56 bb21b723| ,=...'a...}V.!.# 00000210
|11ac3acf 0c3f3c67 30c738ad 219a3919| ..:..?<g0.8.!.9. 00000220
|9f44d20b 614d1098 36a8b86d 17ea5f86| .D..aM..6..m.._. 00000230
|4528be9d d84d9865 65a5dadb 9f9581fa| E(...M.ee....... 00000240
|70e5a5cc d78329ff c4f1b18c 7db50413| p.....).....}... 00000250
|d192eba4 681aaf2f 45d7da71 73bfd0c5| ....h../E..qs... 00000260
|87e4a25c cd1403ab c7a310c4 9b6af307| ...\.........j.. 00000270
|11ac89ad 250dfec7 b9d342b2 c4e4c10f| ....%.....B..... 00000280
|40b14be5 f22672d4 359c722c 8f9fa80c| @.K..&r.5.r,.... 00000290
|0f94877d 2686d8cc a2ef1fd9 5a29266f| ...}&.......Z)&o 000002a0
|b768a65e 75e94be6 11d7a4cf ac788926| .h.^u.K......x.& 000002b0
|758e3e7e b01ed252 b0c6ef8f 2eee032e| u.>~...R........ 000002c0
|305cebab 582b089d 833de5f2 98be6ee0| 0\..X+...=....n. 000002d0
|747ccc2d 32ed4ca9 5b20df7f bb59e6b3| t|.-2.L.[ ...Y.. 000002e0
|23a85670 88a89db6 a3deb96e 5741c155| #.Vp.......nWA.U 000002f0
|d160d903 822db180 159e0253 cff110e1| .`...-.....S.... 00000300
|98066e54 92a99f3b 77f0dd83 1d5273d6| ..nT...;w....Rs. 00000310
|87fcb55f 801501c1 702e426b 3bc47a79| ..._....p.Bk;.zy 00000320
|2fca1c6f 17927269 d7788dc1 c98c7715| /..o..ri.x....w. 00000330
|7263fd8d 0131c1a4 2292a063 a70c533d| rc...1.."..c..S= 00000340
|e4a923ac 80ea7add 6779ef39 d431fef9| ..#...z.gy.9.1.. 00000350
|9318d812 5f921b48 90fe6bcd 2c04790d| ...._..H..k.,.y. 00000360
|2a3beeb9 65411544 78b740b6 4779722b| *;..eA.Dx.@.Gyr+ 00000370
|9df08b28 1f49ed18 12504a30 0e9efe07| ...(.I...PJ0.... 00000380
|a1094c42 d0e1b6f5 13e47465 ddcacddc| ..LB......te.... 00000390
|8c756220 1b88f5b5 6a0ab730 b9a42f57| .ub ....j..0../W 000003a0
|41caeb26 bb0ab87a ff80853a 29ec3115| A..&...z...:).1. 000003b0
|b58a8200 e8f34325 1df395a9 35fe2ae9| ......C%....5.*. 000003c0
|1adef73f b446f921 d5cf74c2 b55f80b4| ...?.F.!..t.._.. 000003d0
|8de960c2 d3b21010 ead7cb62 9159b8f5| ..`........b.Y.. 000003e0
|bdd9018e e35ede06 2d917faf 278f676b| .....^..-...'.gk 000003f0
|bb7f5162 a219151a 3e23f7ca 711ca6ef| ..Qb....>#..q... 00000400
|96522ae0 5dc2d25a 6e4f70e3 822a2fcb| .R*.]..ZnOp..*/. 00000410
|6396b732 e20949e0 3f581f15 264cfad2| c..2..I.?X..&L.. 00000420
|14bdda69 e3bc20d8 ed69681c cb956002| ...i.. ..ih...`. 00000430
|820c8433 2e9f0e55 54e47011 e7b2cde9| ...3...UT.p..... 00000440
|699b8da7 a782f782 110a626d b81e1cfe| i.........bm.... 00000450
|26f7ddb7 83abe15b c310c0c0 48abe8d1| &......[....H... 00000460
|08614452 c7ff9729 8355a3a2 2b502eb7| .aDR...).U..+P.. 00000470
|432e1ddb df9f90ee 25351ff2 995a3098| C.......%5...Z0. 00000480
|94d56707 f6be13a3 d1ee094e 6893a2a1| ..g........Nh... 00000490
|42ce6685 9fde1cbb 2a35266a 73148981| B.f.....*5&js... 000004a0
                                                       000004b0
...

However, nothing makes sense ... yet !

Understanding communications

crypto

After running this program multiple times with the same login request (username: AAAA, password: AAAA), the resulting bytes exchanged appear to always be different. But a pattern emerges:

  • Upon establishing a connection to the real server, it always sends back 16 bytes
  • From this point every communications (client or server wise) are composed of two messages:
    • The first message is always 2 bytes long. It looks like it might indicate the size of the following message to the recipient.
    • The length of the second message varies. In the case of our login request it is 14 bytes long. Adding characters to our username or password increases its length proportionally.

The first 16 bytes sent to the client really look like an initialization vector or some key. We can hijack this key and replace it with our own data in order to understand how it is being used:

key = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
login: A * 4 pass: A * 4
[*] client sends 2 bytes
|f78c|                                ..               00000000
                                                       00000002
[*] client sends 14 bytes
|71618275 2cbfd3ce ed33b2b2 a3d1|     qa.u,....3....   00000000

After testing several keys and modifying the username and password length, we understand that this is a XOR key that rotates over every communications. But even when using a null key we are not able to find our plain text. Moreover when sending the same login requests successively the client does not produces the same output. This implies that there is something else known to the client and the server that is used to encrypt and decrypt the messages.

Let's launch our little program mutliple times with different usernames and compare:

login: AAAA pass: AAAA
[*] client sends 2 bytes
|f78c|                                ..               00000000
                                                       00000002
[*] client sends 14 bytes
|71618275 2cbfd3ce ed33b2b2 a3d1|     qa.u,....3....   00000000
                                                       0000000e
---------------------------------------------------------------
login: BAAA pass: AAAA
[*] client sends 2 bytes
|f78c|                                ..               00000000
                                                       00000002
[*] client sends 14 bytes
|71618275 2fbfd3ce ed33b2b2 a3d1|     qa.u/....3....   00000000
                                                       0000000e

Changing the first character of the username from A to B only changes the 5th byte, it looks like it is also XOR encrypted:

>>> hex(ord('A') ^ 0x2c)
'0x6d'
>>> hex(ord('B') ^ 0x2f)
'0x6d'

If we knew the whole plaintext, retrieving the key would be one XOR operation away. But we only control parts of it:

first message : [ 2 unkown bytes ]
second message: [ 4 unknown bytes ] + [ username ] + [ 2 unknown bytes ] +  [ password ]

Since sending login attempts through the game client seems to advance in the key (not the same bytes produced for successive attempts), we can fill the unknown gaps by overlapping multiple attempts with different username or password sizes.

For instance we can first try to login multiple times with login = A * 4, password = A * 4 and redo the operation with login = A * 16, password = A * 4. Then we can merge both and retrieve the full plaintext:

day2 keystream

As we suspected, the first message sent indicates the length of the following message, 0x0e == 14 bytes.

Since we know the whole plaintext we can easily retrieve the key! I generated a lot of successive login requests that I XORed with the plaintext and stored the result in a file stream.dat Even after 11kb worth of stream, I was not able to find any repetition in the key which must mean that a keystream is used. Fortunately for us this keystream is also used by the server for encrypting its messages, we are now able to decrypt all communications!

I've implemented the decryption / encryption logic in a MitmClient struct in order to manipulate messages more easily. It holds a client and a server StreamWrapper that are in charge of keeping track of their position in the keystream upon reading or writing.

cf. https://github.com/majin42/adventctf_otw_day2/blob/master/src/lib.rs

message format

It took me longer than it should have but in the end, I found out that all payloads are protobuf messages. If we take a closer look at a raw login message, the protobuf structure reveals itself:

full packet: 12 0c 0a 04 41 41 41 41 12 04 42 42 42 42

# can be split into three parts
prefix  : 12 0c
12 0c -> describes an embedded message at field number 2 of length 12

username: 0a 04 41 41 41 41
0a 04 -> the embedded message's field number 1 is a string element of length 4

password: 12 04 42 42 42 42
12 04 -> the embedded messages's field number 2 is a string element of length 4

Using protoc --decode_raw on the login packet confirms our theory

protoc --decode_raw < login_packet.bin
2 {
  1: "AAAA"
  2: "BBBB"
}

I then went through each possible client actions in the game, testing different values, and reconstructed the protobuf messages as best as I could:

syntax = "proto3";

message Action {

    // 120c0a0441414141120442424242
    message Login {
        string username = 1; // ["AAAA"]
        string password = 2; // ["BBBB"]
    }

    // 1a020802
    message Fight {
        uint64 level = 1; // monster level
    }

    // 220410011801
    message Inventory {
        uint64 retrieve_buy = 1; // 1 == retrieve, 2 == buy
        uint64 store_sell = 2; // 1 == store, 2 == sell
        uint64 inventory_id = 3; // zero based
    }

    // 2a021001
    message Use {
        uint64 inventory_id = 2; // zero based
    }

    Login login = 2;
    Fight fight = 3;
    Inventory inventory = 4;
    Use use = 5;

}

Using https://github.com/stepancheg/rust-protobuf we can generate the equivalent rust code of this message. Even though I find the generated code really verbose (ie: Action::create().mut_inventory().set_inventory_id(1) ...) it really helped me test things quicker than fiddling with raw bytes.

Client flag

Before trying to obtain the migthy scroll of secrets, I wanted to know all the items that exist in the game. I took a look at the server's response upon sending a "retrieve object from stash" action, and reconstructed it's protobuf as best as I could. The only important field that I needed to find was the one describing an item id so that I could display any item I wanted in the UI:

syntax = "proto3";

message Response {

    message Retrieve {
        message AAA {
            uint64 f1 = 1; // [1]
            uint64 f3 = 3; // [32]
        }
        AAA f1 = 1; // []
        message Inventory {
            message Item {
                uint64 id = 1; // [6]
                uint64 f2 = 2; // [10]
                uint64 f3 = 3; // [100000]
                message AABAD {
                    repeated uint64 f1 = 1; // [2, 2, 2, 2]
                    repeated uint64 f2 = 2; // [1, 1]
                    repeated uint64 f3 = 3; // [18446744073709551606, 10]
                }
                repeated AABAD f4 = 4; // []
            }
            repeated Item items = 2; // []
            uint64 f3 = 3; // [6]
        }
        Inventory inventory = 2; // []
        uint64 f3 = 3; // [10]
    }

    Retrieve retrieve = 3; // []
}

I then added to my MitmClient write hooks, allowing me to edit messages before they are written to their recipient. I could then tweak the item id sent to the client:

// snip

// hook messages sent to the client
// if it's a Retrieve Response, change the id of the item
mitm.client.hook_write(|raw_msg| {
    hexdump::hexdump(&raw_msg);
    match parse_from_bytes::<Response>(&raw_msg) {
        Ok(mut x) => {
            // if the first item is a potion (id = 6)
            if x.get_retrieve().get_inventory().get_items().len() > 0
                && x.get_retrieve().get_inventory().get_items()[0].get_id() == 6
            {
                trace!("Retrieve hook called");
                // replace it with another id
                x.mut_retrieve().mut_inventory().mut_items()[0].set_id(7);
                return x.write_to_bytes().unwrap();
            }
            return raw_msg;
        }
        Err(_) => {
            return raw_msg;
        }
    }
});

day2 client flag

Would you look at that! Incrementing the potion id by 1 caused the client to display a mystery letter. This letter tells us that more fun awaits us on the server side, let's go!

Server flag

Since we know how to programatically login, fight monsters, fully heal etc.. we could build a bot that would fight monsters until we have enough gold to buy the item we want. After a bit of testing with the Action::Fight protobuf message, we understand that we can only fight monsters that are at most 2 levels above ours. Each fight yields ~10gold, earning 10000000 gold would take quite some time. Additionally the main screen told us:

Solving the challenge does not require excessive brute force

So there has to be another way! I went through each client actions, fuzzing each field, trying to find a logic bug on the server side. Until I took a closer look at the Action::Inventory message:

// 220410011801
message Inventory {
    uint64 retrieve_buy = 1; // 1 == retrieve, 2 == buy
    uint64 store_sell = 2; // 1 == store, 2 == sell
    uint64 inventory_id = 3; // zero based
}

This is the only message that instructs the server to do a different action based on a field value. If we want to retrieve the first item in the stash, we send:

let mut msg_retrieve = Action::new();
msg_retrieve.mut_inventory().set_retrieve_buy(1); // 1 == retrieve
// msg_retrieve.mut_inventory().set_inventory_id(0); // 0 based id, already 0 by default

Or send the first item of our inventory to the stash:

let mut msg_store = Action::new();
msg_store.mut_inventory().set_store_sell(1); // 1 == store
// msg_store.mut_inventory().set_inventory_id(0); // 0 based id, already 0 by default

If the inventory_id points to an empty inventory/stash slot the server closes the connection. But what if we have one item in our stash, one item in our inventory and send a payload containing both a retrieve and a store instruction ?

let mut msg_mix = Action::new();
msg_mix.mut_inventory().set_retrieve_buy(1); // 1 == retrieve
msg_mix.mut_inventory().set_store_sell(1); // 1 == store
// msg_mix.mut_inventory().set_inventory_id(0); // 0 based id, already 0 by default

The item in the stash gets duplicated !!!

We can write a function which will duplicate an item until our stash is filled, and then sell the duplicated items to the shop.

fn exploit(mitm: &mut MitmClient) -> Result<(), Box<dyn Error>> {
    // {{{ messages setup

    // with this message we tell the server to retrieve the first item from
    // the stash, and to store the first item of the inventory at the same time
    let mut msg_dup = Action::new();
    msg_dup.mut_inventory().set_retrieve_buy(1); // 1 == retrieve
    msg_dup.mut_inventory().set_store_sell(1); // 1 == store
    // this line is not useful since the inventory_id field will be 0 by default.
    // msg_dup.mut_inventory().set_inventory_id(0);

    // pop the first item of the stash
    let mut msg_pop = Action::new();
    msg_pop.mut_inventory().set_retrieve_buy(1); // 1 == retrieve

    // sell second item of the inventory
    let mut msg_sell = Action::new();
    msg_sell.mut_inventory().set_store_sell(2);
    msg_sell.mut_inventory().set_inventory_id(1);
    // }}}

    let mut n = 0;
    let stash_size = 31;

    // first we send dup msgs in order to fill te stash
    while n < stash_size {
        mitm.server.send_message(&msg_dup)?;
        n += 1;
    }

    // Then we retrieve duplicated items 5 by 5 and sell them :)
    n += 1;
    while n > 0 {
        let mut inv_size = 0;

        // pop items 5 by 5
        let min_size = cmp::min(n, 5);
        while inv_size < min_size {
            inv_size += 1;
            mitm.server.send_message(&msg_pop)?;
        }

        // sell items 5 by 5
        while inv_size > 0 {
            mitm.server.send_message(&msg_sell)?;
            inv_size -= 1;
            n -= 1;
        }
    }

    Ok(())
}

We then generate a lot of money by buying the next item available in the shop, and repeating the exploit as much as we want.

fn exploit_loop(
    mitm: &mut MitmClient,
    username: &str,
    password: &str,
) -> Result<(), Box<dyn Error>> {
    let mut msg_login = Action::new();
    msg_login.mut_login().set_username(username.to_string());
    msg_login.mut_login().set_password(password.to_string());

    // store second item in stash
    let mut msg_store = Action::new();
    msg_store.mut_inventory().set_store_sell(1);
    msg_store.mut_inventory().set_inventory_id(1);

    // buy second item from shop
    // once an item is bought it is removed
    let mut msg_buy = Action::new();
    msg_buy.mut_inventory().set_retrieve_buy(2);
    msg_buy.mut_inventory().set_inventory_id(1);

    // first step, let's login
    mitm.server.send_message(&msg_login)?;
    info!("logged in as username:{} password:{}", username, password);

    // store the second item (the club)
    // inventory: [potion]
    // stash: [club]
    mitm.server.send_message(&msg_store)?;

    info!("initial duplication exploit");
    exploit(mitm)?;

    let dup_num = 5;
    for i in 0..dup_num {
        info!("[{}/{}] duplication exploit on new shop item", i + 1, dup_num);
        // buy second item from shop
        // once an item is bought, it disappears from the shop
        mitm.server.send_message(&msg_buy)?;

        // place it in stash
        let mut msg_store = Action::new();
        msg_store.mut_inventory().set_store_sell(1);
        msg_store.mut_inventory().set_inventory_id(1);
        mitm.server.send_message(&msg_store)?;

        // make more money by duplicating the new item
        exploit(mitm)?;
    }

    info!("all done, enjoy the money :=)");

    Ok(())
}
cargo run exploit majin42 gimmemoneypls

[00:06][day2][INFO] connected to server: 3.93.128.89:12021
[00:06][day2][INFO] connected to client: 3.93.128.89:12022
[00:06][day2][INFO] reading server provided key
size: 16
[00:06][day2][INFO] sending secret_key to client
[00:06][day2][INFO] reading client connection id
size: 111
Server ID: Qm9+F6TH2MpeoIpwB9LNFtoKba8jokkN
Enter this ID on the game client to connect to this custom server.

[00:06][mitm][INFO] logged in as username:majin42 password:gimmemoneypls
[00:06][mitm][INFO] initial duplication exploit
[00:07][mitm][INFO] [1/5] duplication exploit on new shop item
[00:07][mitm][INFO] [2/5] duplication exploit on new shop item
[00:07][mitm][INFO] [3/5] duplication exploit on new shop item
[00:07][mitm][INFO] [4/5] duplication exploit on new shop item
[00:07][mitm][INFO] [5/5] duplication exploit on new shop item
[00:07][mitm][INFO] all done, enjoy the money :=)

A minute later, we are able to buy the Scroll of secrets and are rewarded with the missing part of the flag when using it.

day2 server flag

I found out, after the release of the challenge sources, that I made a mistake in the Inventory protobuf message. It actually contains a from_inventory and a to_inventory variable, telling the server to move an item from/to the inventory, stash or shop.

Here is the corrected version:

message Inventory {
    uint64 from_inventory = 1; // 0 == inventory, 1 == stash, 2 == shop
    uint64 to_inventory = 2; // 0 == inventory, 1 == stash, 2 == shop
    uint64 item_index = 3; // zero based
}

That explains why I triggered a duplication bug, I've been telling the server to move the first item of the stash to.. the stash. Woopsie!

Conclusion

Thanks overthewire and hpmv for the cool challenge!