Writeup by gregoiremenguy for Magasin à Secret

reverse linux x86/x64

January 27, 2026

This is my writeup for MagasinSecret. I will provide 2 ways to solve it: with and without password cracking.

Starting the Challenge

From the recovered discussion, we know that:

  1. The binary is a password manager;
  2. All users share the same password;
  3. There is at least 2 users: z3r0 and LBigBossDu15;
  4. The users z3r0 and LBigBossDu15 both have an entry in the password manager to store their shared netflix password.

That’s a lot of information ! So now lets start reversing the binary.

By searching the strings in the binary I found many interresting patterns like Option::unwrap() or valuesrc/libcore/option.rs. It really look like a rust code. Indeed, greping rust in the binary provides many outputs:

$ strings MagasinSecret |grep rust

Ok so this is a rust binary. First time for me. Fortunately, it is not stripped:

$ file MagasinSecret
MagasinSecret: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=b188a66c1ae48f72aa61bfc8e4f1677424cf88b2, not stripped

Moreover, searching for the usernames in the strings, we can find a new interresting username: MrFlag (this might be useful).

$ strings MagasinSecret |grep z3r0
/usr/src/rustc-1.32.0/src/libcore/slice/mod.rsMrFlaglhSXH1YQz3r0@Hf2~PJWx<i0y?al34d4c7506bf3ea1bf9521dc5bf4f71dd63ec68d2b36fd685ce4deb9d1f26092d211a1908000f061f040c5e060a160c50341d7e18090224Entropie310b070600a863af583781985893e9c76f8a37e361fcded59413ce35e578383217073d67f2015f915eda106bcd9fc3df615d32eade7ecd1d963b03592b7538dfe513f773195b1d4522050a104c0c6ec34e7446c0e9c8c95ca72468c956eb34ca06632a1210069540fc1b9a8c067e0f4907043912005c590012451004195c05557036LBigBossDu150c9491afee4f4939ed008f11a0cad8566764057d06e54e106ce0c74417193525067e5a2a2302121d66da6a0f8e5f45cc06e7cb408ddf6c40c2a05542960a8ac56bca3f2a585c2d8a39381c533a0a164a251c46003416060a4f0c5431Utilisation:  "Nom d'utilisateur" "Mot de passe"

Reverse Engineering the Main Function

Lets open the binary in BinaryNinja (version 5.1.8104 free). I start to search main in the symbols search bar. There is indeed a main function:

555555407370    int32_t main(int32_t argc, char** argv, char** envp)

555555407370  4889f2             mov     rdx, rsi
555555407373  4863f7             movsxd  rsi, edi
555555407376  488d3df3fcffff     lea     rdi, [rel chall_ecsc_re::main::h91a329e5f7c3415e]
55555540737d  e9aee9ffff         jmp     std::rt::lang_start::hc3df8177b01a8c86

It calls the chall_ecsc_re::main::h91a329e5f7c3415e. Here is the rust-like pseudo code of this function:

fn main() {
    let args: Vec<String> = env::args().collect();
    if args.len() != 3 {
        // Print error message
        return;
    }
    let username = args[1];
    let password = args[2];

    if password.len() != 10 { // The password should be 10 characters long
        // Print error message
        return;
    }

    let secretholders = generate_secretholders();
    let collected_secrets = secretholders.iter()
                                        .filter(functionFilter)
                                        .map(functionMap)
                                        .collect();
    if collected_secrets.len() != 0 {
        // print the secrets
    }    
}

So main calls the generate_secretholders function and 2 lambda functions that I call functionFilter and functionMap.

I expect generate_secretholders to extract the structures in the binary storing the usernames, and secrets. I will not reverse engineer it. I will rather focus on the two lambda functions.

Finding Lambda Function Definitions

At first I thought that the functionFilter (resp. functionMap) function pointer would be passed as an argument to the iter (resp. map) function. However, when looking to the code, it does not seem to be true.

