Contents

BlackAlps 2025 CTF - s4f3

s4f3

With Phil242, we concentrated on this interesting hardware challenge of Karim.

Description

/images/blackalps25-s4f3.png

A binary challenge.elf is provided.

Reconnaissance with AI

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

I try to get some initial thoughts with r2mcp. It doesn’t help me very much, but gives me some idea about the context:

  1. The flag is formatted BA25{.....} and it is redacted in the binary
  2. The binary was developed using Arduino

My initial prompt:

1
Help me understand ./challenge.elf which is a Hardware challenge CTF named s4f3. That's what the server says: I lost the combination to access my device. Can you help recover it ?

The LLM tries to list functions with the following names. It’s a good idea, but there are no matches:

1
2
3
4
{
  "only_named": true,
  "filter": "main|combination|access|device|password|pin|code|check|unlock|verify"
}

Then, it searches for typical Arduino functions - which is an excellent idea (calls r2mcp::list_functions). For a reason I don’t understand, it doesn’t find them (the analysis with aa was done previously, it should have succeeded).

1
2
3
4
{
  "only_named": true,
  "filter": "^(main|setup|loop|app_main)"
}

The rest of its attempts are quite unsuccessful, I stop and resort to manual radare2 investigation. I find both the loop() and the setup() function.

1
2
3
4
5
[0x40375d5c]> afl~loop
0x4200a81c    8     71 dbg.loopTask(void*)
0x42002ed0    5    126 dbg.loop()
0x403785fc    1     20 dbg.rmt_isr_handle_tx_loop_end
0x42032c58   33    282 dbg.esp_event_loop_delete

