Extensif chall from ECSC 2026 writeup
you can check the challenge here, difficulty is 1 star.
TL;DR
reversing an esp32 app image that does basic flag checking
Solve
we are given a file extensif.bin running file on it gives us this:
$ file extensif.bin
extensif.bin: ESP-IDF application image for ESP32, project name: "extensif", version 1, compiled on Mar 24 2026 15:29:16, IDF version: v5.5.1, entry address: 0x400811E8
it is not an elf, googling ESP-IDF i find this github list which contains a lot of resources on esp32 reversing, i spent some time browsing it and trying different stuff until i came up against this ghidra extension, which is used to load esp32 flash dumps, using it i can load extensif.bin successfully into ghidra.
when first loading the binary i find my self in main which is not very helpfull since the main is not of the flag checker rather it is of whatever runs on esp32 when it boots (apparently it’s freertos, i grepped for it inside the binary), so to find the user code, i opened Windows->Defined Strings and searched for the flag format FCSC.
i found this:
getting the xrefs for this string i find my self at the function that appears to be the main user code
void UndefinedFunction_400d6000(void)
{
code *pcVar1;
undefined *puVar2;
undefined *puVar3;
int iVar4;
int iVar5;
char acStack_51 [33];
byte abStack_30 [48];
puVar2 = PTR_s_Bienvenue_400d0698;
func_0x400d8590(puVar2);
for (iVar5 = 0; iVar5 < 4; iVar5 = iVar5 + 1) {
iVar4 = iVar5 * 4;
puVar2 = PTR_DAT_400d069c;
puVar2[iVar5 * 4] = 0x46;
puVar2[iVar4 + 1] = 0x43;
puVar2[iVar4 + 2] = 0x53;
puVar2[iVar4 + 3] = 0x43;
}
func_0x400d60e4();
for (iVar5 = 0; iVar5 < 0x10; iVar5 = iVar5 + 1) {
puVar3 = PTR_BYTE_400d06a0;
puVar2 = PTR_DAT_400d069c;
abStack_30[iVar5] = puVar3[iVar5] ^ puVar2[iVar5];
}
puVar2 = PTR_s_Entrez_le_flag_(16_chars):_400d06a4;
func_0x400d8438(puVar2);
iVar5 = func_0x40088434();
pcVar1 = (code *)PTR_fflush_400d06b8;
(*pcVar1)(*(undefined4 *)(iVar5 + 8));
iVar5 = func_0x40088434();
iVar5 = func_0x400d8048(acStack_51 + 1,0x20,*(undefined4 *)(iVar5 + 4));
if (iVar5 == 0) {
puVar2 = PTR_s_Erreur_de_lecture_400d06a8; // -- (1)
func_0x400d8590(puVar2);
}
else {
pcVar1 = (code *)PTR_strlen_400d0660;
iVar5 = (*pcVar1)(acStack_51 + 1);
if (iVar5 != 0) {
if (acStack_51[iVar5] == '\n') {
acStack_51[iVar5] = '\0';
iVar5 = iVar5 + -1;
}
if (iVar5 == 0x10) {
pcVar1 = (code *)PTR_memcmp_400d067c;
iVar5 = (*pcVar1)(acStack_51 + 1,abStack_30,0x10);
if (iVar5 == 0) {
func_0x400d8504(10);
puVar2 = PTR_DAT_400d06b0;
func_0x400d8438(puVar2,acStack_51 + 1);
return;
}
puVar2 = PTR_s_[-]_Essaie_encore_400d06b4;
func_0x400d8590(puVar2);
return;
}
}
puVar2 = PTR_s_[-]_Longueur_invalide._400d06ac;
func_0x400d8590(puVar2);
}
return;
}
as you can see, it’s pretty bad decompilation, one reason for this is that the architecture of the esp32 Xtensa uses 24 or 16 bit instructions sizes which means you need more than one instruction to assgin an address to registers, so what the compiler does is it puts addresses into nearby memory locations so they can be addressed relatively (got this from here).
anyway, this program is reading a 0x10 long flag into acStack_51 + 1 and memcmp’ing it with abStack_30 so we just need to figure out what’s in it, i used error messages to figure out what it’s doing for example it’s trying to print the message PTR_s_Erreur_de_lecture_400d06a8 which obviously means the function before tries to read input.
the program tries to write the xor of PTR_BYTE_400d06a and PTR_DAT_400d069c into abStack_30 in here:
for (iVar5 = 0; iVar5 < 0x10; iVar5 = iVar5 + 1) {
puVar3 = PTR_BYTE_400d06a0;
puVar2 = PTR_DAT_400d069c;
abStack_30[iVar5] = puVar3[iVar5] ^ puVar2[iVar5];
}
PTR_BYTE_400d06a0 is constant and PTR_DAT_400d069c is set just above it, first it’s set to b’FCSCFCSCFCSCFCSC’ using:
for (iVar5 = 0; iVar5 < 4; iVar5 = iVar5 + 1) {
iVar4 = iVar5 * 4;
puVar2 = PTR_DAT_400d069c;
puVar2[iVar5 * 4] = 0x46;
puVar2[iVar4 + 1] = 0x43;
puVar2[iVar4 + 2] = 0x53;
puVar2[iVar4 + 3] = 0x43;
}
then the first 12 bytes are overwritten by the call to func_0x400d60e4
inspecting func_0x400d60e4
we see it’s decompilation and disassembly:
it calls func_0x400d60f4 with 0x4a and PTR_DAT_400d069c
here is the code for func_0x400d60f4
it writes the first argument into the first byte of the second argument and it writes the first byte of the return address into the second byte and incremnts the first arg by one and second arg by 2 and keeps calling it self recursively until arg1 & 8 == 0
getting the return addresses is easy, the return address is the address of the instrution after the call instruction, which means:
for the first time when it’s called by func_0x400d60e4
400d60ec 65 00 00 call8 SUB_400d60f4
here->400d60ef 1d f0 retw.n
we can see that it’s 0xef and for the rest it’s 0x08
400d6105 e5 fe ff call8 SUB_400d60f4
LAB_400d6108 XREF[1]: 400d60f7(j)
here->400d6108 1d f0 retw.n
now tracing the execution of this function we can deduce that the final value of PTR_DAT_400d069c is: 0x4a,0xef, 0x4b, 0x8, 0x4c, 0x8, 0x4d, 0x8, 0x4e, 0x8, 0x4f, 0x8, 0x46, 0x43, 0x53, 0x43 and the extracted PTR_BYTE_400d06a0 is: 0x79, 0x8D, 0x2D, 0x3E, 0x2E, 0x6E, 0x79, 0x6D, 0x28, 0x38, 0x2D, 0x38, 0x77, 0x75, 0x60, 0x21
now we can get the final flag using this script:
data_400d069c = [0x4a,0xef, 0x4b, 0x8, 0x4c, 0x8, 0x4d, 0x8, 0x4e, 0x8, 0x4f, 0x8, 0x46, 0x43, 0x53, 0x43]
data_400d06a0 = [0x79, 0x8D, 0x2D, 0x3E, 0x2E, 0x6E, 0x79, 0x6D, 0x28, 0x38, 0x2D, 0x38, 0x77, 0x75, 0x60, 0x21]
r = []
for i in range(0x10):
r.append(data_400d069c[i]^data_400d06a0[i])
print(b'FCSC{'+bytes(r)+b'}')
our final flag is : FCSC{3bf6bf4ef0b0163b}