5555554070f3  e888f8ffff         call    chall_ecsc_re::generate_secretholders::h514b87a7f53eafe4
5555554070f8  488d7c2470         lea     rdi, [rsp+0x70 {secretholders_ptr}]
5555554070fd  e87e0b0000         call    _$LT$alloc..vec..Vec$LT$.....Deref$GT$::deref::hb880e50d672bc8e4
555555407102  4889c7             mov     rdi, rax
555555407105  4889d6             mov     rsi, rdx
555555407108  e803f5ffff         call    core::slice::_$LT$impl$u...b$T$u5d$$GT$::iter::hbf822bcd8718a928
55555540710d  4989e6             mov     r14, rsp {collected}
555555407110  488d4c2438         lea     rcx, [rsp+0x38 {username}]
555555407115  488d6c2440         lea     rbp, [rsp+0x40 {password_1}]
55555540711a  4c89f7             mov     rdi, r14 {collected}
55555540711d  4889c6             mov     rsi, rax
555555407120  4989e8             mov     r8, rbp {password_1}
555555407123  e8d8f3ffff         call    core::iter::iterator::Iterator::filter::hbf6308892043202d

Here we cannot see any function pointer passed to filter (this is the same for map). In fact, the function is directly inlined in the filter function. Interrestingly, the name of a lambda function is quite recognizable. It includes (1) the name of the function where the lambda has been created (in our case main); and (2) the keyword closure.

(Optional) How to know that lamba functions are inlined when you never played with Rust (like me) ?

(Show Optional Content)

As it is my first time playing with rust, I tried to understand where is called the lambda function used in filter using the following simple code snippet:

use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();
    let mut filtered = args.iter().filter(|s| s.len() == 2);
    let first = filtered.next();
    match first {
        Some(s) => println!("{}", s),
        None => println!("none")
    }
}

I then looked for references to the String::len function. As expected there is only one call to String::len in a function named testrust::main::_$u7b$$u7b$closure$u7d$$u7d$::hd7018e64a25d9ba0, which is itself a subfunction of iter. Ok so I got it now :-)

Searching for the functions following these rules, I found 2 of them, each calling interresting sub-functions:

  1. chall_ecsc_re::main::_$u7b$$u7b$closure$u7d$$u7d$::h2b4972a88f817938
    • sub-function: chall_ecsc_re::secret_holder::SecretHolder::check_password::habf9fdd677886c4e
  2. chall_ecsc_re::main::_$u7b$$u7b$closure$u7d$$u7d$::h1079d510f4a4da75.llvm.13054834620831216952()
    • sub-function: chall_ecsc_re::secret_holder::SecretHolder::decrypt_secret::hf5f32b7a9e6bc86c

Hence, it seems that the filter part selects all the password manager entries for a given user, and the map part decrypts each of these entries. Lets reverse engineer each of them.

Reverse Engineering check_password

I am a big fan of debugging. So lets start debugging the check_password function. To do so, I run the MagasinSecret z3r0 aaaaaaaaaa into gdb and break in check_password. From here I can check the values of the functions arguments: rdi = &"z3r0" and *rsi=&"aaaaaaaaaa". Hence, the first argument is the username and the second the password.

Now I can try to understand the function. It is quite easy as the code is not stripped. Here is its rust-like pseudocode:

fn check_password(String username, String password) {
    let s = // get some string in the binary
    let extended = String::add(username, s);
    let hash = sha256(extended);
    let expected_hash = // get string from binary at username+0x30
    let decoded_expected_hash = hex::decode(expected_hash);
    return Vec::eq(hash, decoded_expected_pass);
}

I want to extract the hashes to crack them. The first step is to observe that the hash is not computed over password but password+suffix. Such a suffix is called a salt and is common in password hasing. To extract this salt, I put a breakpoint after the String::add call and check the result :

