An ESP32 devkit board plugged in, with a red LED shining.

Return to the embedded world: reversing ESP32 firmware

written on February 22, 2026

Recently I had the pleasure of participating in a CTF initiation challenge alongside the rest of the BINSEC team, organized by my friend and colleague Grégoire Menguy. Since several PhD students in the team hadn't yet had the opportunity to participate in such a competition, it was the perfect occasion to try it out by attempting to complete as many challenges as possible from the France Cybersecurity Challenge (FCSC) 2025 on the Hackropole website.

I was pretty rusty myself, since I haven't had the occasion to participate in such a challenge in quite a long time. We turned to the intro challenges as a warm-up, and soon after that we started attempting some of the more difficult ones. In this post, I'd like to describe how I went about solving one of them, specifically a reverse-engineering challenge concerning a piece of ESP32 firmware.

WARNING: Obviously, MAJOR spoilers ahead, for the CTF challenge itself and possible solutions or hints. I highly recommend you try this for yourself before reading ahead. You have been warned!

Basic analysis

Alright, so the challenge is called Extension. It's a reverse-engineering challenge, meaning that we're given a binary program and are expected to dig through it by any means necessary to find the flag. Usually CTFs tell you more or less what the flag is supposed to look like, and that's the case here too: we're looking for a string of the format FCSC{<something>}, with <something> usually being a sequence of characters and numbers. To prove that we've completed the challenge and found the flag, we need to paste the flag in the appropriate input form field on the challenge page, at which point the site will let us know if the flag is correct.

Let's read the challenge description, which might provide useful hints:

You find an ESP32 in a trash bin. After connecting to the serial port, it seems to expect a flag.

You manage to dump its flash memory, can you find the flag ?

Okay, so this is indeed an ESP32 flash dump. This already tells us some things: ESP32 is a type of microcontroller, usually running bare-metal code, meaning that usually there is no underlying OS like Linux.

Let's start by running file on the downloaded binary (always a good way to get more information):

$ shell

$ file extension.elf
extension.elf: ELF 32-bit LSB executable, Tensilica Xtensa, version 1 (SYSV), statically linked, not stripped

Tensilica Xtensa is indeed the Instruction Set Architecture (ISA) used by ESP32 microcontrollers, and we can also see that it's not stripped; this is great! It means that the symbols (e.g., the function names) have not been stripped away, and we can use them to get useful contextual information as we dig through the binary.

Time to get our hands dirty!

Taking a first look in Ghidra

There are many ways to reverse-engineer a binary. My tool of choice is usually Ghidra, because its decompilation quality is quite good, it supports a wide range of ISAs, and it's free. I also like Binary Ninja, but unfortunately the free version only seems to support x86 binaries. Ghidra does not support literally everything either: make sure your copy is updated to a recent enough version to make sure you have support for Xtensa, otherwise you might need to go looking for a plugin.

We fire up Ghidra and analyze extension.elf. Neat. Now what?

Remember how I said this was a bare-metal binary? Well, that means that the default entrypoint to the program is not good ol' int main(int argc, char** argv). So how do we know where to start?

First of all, we can look at the symbols Ghidra has found in the Symbol Tree window. If we scroll through the Exported symbols, we can see a bunch of them starting with esp_. These are part of the ESP32 HAL. Usually, a microcontroller vendor will make a bunch of models for different use cases (e.g., with more or less memory, higher or lower CPU base frequency (and thus energy efficiency) and so on) which might all have slightly different hardware configurations. A HAL is a library which takes care of mapping each hardware model to the right configuration and exposing a uniform API to the programmer of the microcontroller. That way, the programmer doesn't have to break out the microcontroller manual every time they want to know what address they're supposed to use for a given peripheral.

If we scroll some more in the same window, we will also come across some references to FreeRTOS, a real-time OS kernel (very lightweight compared to OS kernels like Linux) often used in embedded systems like this one.

The main function

Armed with this knowledge, we can look up what the entrypoint function is for this configuration. Digging around the ESP32 documentation leads us to the hello_world example, which seems to contain a single app_main() function. Indeed, searching for app_main in the Symbol Tree window yields a result, and double-clicking it opens the decompiled code in the Decompile window:

Decompile: app_main

void app_main(void)