loop() is located at 0x42002ed0 - we locate a few typical calls at the end of it to handleTouch(), i2cRead() etc - and loopTask()is actually a system function that callssetup()and thenloop()`.

NameAddress
loop0x42002ed0
setup0x42002c50

Decompiling setup

With r2ai, I decompile setup. As this is presumed C code generated by AI, it might not be entirely correct (it turns out to be quite good).

We see that:

  1. It prints a MAC address
  2. Does some typical Arduino / ESP32 setup (Wire.begin etc)
  3. Calls drawScene
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
int setup(int a2, int a3) {
    unsigned long stackChkGuard;
    char mac_str[17];
    char str[16];
    
    memw();
    stackChkGuard = __stack_chk_guard;
    
    __analogRead(0);
    randomSeed(__analogRead(0));
    
    memset(mac_str, 0, 17);
    sprintf(mac_str, "%08X%08X", MAC[0], MAC[1]);
    
    __pinMode(48, 5);
    
    Wire.begin(42, 41, 0x61a80);
    Wire.setTimeOut(50);
    tpReset();
    
    ledcAttach(46, 1000, 10);
    ledcWrite(46, 1000);
    
    for (int i = 0; i < 4; i++) {
        cyl[i] = random(255);
    }
    
    gfx->fillScreen(COL_BG);
    gfx->setTextSize(2);
    
    String str_mac(mac_str);
    printCenteredX(str_mac, 140);
    str_mac.~String();
    
    delay(5000);
    setupLayout();
    drawScene();
    
    if (stackChkGuard != __stack_chk_guard) {
        __stack_chk_fail();
    }
    
    return 0;
}

Decompiling drawScene

Same, I decompile drawScene with r2ai. This function turns out to be quite central for the challenge.

  1. Do some manipulation with the MAC address to take only some portions.
  2. XOR the 4 byte MAC address with a secret, and check they match a value on a cylinder
  3. Toast the “Access Granted” or “Access Denied” message.

Seeing the device helps understand what the cylinders are. The device is a small ESP32 device with an LCD screen on which there are 4 cylinder-based entries to select a hexadecimal value. Once all 4 hex values have been selected, we can validate the entry and get the access message.

/images/blackalps25-device.jpg

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
void drawScene(void) {
    uint8_t mac[4];
    int32_t *stack_guard_ptr = __stack_chk_guard;
    int32_t stack_guard_val;
    
    // Save stack check value
    memw();
    stack_guard_val = *stack_guard_ptr;
    memw();
    
    // Get MAC address
    uint32_t mac_lo = MAC__1[0];
    uint32_t mac_hi = MAC__1[1];
    
    mac[0] = (mac_hi >> 8);
    mac[3] = mac_lo;
    mac[1] = mac_hi;
    mac[2] = (mac_hi >> 16);

    if (showToast) {
        int cyl_valid = 1;
        
        // Verify the cylinder values
        for (int i = 0; i < 4; i++) {
            uint8_t secret_byte = SECRET[i];
            uint8_t mac_byte = mac[i];
            uint8_t xor_result = secret_byte ^ mac_byte;
            uint8_t cyl_val = cyl[i*2];
            
            if (cyl_val != xor_result) {
                cyl_valid = 0;
            }
        }
        
        // Draw graphics based on authentication result
        Arduino_GFX *gfx = gfx;
        gfx->fillScreen(COL_BG);
        gfx->setTextSize(2);
        
        if (cyl_valid) {
            // Access granted
            gfx->setTextColor(COL_YELLOW);
            printCenteredX(String("ACCESS GRANTED"), 45);
            printCenteredX(String("BA25"), 85);
            printCenteredX(String("TOUCH TO RETURN"), 125);
            delay(5000);
            
            // Wait for touch
            while (digitalRead(48) == 0) {
                delay(10);
            }
            
            // Generate random values for cylinders
            for (int i = 0; i < 4; i++) {
                cyl[i*2] = random(255);
            }
        } else {
            // Access denied
            gfx->setTextColor(COL_TEXT);
            printCenteredX(String("ACCESS DENIED"), 45);
            printCenteredX(String("ACCESS DENIED"), 45);
            delay(2800);
        }
        
        // Reset toast flag
        showToast = 0;
    } else {
        // Normal display mode
        gfx->fillScreen(COL_BG);
        drawPixelNoise(220);
        
        // Draw cylinders
        for (int i = 0; i < 4; i++) {
            int x = startX + (cylW + 8) * i;
            int y = startY;
            int selected_match = (selected == i);
            drawCylinder(i, x, y, cylW, cylH, selected_match);
        }
        
        // Draw buttons
        const char* leftButtonText = editing ? "DONE" : "EDIT";
        const char* centerButtonText = editing ? "LOCK" : "UNLOCK";
        const char* rightButtonText = editing ? "UP" : "SPIN";
        
        drawButton(btnL, leftButtonText, COL_RED);
        drawButton(btnC, centerButtonText, COL_RED);
        drawButton(btnR, rightButtonText, COL_RED);
        drawButton(btnValidate, "VALIDATE", COL_RED);
    }
    
    // Stack check
    memw();
    if (*stack_guard_ptr != stack_guard_val) {
        __stack_chk_fail();
    }
}

By the way, we have no doubt this function is specifically written for the challenge, because Radare2 (and Ghidra) displays the source filename: blackalps25.ino.

Where is the secret?

The MAC address is XORed with a 4 secret bytes. In Radare2, those 4 bytes are referenced as a global variable. But we can’t see their value.

1
2
3
4
5
[0x40375d5c]> f~SECRET
0x3c04b742 4 global_SECRET
[0x40375d5c]> px 4 @ global_SECRET 
- offset -  4243 4445 4647 4849 4A4B 4C4D 4E4F 5051  23456789ABCDEF01
0x3c04b742  0000 0000                                ....

With Ghidra, a label has been created with value 0x3713300b (0b 30 13 37):

/images/blackalps25-secret.png

The reference to l33t (1337) gives us a good hint this is the correct secret value.

MAC address

We reboot the device (there’s a boot button) and the slash screen displays the MAC address for the device. There are 2 devices. Each one has (as expected) a slightly different MAC address.

  • 0000F86D46BA2010
  • 0000E86D46BA2010

We are a bit uncertain about what the program does with the MAC address in drawScene. The following code was generated by AI, and as such, is likely to contain errors.

1
2
3
4
5
6
7
uint32_t mac_lo = MAC__1[0];
uint32_t mac_hi = MAC__1[1];
    
mac[0] = (mac_hi >> 8);
mac[3] = mac_lo;
mac[1] = mac_hi;
mac[2] = (mac_hi >> 16);

A MAC address consists of 3 bytes that identify the vendor, and 3 other bytes that identify the device. The vendor Id is assigned by IEEE, the 3 vendor bytes are assigned by the vendor itself.

So, we have the MAC address right, but the real issue that confused us is endianess. How is that stored? Big Endian or Little Endian? In mac_low, do we have 46BA2010, 1020BA46, 6DF80000 or 0000F86D?

During the CTF, we weren’t alert/wise enough to simply lookup the vendor id – although it just seems to be the right thing to do. If you look up 1020BA, you find out the bytes are assigned to Espressif, which is perfect for an ESP32. So our MAC address (high) 10:20:BA:46:6D:F8 (low). Yes, it seems so easy when you sleep :(

Ghidra’s decompiled code isn’t that helpful either:

/images/blackalps25-ghidra-mac.png

Seleting the correct bytes of the MAC to XOR

What happens when you have no brain left, and not that much time either? You use AI, or brute force, or both.

AI - when you have no brain left

Claude tells us we should absolutely select F8 6D 46 BA. Actually, this is correct.

/images/blackalps25-claude-mac.png

Bruteforce

We are still all messed up with endianness in our brains, so we brute force the solution. We have 4 codes to try on each device.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
print("f8")
print(hex(0xF86D46BA ^  0x3713300b))
print(hex(0xBA466DF8 ^  0x3713300b))
print(hex(0xF86D46BA ^  0x0b301337))
print(hex(0xBA466DF8 ^  0x0b301337))

print("e8")
print(hex(0xE86D46BA ^  0x3713300b))
print(hex(0xBA466DE8 ^  0x3713300b))
print(hex(0xE86D46BA ^  0x0b301337))
print(hex(0xBA466DE8 ^  0x0b301337))

To speed it up a little, we try the codes in parallel, Phil242 and I, one starting from the top values, the other starting with the bottom ones.

And… we flag.

/images/blackalps25-flag.jpg

Conclusion

This was a fun challenge, we like challenges which involve devices in CTFs. Thanks Karim! We enjoyed exploring it (and flagging), but our solution isn’t fully satisfactory.

The algorithm that selects the given bytes of the MAC address still gives me headaches with its ssai and src instructions. ssai sets the shift amount for the next operation, and src combines it with a right shift, which is not natural to me… nor to LLMs apparently and decompilers provide a mess. I wonder if some other teams who flagged went through each step of assembly, or selected the continuous bytes of the MAC address from the end … a bit by luck.

In our case, we had thought to XOR with 0x0b301337 instead of 0x3713300b, we would have flagged an hour earlier.

Remaining questions

  1. When I load the binary with r2, I get a warning, I don’t know why: WARN: Cannot find asm.parser for xtensa
  2. I have no idea how to get the secret value with r2. Anybody?
  3. Would love to read a write-up that clearly explains the successive srli, src, s8i etc. So, far I always ended up with an error at some point.