$ gdb MagasinSecret
> start z3r0 aaaaaaaaaa 
> break *0x555555408cc0
> continue
> x/gx $rax
0x7fffffffd450: 0x0000555555644360
> x/s 0x0000555555644360
0x555555644360: "aaaaaaaaaa@Hf2"

So the salt is : @Hf2. Now lets find the expected hash. To do so, I break before the hex::decode function and check its inputs:

$ gdb MagasinSecret
> start z3r0 aaaaaaaaaa 
> break *0x555555408d9b
> continue
> x/gx $rsi
0x7fffffffd3b0: 0x00005555556443b0
> x/s 0x00005555556443b0
0x5555556443b0: "f2015f915eda106bcd9fc3df615d32eade7ecd1d963b03592b7538dfe513f773"

If we want we can do it for the other users. It leads to the following hashes:

$ cat hashes_sha256_salted
0c9491afee4f4939ed008f11a0cad8566764057d06e54e106ce0c74417193525:x<i0
f2015f915eda106bcd9fc3df615d32eade7ecd1d963b03592b7538dfe513f773:@Hf2
34d4c7506bf3ea1bf9521dc5bf4f71dd63ec68d2b36fd685ce4deb9d1f26092d:lhSX

I tried breaking these passwords using hashcat (https://hashcat.net/hashcat/):

$ hashcat -a 3 -m 1410 hashes_sha256_salted "?a?a?a?a?a?a?a?a?a?a" -O -D 1,2
...
!!!! AS EXPECTED MY LAPTOP IS BURNING !!!!

I tried other masks and some password database, but it failed. Too bad, I will have to reverse engineer decrypt_secret.

Reverse Engineering decrypt_secret

Once again, understanding the function is not too hard as the code is not stripped. Here is a rust-like pseudo code for the function.

fn decrypt_secret(String username, String password) {
    let key = String::add(username, password);
    let secret = // read string from binary
    let decoded = hex::decode(secret).unwrap().into_vec()
                        .iter().enumerate().into_iter();

    let mut res = vec![];
    while (true) {
        match decoded.next() { // get next bytes
            None -> break;
            Some (i, d) ->
                let len = key.len();
                assert!(len > 0);
                let indice = i % len;
                res.push(d ^ key[indice]);  
        }
    }
    return res; // this is the decoded secret
}

Essentially, it decodes the stored secrets by xoring each byte with username + password.

Lets dump all the ciphered secrets stored in the passmanager. To do so, I just put two breakpoints: (1) at the end of the check_password; and (2) in decrypt_secret before the hex::decode call.

$ gdb MagasinSecret
> break *0x555555408ddb // last instruction of check_password
> break *0x555555408e94 // call of hex:decode in decrypt_secret
> r z3r0 aaaaaaaaaa
[ Reached 0x555555408ddb ]
> set $rax := 0x1 // force the password check to succeed
> continue
[ Reached 0x555555408e94 ]
> x/gx rsi
0x7fffffffd340: 0x0000555555644450
> x/s 0x0000555555644450
0x555555644450: "195b1d4522050a104c0c"

Ok so the first entry for the user z3r0 is 195b1d4522050a104c0c. We can recover all the ciphered secrets in the same manner. For instance, if you want the second entry for z3r0, you apply the same process but do not modify the value of rax the first time you break in check_password. Like this, you will pass the first entry. When you break again (for the 2nd time) at the end of check_password, you can poison the value of rax.

This gives us the following sets of secrets:

$ cat secrets
MrFlag:
211a1908000f061f040c5e060a160c50341d7e18090224

LBigBossDu15:
067e5a2a2302121d
39381c533a0a164a251c46003416060a4f0c5431

z3r0:
195b1d4522050a104c0c
0f4907043912005c590012451004195c05557036

Recovering the First Bytes of the Password

As, the xoring key is username+password, we can decipher the start of each secrets as follow:

def get_start(username, secret):
    user_bytes = bytearray(username)
    secret_bytes = bytearray(secret)
    for i, u in enumerate(user_bytes):
        if i >= len(secret_bytes): break
        res += chr(u ^ secret_bytes[i])
    print(username, ": ", res)

get_start(b"MrFlag", b"211a1908000f061f040c5e060a160c50341d7e18090224")

get_start(b"LBigBossDu15", b"067e5a2a2302121d")
get_start(b"LBigBossDu15", b"39381c533a0a164a251c46003416060a4f0c5431")

get_start(b"z3r0", b"195b1d4522050a104c0c")
get_start(b"z3r0", b"0f4907043912005c590012451004195c05557036")

This returns:

$ python3 ./get_start.py
MrFlag : lh_dah                 // Looks like the start of the flag :-) 
LBigBossDu15 : J<3Maman
LBigBossDu15 : uzu4xee9aiw5
z3r0: chou
z3r0: uzu4

Ok, we found a secret shared between LBigBossDu15 and z3r0 : uzu4xee9aiw5... (this is the netflix password). This will help us recover the passmanager password.

Now we start the funny part of the challenge. Indeed, we will use two weaknesses to recover the password:

  1. Xor is easily inversible (a ^ b = c <=> a = c ^ b);
  2. The deciphering key uses both the username and the password.

Essentially we know that:

LBigBossDu15 ^ 0x39381c533a0a164a251c4600 = uzu4xee9aiw5
z3r0xxxxxxxx ^ 0x0f4907043912005c59001245 = uzu4xee9aiw5

and so 
z3r0xxxxxxxx = uzu4xee9aiw5 ^ 0x0f4907043912005c59001245
             = z3r0Awee8iep

Hence, we found the 8th first characters of the password: Awee8iep.

Finding the Full Password with Password Cracking

A straightforward way to find the two remaining bytes is to bruteforce them. I use hashcat (https://hashcat.net/hashcat/) the state-of-the-art password cracker to do it:

$ hashcat -a 3 -m 1410 hashes_sha256_salted "Awee8iep?a?a" -O -D 1,2
...
Awee8iepee

We found the password ! :-D

Finding the Full Password Without Password Cracking

Cracking the two last bytes of the password is fun. However, it would not have worked if the password was bigger. What would happen if it was 20 characters long ? We would have learned the 8 first characters of the password. So we would need to crack the other 12 bytes which would be too long for a brute-force attack.

That’s not a problem because we do not really need to use a bruteforce attack. Indeed, we can apply the process we used to learn the first 8 bytes, to find the others.

We provide here a script to deduce the password without bruteforcing it. Note that it would work even if the password was very long.

def xor(bstr1, bstr2):
    res = bytearray()
    for i, b1 in enumerate(bstr1):
        if i >= len(bstr2):
            break
        res.append(b1 ^ bstr2[i])
    return res

def crack():
    z3r0_username = bytearray(b"z3r0")
    z3r0_secret = bytearray.fromhex("1f4907043912005c590012451004195c05557036")

    bigboss_username = bytearray(b"LBigBossDu15")
    bigboss_secret = bytearray.fromhex("39381c533a0a164a251c46003416060a4f0c5431")

    pass_size = 10

    # Known part of the password
    known_pass = bytearray()

    while len(known_pass) < pass_size:
        known_z3r0 = z3r0_username + known_pass
        known_bigboss = bigboss_username + known_pass

        xored_bigboss = xor(known_bigboss, bigboss_secret)

        start = len(known_z3r0)
        end = len(known_bigboss)
        new_found_bytes = xor(xored_bigboss[start:end], z3r0_secret[start:end])

        known_pass.extend(new_found_bytes)
    return known_pass[:pass_size]

print("".join(chr(e) for e in crack()))

Again we find the password: Awee8iepee.

Finding the Flag

Know that we know the password, we can simply find the flag by running:

$ ./MagasinSecret MrFlag Awee8iepee
Voici vos secrets:
lh_dahGhaifoofi5yo8thee