{
  int iVar1;
  undefined4 local_70;
  undefined1 auStack_6c [20];
  char *pcStack_58;
  char *pcStack_50;
  undefined *puStack_4c;
  code *pcStack_44;
  undefined4 *puStack_40;
  undefined4 uStack_34;
  undefined4 uStack_30;
  undefined4 uStack_2c;
  undefined4 uStack_28;
  
  local_70 = 0;
  memcpy(auStack_6c,&DAT_3f409030,0x1c);
  pcStack_58 = s_FCSC>_3f4038b8;
  checkflag_args = arg_str0(0,0,s_<flag>_3f4038d4,s_Flag_du_challenge_3f4038c0);
  DAT_3ffb2a88 = arg_end(2);
  memset(&pcStack_50,0,0x1c);
  pcStack_50 = s_checkflag_3f4038dc;
  puStack_4c = &DAT_3f4038e8;
  pcStack_44 = checkflag;
  puStack_40 = &checkflag_args;
  iVar1 = esp_console_cmd_register(&pcStack_50);
  if (iVar1 != 0) {
    _esp_error_check_failed
              (iVar1,s_./main/main.c_3f403930,0x7c,__func__$0,
               s_esp_console_cmd_register(&checkf_3f403904);
  }
  esp_console_register_help_command();
  uStack_34 = DAT_3f40904c;
  uStack_30 = DAT_3f409050;
  uStack_2c = DAT_3f409054;
  uStack_28 = DAT_3f409058;
  iVar1 = esp_console_new_repl_uart(&uStack_34,auStack_6c,&local_70);
  if (iVar1 != 0) {
    _esp_error_check_failed
              (iVar1,s_./main/main.c_3f403930,0x82,__func__$0,
               s_esp_console_new_repl_uart(&hw_co_3f403940);
  }
  iVar1 = esp_console_start_repl(local_70);
  if (iVar1 != 0) {
    _esp_error_check_failed
              (iVar1,s_./main/main.c_3f403930,0x84,__func__$0,
               s_esp_console_start_repl(repl)_3f40397c);
  }
  return;
}

Naturally, this is hard to read at first. However, you'll notice a few symbols here and there that we can use to refine the types of the different variables and make this much easier to read.

For instance, what are those arg_str0() and arg_end() functions? Some quick searching reveals that they are part of Argtable, a C library used for parsing command-line arguments. Luckily for us, Argtable is open-source, so we can look through the code to get the definitions of these functions. Starting with arg_str0(), we can find its definition in the source code:

argtable3/src/argtable3.h

ARG_EXTERN arg_str_t* arg_str0(const char* shortopts, const char* longopts, const char* datatype, const char* glossary);

Similarly, we can find the definition of arg_str_t and all of its parts, until we have the definition of everything used by this function in terms of base C types. Then, we can go ahead and define these types in Ghidra to make the decompiled code clearer. We can do that in the Data Type Manager window, by right-clicking extension.elf, selecting New, and then clicking on the appropriate type. Function Definition is used for function pointer typedefs, Structure for structs, Typedef for other typedefs and so on.

One important thing to note is alignment: for instance, for the arg_hdr_t structure, we need to apply an alignment of four, otherwise stuff will look weird later. Specifically, the first field of the structure (which is a char) actually takes up four bytes with padding, not just one.

Once we have our types, we can right-click each Argtable function and click on Edit Function Signature, and then enter the function signature found in the Argtable source code. Ghidra doesn't seem to like the const qualifier, so I avoid it, plus it doesn't really make a huge difference for reverse-engineering purposes. Once we've edited the function signatures of both arg_str0() and arg_end(), our decompiled app_main() looks something like this:

Decompile: app_main

void app_main(void)

{
  int iVar1;
  undefined4 local_70;
  undefined1 auStack_6c [20];
  char *pcStack_58;
  char *pcStack_50;
  undefined *puStack_4c;
  code *pcStack_44;
  undefined4 *puStack_40;
  undefined4 uStack_34;
  undefined4 uStack_30;
  undefined4 uStack_2c;
  undefined4 uStack_28;
  
  local_70 = 0;
  memcpy(auStack_6c,&DAT_3f409030,0x1c);
  pcStack_58 = s_FCSC&gt;_3f4038b8;
  checkflag_args = arg_str0((char *)0x0,(char *)0x0,s_&lt;flag&gt;_3f4038d4,s_Flag_du_challenge_3f4038c0);
  DAT_3ffb2a88 = arg_end(2);
  memset(&pcStack_50,0,0x1c);
  pcStack_50 = s_checkflag_3f4038dc;
  puStack_4c = &DAT_3f4038e8;
  pcStack_44 = checkflag;
  puStack_40 = &checkflag_args;
  iVar1 = esp_console_cmd_register(&pcStack_50);
  if (iVar1 != 0) {
    _esp_error_check_failed
              (iVar1,s_./main/main.c_3f403930,0x7c,__func__$0,
               s_esp_console_cmd_register(&checkf_3f403904);
  }
  esp_console_register_help_command();
  uStack_34 = DAT_3f40904c;
  uStack_30 = DAT_3f409050;
  uStack_2c = DAT_3f409054;
  uStack_28 = DAT_3f409058;
  iVar1 = esp_console_new_repl_uart(&uStack_34,auStack_6c,&local_70);
  if (iVar1 != 0) {
    _esp_error_check_failed
              (iVar1,s_./main/main.c_3f403930,0x82,__func__$0,
               s_esp_console_new_repl_uart(&hw_co_3f403940);
  }
  iVar1 = esp_console_start_repl(local_70);
  if (iVar1 != 0) {
    _esp_error_check_failed
              (iVar1,s_./main/main.c_3f403930,0x84,__func__$0,
               s_esp_console_start_repl(repl)_3f40397c);
  }
  return;
}

Not much seems to have changed, but sometimes progress is not immediately obvious. There are a few additional little things we can clean up: for instance, in the arg_str0() call, we can right-click each 0x0 value and choose Set Equate, then enter NULL. This doesn't change much either, but in my opinion it makes things a little cleaner, since we know that the first two arguments are pointers to strings or NULL.

In the same arg_str0() call, we can also clean up the rest of the arguments. Double-clicking each of them and looking at the memory values in the Listing window, we can see that the constant s_<flag>_3f4038d4 corresponds to the string "<flag>", while the constant s_Flag_du_challenge_3f4038c0 corresponds to the string "Flag du challenge" (challenge flag in French). We can right-click each of them and choose Rename Global, then rename them something like FLAG_ARGUMENT_PLACEHOLDER and FLAG_ARGUMENT_HELP respectively. This does yield a slightly prettier result:

Decompile: app_main

  checkflag_args = arg_str0((char *)NULL,(char *)NULL,FLAG_ARGUMENT_PLACEHOLDER,FLAG_ARGUMENT_HELP);

Similarly, we can rename the memory address at the receiving end of the arg_end(2) call. The documentation says that it returns an end-of-table marker, so we can call it arg_end_marker:

Decompile: app_main

  checkflag_args = arg_str0((char *)NULL,(char *)NULL,FLAG_ARGUMENT_PLACEHOLDER,FLAG_ARGUMENT_HELP);
  arg_end_marker = arg_end(2);

We're slowly getting somewhere! Next, let's tackle the ESP32 HAL functions we can see in app_main(): esp_console_cmd_register(), esp_console_register_help_command(), esp_console_new_repl_uart(), and esp_console_start_repl(). We will not worry about _esp_error_check_failed() for now, since it's pretty self-descriptive. Again, we can annotate all of these functions and define their types by searching through the ESP32 documentation.

But, what are these functions supposed to do? There are two key words here: UART (Universal Asynchronous Receiver-Transmitter) and REPL (Read-Eval-Print Loop). To understand why we would even reach for those, we need to take a step back and consider how a microcontroller works. Fundamentally, a desktop computer (i.e., an actual desktop, a laptop, and whatever other type of traditional computer you may be using) shares a lot of common parts with a microcontroller: for instance, they both have CPUs, which handle the actual execution of the instructions they're programmed to execute. They also both have some type of memory, typically some RAM-style memory (erased after a reboot) and some ROM-style memory (not erased after a reboot).1 Now, when you go to use your desktop computer, how do you do so? Well, you have one or more input devices (like a keyboard, a mouse and so on) and one or more output devices (like a screen). If you want to communicate with your computer, you could, for example, open a Bash shell, type something like echo hello world, and hit Enter. Your computer then would read the command you just typed, evaluate it, and print hello world on the screen.

Now what about doing the same on a microcontroller (or, more precisely, a devkit—a microcontroller on a board, plus some handy hardware), such as the ESP32? Most microcontroller boards don't come with screens or keyboards or mice, so how do we interact with them?2 Thankfully, most of them come with some serial interface, since we definitely need some way to get data on them in order to program them. UART is an extremely common way to achieve this—usually coupled with a UART-to-USB bridge to be able to connect to a desktop computer via USB3—which is why it's super common to see UART-related functions in the HALs which come with microcontroller boards. You don't need to know the specifics of UART of course, but it helps to roughly understand what its role is when reversing the binary. Okay, what about REPL then? You actually already know what this is: it's basically what every shell ever does. It reads a command (or expression or whatever, depending on the shell), evaluates it (e.g., Bash evaluates $HOME to whatever directory it actually points to), and finally prints something on the screen (sometimes), only to do it all over again. So, in summary, esp_console_new_repl_uart() seems to set up a REPL on the UART interface of the microcontroller, presumably so we can interact with it via standard means through some desktop computer.

Now the other functions, like esp_console_cmd_register(), should start making a little more sense: since we're setting up a sort of shell, we need to define what the commands we can type into it are. Here, we only seem to be registering two commands: a custom one (via esp_console_cmd_register()) and a help one (via esp_console_register_help_command()), which presumably just prints the available commands and how to use them.

Using the ESP32's documentation to create the necessary data structures and annotate esp_console_cmd_register() already yields cleaner decompiled code:

Decompile: app_main

  esp_err_t eVar1;
  esp_console_cmd_t eStack_50;

  memset(&eStack_50,0,0x1c);
  eStack_50.command = s_checkflag_3f4038dc;
  eStack_50.help = &DAT_3f4038e8;
  eStack_50.func = checkflag;
  eStack_50.argtable = &checkflag_args;
  eVar1 = esp_console_cmd_register(&eStack_50);
  if (eVar1 != 0) {
    _esp_error_check_failed
              (eVar1,s_./main/main.c_3f403930,0x7c,__func__$0,
               s_esp_console_cmd_register(&checkf_3f403904);
  }

As usual, we can clean up the labels, by observing the constants in memory and by looking at the documentation of the ESP32:

Decompile: app_main

  esp_err_t result;
  esp_console_cmd_t checkflag_cmd;

  memset(&checkflag_cmd,0,0x1c);
  checkflag_cmd.command = CHECKFLAG_CMD_NAME;
  checkflag_cmd.help = &CHECKFLAG_CMD_HELP;
  checkflag_cmd.func = checkflag;
  checkflag_cmd.argtable = &checkflag_args;
  result = esp_console_cmd_register(&checkflag_cmd);
  if (result != 0) {
    _esp_error_check_failed
              (result,MAIN_FILE_PATH,0x7c,MAIN_FUNCTION_NAME,ESP_CONSOLE_CMD_REGISTER_EXPRESSION);
  }

So, what this function does should be clear by now: it registers a checkflag command in the REPL, and it uses the argument parsing configuration set up previously with Argtable. The CHECKFLAG_CMD_HELP string says that it verifies the value of the flag, so presumably we would enter the value of the flag via the UART REPL, and it would tell us whether or not it's correct. If the registration of the checkflag command fails, we call some failure function, which does a standard microcontroller thing: it causes some exception to be triggered at the CPU level, which in turn causes some fault handler function to be called. We don't really care what happens in that case (at least for now), but commonly this function reboots the microcontroller—it's a standard way to get out of the hole should the embedded system find itself in one.

There is one last thing we can do for this part (which might be considered a nitpick): Currently, the call to memset() gets a size argument of 0x1c, which on its own doesn't mean much. However, if we inspect the size of esp_console_cmd_t (which is the type of checkflag_cmd, the first argument passed to memset()), it just so happens to be 0x1c bytes long. We can right-click the value and hit Set Equate, then replace it by sizeof(checkflag_cmd), which makes things ever so slightly clearer:

Decompile: app_main

  memset(&checkflag_cmd,0,sizeof(checkflag_cmd));

For completeness' sake, let's go through and annotate the rest of app_main():

Decompile: app_main

void app_main(void)

{
  esp_err_t result;
  esp_console_repl_t *repl;
  esp_console_repl_config_t repl_config;
  esp_console_cmd_t checkflag_cmd;
  esp_console_dev_uart_config_t uart_config [3];
  
  repl = (esp_console_repl_t *)NULL;
  memcpy(&repl_config,&REPL_CONFIG_DATA,sizeof(repl_config));
  repl_config.prompt = REPL_PROMPT;
  checkflag_args = arg_str0((char *)NULL,(char *)NULL,FLAG_ARGUMENT_PLACEHOLDER,FLAG_ARGUMENT_HELP);
  arg_end_marker = arg_end(2);
  memset(&checkflag_cmd,0,sizeof(checkflag_cmd));
  checkflag_cmd.command = CHECKFLAG_CMD_NAME;
  checkflag_cmd.help = &CHECKFLAG_CMD_HELP;
  checkflag_cmd.func = checkflag;
  checkflag_cmd.argtable = &checkflag_args;
  result = esp_console_cmd_register(&checkflag_cmd);
  if (result != 0) {
    _esp_error_check_failed
              (result,MAIN_FILE_PATH,0x7c,MAIN_FUNCTION_NAME,ESP_CONSOLE_CMD_REGISTER_EXPRESSION);
  }
  esp_console_register_help_command();
  uart_config[0].channel = UART_CHANNEL;
  uart_config[0].baud_rate = UART_BAUD_RATE;
  uart_config[0].tx_gpio_num = UART_GPIO_TX;
  uart_config[0].rx_gpio_num = UART_GPIO_RX;
  result = esp_console_new_repl_uart(uart_config,&repl_config,&repl);
  if (result != 0) {
    _esp_error_check_failed
              (result,MAIN_FILE_PATH,0x82,MAIN_FUNCTION_NAME,ESP_CONSOLE_NEW_REPL_UART_EXPRESSION);
  }
  result = esp_console_start_repl(repl);
  if (result != 0) {
    _esp_error_check_failed
              (result,MAIN_FILE_PATH,0x84,MAIN_FUNCTION_NAME,ESP_CONSOLE_START_REPL_EXPRESSION);
  }
  return;
}

This is much more readable than what we started with! We can now even see which GPIO pins are used for the UART, what the baud rate is and so on, should we want to debug that later (or even just cross-reference with the hardware documentation). What should we look at next? Naturally, one might be tempted to go into the UART code—since that would follow the logical flow of the program—but that's most likely not interesting at all. That's fully entering HAL territory, and while it's not impossible that the flag could be hidden there, I think it's worth looking at other things first. For instance, what happens if we run that checkflag command in the REPL? Clearly, the .func member of the struct points to a function in the binary! It seems that this function handles the actual checking of the flag, so it's the perfect candidate to look at next.

The flag checking function

Again, at first this function will probably be a bit of a mess:

Decompile: checkflag

undefined4 checkflag(undefined4 param_1,undefined4 param_2)

{
  int iVar1;
  
  iVar1 = arg_parse(param_1,param_2,&checkflag_args);
  if (iVar1 == 0) {
    if (*(int *)(checkflag_args + 0x34) == 0) {
      puts(s_Arguments_invalides_3f40385c);
    }
    else {
      iVar1 = verif(**(undefined4 **)(checkflag_args + 0x38));
      if (iVar1 == 0) {
        printf(&DAT_3f403870,**(undefined4 **)(checkflag_args + 0x38));
      }
      else {
        puts(&DAT_3f40389c);
      }
    }
  }
  else {
    puts(s_Arguments_invalides_3f40385c);
  }
  return 0;
}

We can start by annotating the function signature itself. We know what this is supposed to look like, because we know what type of handler function esp_console_cmd_register() expects. This will propagate the new types of the parameters to the function body, slightly clearing up the intentions of the rest of the code:

Decompile: checkflag

int checkflag(int argc,char **argv)

{
  int iVar1;
  
  iVar1 = arg_parse(argc,argv,&checkflag_args);
  if (iVar1 == 0) {
    if (*(int *)(checkflag_args + 0x34) == 0) {
      puts(s_Arguments_invalides_3f40385c);
    }
    else {
      iVar1 = verif(**(undefined4 **)(checkflag_args + 0x38));
      if (iVar1 == 0) {
        printf(&DAT_3f403870,**(undefined4 **)(checkflag_args + 0x38));
      }
      else {
        puts(&DAT_3f40389c);
      }
    }
  }
  else {
    puts(s_Arguments_invalides_3f40385c);
  }
  return 0;
}

Next up: annotating arg_parse(). This is another Argtable function. I think we can also safely call iVar1 result, since it seems to be storing the result of the call to arg_parse():

Decompile: checkflag

int checkflag(int argc,char **argv)

{
  int result;
  
  result = arg_parse(argc,argv,(void **)&checkflag_args);
  if (result == 0) {
    if (*(int *)(checkflag_args + 0x34) == 0) {
      puts(s_Arguments_invalides_3f40385c);
    }
    else {
      result = verif(**(undefined4 **)(checkflag_args + 0x38));
      if (result == 0) {
        printf(&DAT_3f403870,**(undefined4 **)(checkflag_args + 0x38));
      }
      else {
        puts(&DAT_3f40389c);
      }
    }
  }
  else {
    puts(s_Arguments_invalides_3f40385c);
  }
  return 0;
}

Okay, now what is all that checkflag_args + 0x34 stuff? If you remember, back in app_main(), we established that checkflag_args is a global variable (specifically of type struct arg_str_t *), initialized by Argtable, to help with parsing arguments in the REPL. Given that, then clearly checkflag_args + 0x34 is accessing whatever structure member is found at offset 0x34. Can't Ghidra figure that out for us? Of course it can! We just need to specify what its type is. Doing so clears up the code by a lot:

Decompile: checkflag

int checkflag(int argc,char **argv)

{
  int result;
  
  result = arg_parse(argc,argv,&checkflag_args);
  if (result == 0) {
    if (checkflag_args->count == 0) {
      puts(s_Arguments_invalides_3f40385c);
    }
    else {
      result = verif(*checkflag_args->sval);
      if (result == 0) {
        printf(&DAT_3f403870,*checkflag_args->sval);
      }
      else {
        puts(&DAT_3f40389c);
      }
    }
  }
  else {
    puts(s_Arguments_invalides_3f40385c);
  }
  return 0;
}

We can now deal with the static data labels. Translating from the original French, s_Arguments_invalides_3f40385c points to a "Invalid arguments" string, DAT_3f403870 points to a "Congratulations, you can validate with %s" string, and DAT_3f40389c points to a "Sorry, try again!" string. Here is the decompiled code after renaming the constants:

Decompile: checkflag

int checkflag(int argc,char **argv)

{
  int result;
  
  result = arg_parse(argc,argv,&checkflag_args);
  if (result == 0) {
    if (checkflag_args->count == 0) {
      puts(INVALID_ARGUMENTS_MESSAGE);
    }
    else {
      result = verif(*checkflag_args->sval);
      if (result == 0) {
        printf(&FLAG_SUCCESS_MESSAGE,*checkflag_args->sval);
      }
      else {
        puts(&FLAG_FAILURE_MESSAGE);
      }
    }
  }
  else {
    puts(INVALID_ARGUMENTS_MESSAGE);
  }
  return 0;
}

What this function does should now be crystal clear:

  1. Parse the arguments given to the function, given the argument-parsing rules defined in app_main();
  2. If the parsing fails or the number of parsed arguments is zero, print an invalid arguments message and skip to step 5 (else continue to step 3);
  3. Call the verif() function with the parsed argument (*checkflag_args->sval is essentially checkflag_args->sval[0], and the Argtable documentation tells us that sval is a table of strings containing all parsed arguments);
  4. If the result returned by verif() is zero, then print a success message (indicating that the flag the user has entered is indeed the correct one), otherwise print a try again message;
  5. In any case, return zero.

Of course, the next function to look at is verif().

The flag verification function

The verif() function seems to be by far the longest and gnarliest so far:

Decompile: verif

byte verif(int param_1)

{
  byte bVar1;
  int iVar2;
  uint uVar3;
  int iVar4;
  uint uVar5;
  byte bVar6;
  uint uVar7;
  uint uVar8;
  byte local_50 [80];
  
  iVar2 = strlen(param_1);
  if (iVar2 == 0x18) {
    for (iVar2 = 0; iVar2 < 0x18; iVar2 = iVar2 + 1) {
      local_50[iVar2] = 0;
    }
    iVar2 = 0;
    uVar8 = 0x7f;
    while (iVar2 < 0xc0) {
      for (uVar7 = 0; (int)uVar7 < 8; uVar7 = uVar7 + 1) {
        uVar5 = uVar8 & 0x71;
        for (uVar3 = 1; (int)uVar3 < 8; uVar3 = uVar3 + 1) {
          uVar5 = (uVar5 ^ (int)(uVar8 & 0x71) >> (uVar3 & 0x1f)) & 0xff;
        }
        iVar4 = iVar2 + 7;
        if (-1 < iVar2) {
          iVar4 = iVar2;
        }
        local_50[iVar4 >> 3] =
             local_50[iVar4 >> 3] ^ (byte)((uVar8 & 1) << 0x20 - (0x20 - (uVar7 & 0x1f)));
        uVar8 = uVar8 >> 1 | (uVar5 & 1) << 7;
        iVar2 = iVar2 + 1;
      }
    }
    memcpy(local_50 + 0x18,&DAT_3f403840,0x18);
    breakpoint(0x1000,0x400d88a2,1,1);
    bVar6 = 0;
    for (iVar2 = 0; iVar2 < 0x18; iVar2 = iVar2 + 1) {
      bVar1 = local_50[iVar2] ^ local_50[iVar2 + 0x18];
      local_50[iVar2 + 0x18] = bVar1;
      bVar6 = bVar6 | bVar1 ^ *(byte *)(param_1 + iVar2);
    }
    bVar6 = bVar6 | local_50[0x18] != 'F' | local_50[0x19] != 'C' | local_50[0x1a] != 'S' |
            local_50[0x1b] != 'C';
  }
  else {
    bVar6 = 1;
  }
  return bVar6;
}

Let's start at the beginning. Since verif() is not part of any third-party open-source library or anything, we don't know what its signature is. We can make a pretty good guess though: it definitely does not take an int as a parameter! In fact, we know that it's called in checkflag() with a string (which also makes much more sense, since this function is supposed to be verifying the value of the flag entered by the user). Let's start by changing that, but leaving the return type alone for now, since we don't currently have any information about it:

Decompile: verif

byte verif(char *flag)

{
  byte bVar1;
  int iVar2;
  byte bVar3;
  uint uVar4;
  int iVar5;
  uint uVar6;
  uint uVar7;
  uint uVar8;
  byte local_50 [80];
  
  iVar2 = strlen(flag);
  if (iVar2 == 0x18) {
    for (iVar2 = 0; iVar2 < 0x18; iVar2 = iVar2 + 1) {
      local_50[iVar2] = 0;
    }
    iVar2 = 0;
    uVar8 = 0x7f;
    while (iVar2 < 0xc0) {
      for (uVar7 = 0; (int)uVar7 < 8; uVar7 = uVar7 + 1) {
        uVar6 = uVar8 & 0x71;
        for (uVar4 = 1; (int)uVar4 < 8; uVar4 = uVar4 + 1) {
          uVar6 = (uVar6 ^ (int)(uVar8 & 0x71) >> (uVar4 & 0x1f)) & 0xff;
        }
        iVar5 = iVar2 + 7;
        if (-1 < iVar2) {
          iVar5 = iVar2;
        }
        local_50[iVar5 >> 3] =
             local_50[iVar5 >> 3] ^ (byte)((uVar8 & 1) << 0x20 - (0x20 - (uVar7 & 0x1f)));
        uVar8 = uVar8 >> 1 | (uVar6 & 1) << 7;
        iVar2 = iVar2 + 1;
      }
    }
    memcpy(local_50 + 0x18,&DAT_3f403840,0x18);
    breakpoint(0x1000,0x400d88a2,1,1);
    bVar3 = 0;
    for (iVar2 = 0; iVar2 < 0x18; iVar2 = iVar2 + 1) {
      bVar1 = local_50[iVar2] ^ local_50[iVar2 + 0x18];
      local_50[iVar2 + 0x18] = bVar1;
      bVar3 = bVar3 | bVar1 ^ flag[iVar2];
    }
    bVar3 = bVar3 | local_50[0x18] != 'F' | local_50[0x19] != 'C' | local_50[0x1a] != 'S' |
            local_50[0x1b] != 'C';
  }
  else {
    bVar3 = 1;
  }
  return bVar3;
}

Again, taking it one step at a time, we can start by giving iVar2 a more meaningful name. Since it's used to store the return value of strlen(), I think flag_length is an apt name:

Decompile: verif

byte verif(char *flag)

{
  byte bVar1;
  int flag_length;
  byte bVar2;
  uint uVar3;
  int iVar4;
  uint uVar5;
  uint uVar6;
  uint uVar7;
  byte local_50 [80];
  
  flag_length = strlen(flag);
  if (flag_length == 0x18) {
    for (flag_length = 0; flag_length < 0x18; flag_length = flag_length + 1) {
      local_50[flag_length] = 0;
    }
    flag_length = 0;
    uVar7 = 0x7f;
    while (flag_length < 0xc0) {
      for (uVar6 = 0; (int)uVar6 < 8; uVar6 = uVar6 + 1) {
        uVar5 = uVar7 & 0x71;
        for (uVar3 = 1; (int)uVar3 < 8; uVar3 = uVar3 + 1) {
          uVar5 = (uVar5 ^ (int)(uVar7 & 0x71) >> (uVar3 & 0x1f)) & 0xff;
        }
        iVar4 = flag_length + 7;
        if (-1 < flag_length) {
          iVar4 = flag_length;
        }
        local_50[iVar4 >> 3] =
             local_50[iVar4 >> 3] ^ (byte)((uVar7 & 1) << 0x20 - (0x20 - (uVar6 & 0x1f)));
        uVar7 = uVar7 >> 1 | (uVar5 & 1) << 7;
        flag_length = flag_length + 1;
      }
    }
    memcpy(local_50 + 0x18,&DAT_3f403840,0x18);
    breakpoint(0x1000,0x400d88a2,1,1);
    bVar2 = 0;
    for (flag_length = 0; flag_length < 0x18; flag_length = flag_length + 1) {
      bVar1 = local_50[flag_length] ^ local_50[flag_length + 0x18];
      local_50[flag_length + 0x18] = bVar1;
      bVar2 = bVar2 | bVar1 ^ flag[flag_length];
    }
    bVar2 = bVar2 | local_50[0x18] != 'F' | local_50[0x19] != 'C' | local_50[0x1a] != 'S' |
            local_50[0x1b] != 'C';
  }
  else {
    bVar2 = 1;
  }
  return bVar2;
}

Let's try to understand a bit of what the code does before renaming more things. We can see that the top-level if-else branching checks whether the length of the flag string is 0x18 bytes; if so, then we continue through the function, otherwise we set bVar2 to one and return it. We can rename bVar2 to flag_is_incorrect, since it seems to be set to one when the flag does not have the right length, and back in checkflag() the success condition is for verif() to return zero. After that, we see that the first 0x18 (24) elements of local_50 are set to zero. Further down in the function, local_50 seems to be edited repeatedly, culminating in playing a role in the final value of bVar2. Specifically, we see code checking whether a part of local_50 contains the string "FCSC". But remember: the flag is a string of format FCSC{<something>}, so an early guess we could make here is that local_50 is the value that ultimately gets compared to the flag. This also makes sense at the conceptual level: this function's job is to check whether the flag is correct, but it obviously can't just strcmp() against the actual flag, since that would trivially reveal the correct value. So, in order to make reversing more difficult, it employs some (basic) obfuscation. With this tentative theory in mind, let's rename bVar2 and local_50:

Decompile: verif

byte verif(char *flag)

{
  byte bVar1;
  int flag_length;
  byte flag_is_incorrect;
  uint uVar2;
  int iVar3;
  uint uVar4;
  uint uVar5;
  uint uVar6;
  byte expected_flag [80];
  
  flag_length = strlen(flag);
  if (flag_length == 0x18) {
    for (flag_length = 0; flag_length < 0x18; flag_length = flag_length + 1) {
      expected_flag[flag_length] = 0;
    }
    flag_length = 0;
    uVar6 = 0x7f;
    while (flag_length < 0xc0) {
      for (uVar5 = 0; (int)uVar5 < 8; uVar5 = uVar5 + 1) {
        uVar4 = uVar6 & 0x71;
        for (uVar2 = 1; (int)uVar2 < 8; uVar2 = uVar2 + 1) {
          uVar4 = (uVar4 ^ (int)(uVar6 & 0x71) >> (uVar2 & 0x1f)) & 0xff;
        }
        iVar3 = flag_length + 7;
        if (-1 < flag_length) {
          iVar3 = flag_length;
        }
        expected_flag[iVar3 >> 3] =
             expected_flag[iVar3 >> 3] ^ (byte)((uVar6 & 1) << 0x20 - (0x20 - (uVar5 & 0x1f)));
        uVar6 = uVar6 >> 1 | (uVar4 & 1) << 7;
        flag_length = flag_length + 1;
      }
    }
    memcpy(expected_flag + 0x18,&DAT_3f403840,0x18);
    breakpoint(0x1000,0x400d88a2,1,1);
    flag_is_incorrect = 0;
    for (flag_length = 0; flag_length < 0x18; flag_length = flag_length + 1) {
      bVar1 = expected_flag[flag_length] ^ expected_flag[flag_length + 0x18];
      expected_flag[flag_length + 0x18] = bVar1;
      flag_is_incorrect = flag_is_incorrect | bVar1 ^ flag[flag_length];
    }
    flag_is_incorrect =
         flag_is_incorrect | expected_flag[0x18] != 'F' | expected_flag[0x19] != 'C' |
         expected_flag[0x1a] != 'S' | expected_flag[0x1b] != 'C';
  }
  else {
    flag_is_incorrect = 1;
  }
  return flag_is_incorrect;
}

Now, if this is indeed (basic) obfuscation, it's probably better to first try to split it into pieces. Let's try to analyze the first part, from the initialization of the first 24 bytes of expected_flag up until the call to memcpy(). We can see some and-ing, some shifting, and some XOR-ing using two constants: 0x7f and 0x71. These constants mean nothing to us (at least for now), so I will rename them to magic_byte_1 and magic_byte_2:

Decompile: verif

    for (flag_length = 0; flag_length < 0x18; flag_length = flag_length + 1) {
      expected_flag[flag_length] = 0;
    }
    flag_length = 0;
    magic_byte_1 = 0x7f;
    while (flag_length < 0xc0) {
      for (uVar4 = 0; (int)uVar4 < 8; uVar4 = uVar4 + 1) {
        magic_byte_2 = magic_byte_1 & 0x71;
        for (uVar2 = 1; (int)uVar2 < 8; uVar2 = uVar2 + 1) {
          magic_byte_2 = (magic_byte_2 ^ (int)(magic_byte_1 & 0x71) >> (uVar2 & 0x1f)) & 0xff;
        }
        iVar3 = flag_length + 7;
        if (-1 < flag_length) {
          iVar3 = flag_length;
        }
        expected_flag[iVar3 >> 3] =
             expected_flag[iVar3 >> 3] ^
             (byte)((magic_byte_1 & 1) << 0x20 - (0x20 - (uVar4 & 0x1f)));
        magic_byte_1 = magic_byte_1 >> 1 | (magic_byte_2 & 1) << 7;
        flag_length = flag_length + 1;
      }
    }

The only remaining unnamed variables here are uVar4, uVar2 and iVar3. Since the first two are used as loop indices, I will just rename them to i and j. For the last one, I'm not really sure what it does yet, but since it's used in bit-shifting operations near the end of this code chunk I will just rename it to offset:4

Decompile: verif

    for (flag_length = 0; flag_length < 0x18; flag_length = flag_length + 1) {
      expected_flag[flag_length] = 0;
    }
    flag_length = 0;
    magic_byte_1 = 0x7f;
    while (flag_length < 0xc0) {
      for (i = 0; (int)i < 8; i = i + 1) {
        magic_byte_2 = magic_byte_1 & 0x71;
        for (j = 1; (int)j < 8; j = j + 1) {
          magic_byte_2 = (magic_byte_2 ^ (int)(magic_byte_1 & 0x71) >> (j & 0x1f)) & 0xff;
        }
        offset = flag_length + 7;
        if (-1 < flag_length) {
          offset = flag_length;
        }
        expected_flag[offset >> 3] =
             expected_flag[offset >> 3] ^ (byte)((magic_byte_1 & 1) << 0x20 - (0x20 - (i & 0x1f)));
        magic_byte_1 = magic_byte_1 >> 1 | (magic_byte_2 & 1) << 7;
        flag_length = flag_length + 1;
      }
    }

Right. Now how do we figure out what this code is supposed to be doing? One way is to take a pen and paper and write down all the operations one by one, trying to discern a pattern and to understand the big picture of the obfuscation. That might be fun for some people, but I'd much rather use a computer to speed things up. We can just copy this code and turn it into an actual little program we can run, allowing us to experimentally observe what is going on. My first instinct here was to reach for Python, but I ended up deciding that C is a much better choice: first, this is more or less valid C, so we can more or less just copy and paste code, and second, Python does not have the same type semantics as C—for instance, Python ints don't overflow in the same way as in C—and so we can't be sure that the behavior of the Python code and the actual binary we're analyzing are equivalent.

That being said, let's create a verif.c file, which we'll use to play around with and understand the (de)obfuscation code:

verif.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>


typedef unsigned char byte;
typedef unsigned int uint;


void debug_print_string(char* name, unsigned char* string, size_t length) {
    printf("DEBUG: %s = \"", name);
    for (size_t i = 0; i < length; i++) {
        printf("%c", string[i]);
    }
    printf("\"\n");
}

byte verif(char* flag) {
    int flag_length;
    byte flag_is_incorrect;
    uint j;
    int offset;
    uint magic_byte_2;
    uint i;
    uint magic_byte_1;
    byte expected_flag[80];

    flag_length = strlen(flag);
    if (flag_length == 0x18) {
        for (flag_length = 0; flag_length < 0x18; flag_length = flag_length + 1) {
            expected_flag[flag_length] = 0;
        }
        flag_length = 0;
        magic_byte_1 = 0x7f;
        while (flag_length < 0xc0) {
            for (i = 0; (int)i < 8; i = i + 1) {
                magic_byte_2 = magic_byte_1 & 0x71;
                for (j = 1; (int)j < 8; j = j + 1) {
                    magic_byte_2 = (magic_byte_2 ^ (int)(magic_byte_1 & 0x71) >> (j & 0x1f)) & 0xff;
                }
                offset = flag_length + 7;
                if (-1 < flag_length) {
                    offset = flag_length;
                }
                expected_flag[offset >> 3] = expected_flag[offset >> 3] ^ (byte)(
                    (magic_byte_1 & 1) << 0x20 - (0x20 - (i & 0x1f))
                );
                magic_byte_1 = magic_byte_1 >> 1 | (magic_byte_2 & 1) << 7;
                flag_length = flag_length + 1;
            }
        }
        // TODO: handle the rest
        // NOTE: the line below is used to observe the value of `expected_flag`.
        debug_print_string("expected_flag", expected_flag, sizeof(expected_flag));
    }
    else {
        flag_is_incorrect = 1;
    }
    return flag_is_incorrect;
}

int main(void) {
    char* flag = "abcdefghijklmnopqrstuvwx";

    verif(flag);

    return 0;
}

Of course, I've had to include some headers, add some typedefs, and add a main() function for this to compile. I've also added a debug_print_string() function which we can use to inspect the content of expected_flag, and I've started by calling verif() with a string of a specific size, which is of course due to the top-level if-else statement in verif(): we know that if the flag is not precisely 24 bytes long, then the function will consider that it is incorrect. Running it yields slightly different results each time:

$ shell

$ gcc -o verif verif.c
$ ./verif
DEBUG: expected_flag = "BQ3ݩi1!>,k+ȈE"
$ ./verif
DEBUG: expected_flag = "BQ3ݩi1!>,k+Ȉ0"
$ ./verif
DEBUG: expected_flag = "BQ3ݩi1!>,k+8F7"

It seems that the first part of expected_flag stays the same, while the last part changes slightly with each execution. Based on what we've seen so far, this is expected: the first 24 bytes of expected_flag are initialized to zero, thus yielding deterministic results. However, the rest of the bytes (remember, it is 80 bytes long) are not: expected_flag is a stack variable, meaning that it is not stored in the BSS segment, meaning that it is not automatically initialized to zero (it just inherits whatever values are found on the stack at this point).

The next step is the call to memcpy(). How do we translate that to our C program? The quick and dirty way of doing it is to just copy whatever data is found at the source address in Ghidra, make an array with it in our C program, and use that as the source for the memcpy(). While doing so, we can also take the time to rename the DAT_3f403840 constant pointing to that memory address—I'll call it OBFUSCATED_DATA:

Decompile: verif

    memcpy(expected_flag + 0x18,&OBFUSCATED_DATA,0x18);

Now, to copy the data, we can double-click on OBFUSCATED_DATA in the memcpy() call, then select all of the data (right up to just before the next symbol in memory, which should be INVALID_ARGUMENTS_MESSAGE), then right-click and select Copy Special. In the menu which appears, we need to select C Array and then click OK. Now the data should be in our clipboard, and we can just paste it into verify.c We can then also go ahead and add the memcpy() call to verify.c:

verif.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>


typedef unsigned char byte;
typedef unsigned int uint;


byte OBFUSCATED_DATA[] = {
    0x39, 0x03, 0xb2, 0xef, 0xf8, 0x0e, 0x71, 0xb4, 0xdb, 0x93, 0xf6, 0x51, 0x3a, 0x62, 0xa6, 0xe8,
    0x64, 0x9b, 0x81, 0x04, 0x01, 0x42, 0x74, 0xb5, 0x00, 0x00, 0x00, 0x00
};

void debug_print_string(char* name, unsigned char* string, size_t length) {
    printf("DEBUG: %s = \"", name);
    for (size_t i = 0; i < length; i++) {
        printf("%c", string[i]);
    }
    printf("\"\n");
}

byte verif(char* flag) {
    int flag_length;
    byte flag_is_incorrect;
    uint j;
    int offset;
    uint magic_byte_2;
    uint i;
    uint magic_byte_1;
    byte expected_flag[80];

    flag_length = strlen(flag);
    if (flag_length == 0x18) {
        for (flag_length = 0; flag_length < 0x18; flag_length = flag_length + 1) {
            expected_flag[flag_length] = 0;
        }
        flag_length = 0;
        magic_byte_1 = 0x7f;
        while (flag_length < 0xc0) {
            for (i = 0; (int)i < 8; i = i + 1) {
                magic_byte_2 = magic_byte_1 & 0x71;
                for (j = 1; (int)j < 8; j = j + 1) {
                    magic_byte_2 = (magic_byte_2 ^ (int)(magic_byte_1 & 0x71) >> (j & 0x1f)) & 0xff;
                }
                offset = flag_length + 7;
                if (-1 < flag_length) {
                    offset = flag_length;
                }
                expected_flag[offset >> 3] = expected_flag[offset >> 3] ^ (byte)(
                    (magic_byte_1 & 1) << 0x20 - (0x20 - (i & 0x1f))
                );
                magic_byte_1 = magic_byte_1 >> 1 | (magic_byte_2 & 1) << 7;
                flag_length = flag_length + 1;
            }
        }
        memcpy(expected_flag + 0x18,&OBFUSCATED_DATA,0x18);
        // TODO: handle the rest
        // NOTE: the line below is used to observe the value of `expected_flag`.
        debug_print_string("expected_flag", expected_flag, sizeof(expected_flag));
    }
    else {
        flag_is_incorrect = 1;
    }
    return flag_is_incorrect;
}

int main(void) {
    char* flag = "abcdefghijklmnopqrstuvwx";

    verif(flag);

    return 0;
}

Before moving on, it would be interesting to see what effect the input (flag) has on expected_flag. In order to do that, we can write a little function generating random 24-character strings which we will then feed to verif():

verif.c

#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>

// ... [omitted] ...

char* random_string(size_t length) {
    char* string = malloc((length + 1) * sizeof(char));
    if (!string) return NULL;

    for (size_t i = 0; i < length; i++) {
        string[i] = 1 + rand() / ((RAND_MAX + 1u) / CHAR_MAX);
    }
    string[length] = '\0';

    return string;
}

// ... [omitted] ...

int main(void) {
    srand(time(NULL));
    char* flag = random_string(0x18);
    if (!flag) return 1;

    verif(flag);
    free(flag);

    return 0;
}

Compiling and running it gives this result:

$ shell

$ gcc -o verif verif.c
$ ./verif
DEBUG: expected_flag = "BQ3ݩi1!>,k+9qۓQ:bdBtM\L\Z"
$ ./verif
DEBUG: expected_flag = "BQ3ݩi1!>,k+9qۓQ:bdBt@&2Ys["
$ ./verif
DEBUG: expected_flag = "BQ3ݩi1!>,k+9qۓQ:bdBtMJʒ`"

We observe more or less the same effect as before: a first part of expected_flag remains stable, while the rest of it differs between executions, because of the random values inherited from the stack memory. Let's move on.

The next instruction to treat is a breakpoint(). There is no immediately obvious way to handle this right now, so let's just skip over it. I'm sure it has nothing to do with the flag.

The last part of the (de)obfuscation code uses yet another variable, bVar1, which seems to be XOR-ed around. Again, I don't have a good name for it, so I'll just call it hash:

Decompile: verif

    flag_is_incorrect = 0;
    for (flag_length = 0; flag_length < 0x18; flag_length = flag_length + 1) {
      hash = expected_flag[flag_length] ^ expected_flag[flag_length + 0x18];
      expected_flag[flag_length + 0x18] = hash;
      flag_is_incorrect = flag_is_incorrect | hash ^ flag[flag_length];
    }
    flag_is_incorrect =
         flag_is_incorrect | expected_flag[0x18] != 'F' | expected_flag[0x19] != 'C' |
         expected_flag[0x1a] != 'S' | expected_flag[0x1b] != 'C';

To wrap up, let's copy the code over to verif.c:

verif.c

#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>


typedef unsigned char byte;
typedef unsigned int uint;


byte OBFUSCATED_DATA[] = {
    0x39, 0x03, 0xb2, 0xef, 0xf8, 0x0e, 0x71, 0xb4, 0xdb, 0x93, 0xf6, 0x51, 0x3a, 0x62, 0xa6, 0xe8,
    0x64, 0x9b, 0x81, 0x04, 0x01, 0x42, 0x74, 0xb5, 0x00, 0x00, 0x00, 0x00
};

char* random_string(size_t length) {
    char* string = malloc((length + 1) * sizeof(char));
    if (!string) return NULL;

    for (size_t i = 0; i < length; i++) {
        string[i] = 1 + rand() / ((RAND_MAX + 1u) / CHAR_MAX);
    }
    string[length] = '\0';

    return string;
}

void debug_print_string(char* name, unsigned char* string, size_t length) {
    printf("DEBUG: %s = \"", name);
    for (size_t i = 0; i < length; i++) {
        printf("%c", string[i]);
    }
    printf("\"\n");
}

byte verif(char* flag) {
    int flag_length;
    byte flag_is_incorrect;
    uint j;
    int offset;
    uint magic_byte_2;
    uint i;
    uint magic_byte_1;
    byte expected_flag[80];
    byte hash;

    flag_length = strlen(flag);
    if (flag_length == 0x18) {
        for (flag_length = 0; flag_length < 0x18; flag_length = flag_length + 1) {
            expected_flag[flag_length] = 0;
        }
        flag_length = 0;
        magic_byte_1 = 0x7f;
        while (flag_length < 0xc0) {
            for (i = 0; (int)i < 8; i = i + 1) {
                magic_byte_2 = magic_byte_1 & 0x71;
                for (j = 1; (int)j < 8; j = j + 1) {
                    magic_byte_2 = (magic_byte_2 ^ (int)(magic_byte_1 & 0x71) >> (j & 0x1f)) & 0xff;
                }
                offset = flag_length + 7;
                if (-1 < flag_length) {
                    offset = flag_length;
                }
                expected_flag[offset >> 3] = expected_flag[offset >> 3] ^ (byte)(
                    (magic_byte_1 & 1) << 0x20 - (0x20 - (i & 0x1f))
                );
                magic_byte_1 = magic_byte_1 >> 1 | (magic_byte_2 & 1) << 7;
                flag_length = flag_length + 1;
            }
        }
        memcpy(expected_flag + 0x18,&OBFUSCATED_DATA,0x18);
        // TODO: handle the line below somehow.
        //breakpoint(0x1000,0x400d88a2,1,1);
        flag_is_incorrect = 0;
        for (flag_length = 0; flag_length < 0x18; flag_length = flag_length + 1) {
            hash = expected_flag[flag_length] ^ expected_flag[flag_length + 0x18];
            expected_flag[flag_length + 0x18] = hash;
            flag_is_incorrect = flag_is_incorrect | hash ^ flag[flag_length];
        }
        flag_is_incorrect =
             flag_is_incorrect | expected_flag[0x18] != 'F' | expected_flag[0x19] != 'C' |
             expected_flag[0x1a] != 'S' | expected_flag[0x1b] != 'C';
        // NOTE: the line below is used to observe the value of `expected_flag`.
        debug_print_string("expected_flag", expected_flag, sizeof(expected_flag));
    }
    else {
        flag_is_incorrect = 1;
    }
    return flag_is_incorrect;
}

int main(void) {
    srand(time(NULL));
    char* flag = random_string(0x18);
    if (!flag) return 1;

    verif(flag);
    free(flag);

    return 0;
}

Before we compile and run this program, what do we expect to get? Let's recall our current theory: this function ultimately compares a candidate flag value (passed as an argument) with the actual flag value, and returns zero if they match, and one if they don't. Since a direct comparison (e.g., via strcmp()) would immediately give the flag away, (basic) obfuscation is used. In fact, we can even see that the line flag_is_incorrect = flag_is_incorrect | hash ^ flag[flag_length] essentially checks that every character of the candidate flag matches the current hash, which itself is just an XOR of two parts of expected_flag, leading overall to a sort of equality check for every character of flag and expected_flag. It follows that, by inspecting the value of expected_flag at the very end of the comparison, we should be able to finally lay eyes on the flag! Let's try it:

$ shell

$ gcc -o verif verif.c
$ ./verif
DEBUG: expected_flag = "BQ3ݩi1!>,k+FAKE{_BIEN_ESSAYE!_:-)_}Fq\"

I'm sure you can tell that something is up, but to make it very clear, amongst the garbled nonsensical characters that are being printed there is the string FAKE{_BIEN_ESSAYE!_:-)_}, which translates to FAKE{_NICE_TRY!_:-)_}. That's obviously not the flag! It feels like we're so close, though… maybe it's time to look at that breakpoint?

It was the breakpoint all along

How do we handle the breakpoint, though? It's not a function per se; so, what is it? The Decompile window is cool and all, but sometimes we need to look at the assembly in the Listing window to get a more accurate picture. Single-clicking on the breakpoint() call in the Decompile window highlights the following line in the Listing window:

Listing: extension.elf

        400d88a2 10 41 00        break      0x1,0x1

Hm, time to hit the books. The Xtensa ISA documentation Section 10.7.1 says (bold text mine):

The break (24-bit) instruction has two immediate 4-bit operands, and the break.n (narrow, 16-bit) instruction has one immediate 4-bit operand. These operands (informally called break codes in this section) can be used to convey relevant information to the debug exception handler. Their exact meaning is a matter of convention. However, some of the tools and software (debuggers, OS ports, and so forth) used with Xtensa cores necessarily make use of the break instructions, so some conventions had to be established. The conventions that have been adopted are described in this section.

Reading further down in the section, it seems that break 1,1 has the following description:

Signals an unhandled level 1 user exception

This comes back to how microcontrollers work. In microcontrollers (such as the ESP32), the CPU will often start executing code at a fixed address. It is the programmer's responsibility5 to provide code (commonly called startup code), setting up what is known as the Interrupt Vector Table. The latter is just a table of function pointers, which point to different handler functions, to which the CPU will automatically jump to when certain exceptions occur. For instance, every microcontroller has a hard fault handler equivalent, which—as the name suggests—deals with hard faults, which are generally unrecoverable failure states (such as an integer division by zero), and usually just reboots the microcontroller.

Similarly, the break instruction causes some sort of exception at the CPU level, which, as the docs seem to indicate, will lead to the debug exception handler being called. In order to get further with this piece of information, we can look around the available documentation (both the Xtensa ISA docs and the ESP32 docs). The ESP32 docs have a page on High Priority Interrupts which seems to be referring to this exact scenario:

Priority Level Symbol Remark
dbg xt_debugexception Debug exception. Called on e.g., a BREAK instruction.

Sure enough, if we look up the xt_debugexception symbol in Ghidra's Symbol Tree window, we come across something that looks suspiciously like the missing piece to our (de)obfuscation puzzle:

Decompile: xt_debugexception

void xt_debugexception(undefined1 *param_1)

{
  *param_1 = *param_1;
  param_1[1] = param_1[1] ^ 2;
  param_1[2] = param_1[2] ^ 0x18;
  param_1[3] = param_1[3] ^ 6;
  param_1[4] = param_1[4];
  param_1[5] = param_1[5] ^ 0x66;
  param_1[6] = param_1[6] ^ 0x27;
  param_1[7] = param_1[7] ^ 0x7b;
  param_1[8] = param_1[8] ^ 0x76;
  param_1[9] = param_1[9] ^ 0x2a;
  param_1[10] = param_1[10] ^ 0x69;
  param_1[0xb] = param_1[0xb] ^ 0x26;
  param_1[0xc] = param_1[0xc] ^ 0x35;
  param_1[0xd] = param_1[0xd] ^ 0x35;
  param_1[0xe] = param_1[0xe] ^ 0x75;
  param_1[0xf] = param_1[0xf] ^ 0x69;
  param_1[0x10] = param_1[0x10] ^ 0x70;
  param_1[0x11] = param_1[0x11] ^ 0x45;
  param_1[0x12] = param_1[0x12] ^ 0x3e;
  param_1[0x13] = param_1[0x13] ^ 0xd;
  param_1[0x14] = param_1[0x14] ^ 0x19;
  param_1[0x15] = param_1[0x15] ^ 0x1a;
  param_1[0x16] = param_1[0x16] ^ 0x6b;
  param_1[0x17] = param_1[0x17];
  return;
}

But, what is param_1 here, and of what type is it? Looking on various search engines for the signature of this function yields nothing. So how can we know what specific part of memory is being used here? Let's rewind a little bit. Remember what the instructions right before the break instructions were doing? Those instructions were used to carry out a call to memcpy(), which in turn copied some (obfuscated) data to the expected_flag buffer. Specifically, expected_flag + 0x18 seems to be stored in register a6 right before the call to memcpy(). Then, right before the break instruction, we see a mov.n a2, a6 instruction, and Section 10.1.1, table 260 of the Xtensa ISA documentation says that a2 is used to store the first argument to the called function. Putting all of this together, it seems that the param_1 argument passed to xt_debugexception is indeed expected_flag.

All that remains now, is to first copy this code to our verif.c file (taking care to replace breakpoint() by a simple call to xt_debugexception()), compile, and run it:

$ shell

$ gcc -o verif verif.c
$ ./verif
DEBUG: expected_flag = "@ᬃ72\Q35q@FCSC{9e23d6cff405da7434}er%Uc"

And, checking on the Hackropole website, FCSC{9e23d6cff405da7434} is actually the right flag!

Testing on an actual ESP32

As a matter of fact, I happen to own an ESP32, so we can test with the actual board! I won't go through the whole process (the ESP32 docs describe it pretty well), but obviously you need to install the ESP32 toolchain to be able to program and flash the microcontroller itself.

What the challenge gives us is an ELF file, which is not what the microcontroller takes as input when programmed. It actually needs to be a binary file (often called a binary blob), and the ESP32 toolchain has a nifty little tool to do exactly this:

$ shell

$ esptool.py --chip esp32 elf2image extension.elf
esptool.py v4.12.dev1
Creating esp32 image...
Merged 2 ELF sections
Successfully created esp32 image.

This creates an extension.bin file, and we can use esptool.py to flash it onto the ESP32:

$ shell

$ esptool.py --chip esp32 --port /dev/ttyUSB0 write_flash 0x10000 extension.bin
esptool.py v4.12.dev1
Serial port /dev/ttyUSB0
Connecting....
Chip is ESP32-D0WD-V3 (revision v3.0)
Features: WiFi, BT, Dual Core, 240MHz, VRef calibration in efuse, Coding Scheme None
Crystal is 40MHz
MAC: xx:xx:xx:xx:xx:xx
Uploading stub...
Running stub...
Stub running...
Configuring flash size...
Flash will be erased from 0x00010000 to 0x0004ffff...
Compressed 258272 bytes to 143402...
Wrote 258272 bytes (143402 compressed) at 0x00010000 in 12.7 seconds (effective 162.6 kbit/s)...
Hash of data verified.

Leaving...
Hard resetting via RTS pin...

We indeed want to write into the flash memory (as mentioned on the challenge page itself, this is supposedly a flash dump), and the 0x10000 address can be found by looking at the partition tables or simply the example commands in the ESP32 documentation.

Now that the firmware should be flashed onto the ESP32, we can use something like minicom to communicate with the device. The baud rate to use is actually found in the decompiled app_main(): if we double-click on the renamed UART_BAUD_RATE constant, we see a value of 115200. Let's try it out:

$ shell

$ minicom -D /dev/ttyUSB0 -b 115200

Welcome to minicom 2.9

OPTIONS: I18n
Port /dev/ttyUSB0, 20:05:36

Press CTRL-A Z for help on special keys

Hm, nothing shows up? Let me hit the reset button on the board…

$ shell

rst:0x1 (POWERON_RESET),boot:0x13 (SPI_FAST_FLASH_BOOT)
configsip: 0, SPIWP:0xee
clk_drv:0x00,q_drv:0x00,d_drv:0x00,cs0_drv:0x00,hd_drv:0x00,wp_drv:0x00
mode:DIO, clock div:2
load:0x3fff0030,len:6280
load:0x40078000,len:15864
load:0x40080400,len:3920
entry 0x40080644
I (27) boot: ESP-IDF v5.5.3-dirty 2nd stage bootloader
I (27) boot: compile time Feb 22 2026 18:49:15
I (28) boot: Multicore bootloader
I (29) boot: chip revision: v3.0
I (32) boot.esp32: SPI Speed      : 40MHz
I (36) boot.esp32: SPI Mode       : DIO
I (39) boot.esp32: SPI Flash Size : 2MB
I (43) boot: Enabling RNG early entropy source...
I (47) boot: Partition Table:
I (50) boot: ## Label            Usage          Type ST Offset   Length
I (56) boot:  0 nvs              WiFi data        01 02 00009000 00006000
I (63) boot:  1 phy_init         RF data          01 01 0000f000 00001000
I (69) boot:  2 factory          factory app      00 00 00010000 00100000
I (76) boot: End of partition table
I (79) esp_image: segment 0: paddr=00010020 vaddr=3f400020 size=0bcf4h ( 48372) map
I (103) esp_image: segment 1: paddr=0001bd1c vaddr=3ff80000 size=0001ch (    28) load
I (104) esp_image: segment 2: paddr=0001bd40 vaddr=3ffb0000 size=023bch (  9148) load
I (111) esp_image: segment 3: paddr=0001e104 vaddr=40080000 size=01f14h (  7956) load
I (118) esp_image: segment 4: paddr=00020020 vaddr=400d0020 size=232c0h (144064) map
I (171) esp_image: segment 5: paddr=000432e8 vaddr=40081f14 size=0bdd0h ( 48592) load
I (197) boot: Loaded app from partition at offset 0x10000
I (197) boot: Disabling RNG early entropy source...
I (208) cpu_start: Multicore app
I (216) cpu_start: Pro cpu start user code
I (216) cpu_start: cpu freq: 160000000 Hz
I (216) app_init: Application information:
I (216) app_init: Project name:     console
I (220) app_init: App version:      a05ec3b-dirty
I (225) app_init: Compile time:     Mar 23 2025 00:11:39
I (230) app_init: ELF file SHA256:  8e14da6d2...
I (234) app_init: ESP-IDF:          HEAD-HASH-NOTFOUND
I (239) efuse_init: Min chip rev:     v0.0
I (243) efuse_init: Max chip rev:     v3.99
I (247) efuse_init: Chip rev:         v3.0
I (251) heap_init: Initializing. RAM available for dynamic allocation:
I (257) heap_init: At 3FFAE6E0 len 00001920 (6 KiB): DRAM
I (262) heap_init: At 3FFB2CE0 len 0002D320 (180 KiB): DRAM
I (267) heap_init: At 3FFE0440 len 00003AE0 (14 KiB): D/IRAM
I (273) heap_init: At 3FFE4350 len 0001BCB0 (111 KiB): D/IRAM
I (278) heap_init: At 4008DCE4 len 0001231C (72 KiB): IRAM
I (285) spi_flash: detected chip: generic
I (287) spi_flash: flash io: dio
W (290) spi_flash: Detected size(4096k) larger than the size in the binary image header(2048k). Using the size in the binary image header.
I (303) main_task: Started on CPU0
I (313) main_task: Calling app_main()

Type 'help' to get the list of commands.
Use UP/DOWN arrows to navigate through command history.
Press TAB when typing command name to auto-complete.
I (363) main_task: Returned from app_main()
FCSC>

Hey, we got the FCSC prompt! And finally, let's try out the flag:

$ shell

FCSC> checkflag FCSC{9e23d6cff405da7434}
Félicitations, tu peux valider avec FCSC{9e23d6cff405da7434}

The message roughly translates to Congratulations, you can validate [the flag on the Hackropole website] with FCSC{9e23d6cff405da7434}, so we get another confirmation at the microcontroller level!

This has been so fun to hack on for me, thanks for reading all the way to the end! I hope I'll find some time to hack on some more embedded challenges like this in the near future :)


  1. To my surprise, Wikipedia translates the French terms mémoire vive (live memory) and mémoire morte (dead memory) to RAM and ROM respectively, which is a shame, since ROM is a type of dead (i.e., persistent across reboots) memory, but not the only type. I really think the French terms are superior, since they only characterize the (non-)persistence of memory contents after a reboot.

  2. Technically, most if not all microcontroller boards sport some form of basic input/output, usually in the form of small buttons and LEDs, but these are mostly for testing blinky-type programs and resetting the microcontroller in case of some fault.

  3. Note that UART is at the data link layer, so even without USB we would need something at the physical layer in addition. The choice of USB here is most likely for convenience.

  4. Note that for my version of Ghidra (12.0.3, Build PUBLIC, 2026-Feb-10 1331 EST, Java Version 21.0.10) this renaming somehow has no effect in the Decompile window. I did some searching and this seems to be an issue that pops up now and again, but I don't feel like looking into it, so I renamed the variables in the code snippets of this post but do know that I still had to look at iVar3 (actually, iVar2; its name changed after I renamed some other stuff first) for the rest of the project :(

  5. In reality, most (if not all) vendors provide such startup scripts to improve the developer experience of writing firmware (and since these things are generally very much standardized). In my experience, editing a startup script by hand is mostly done for educational purposes or to further optimize, for example, the footprint (size) of the firmware.