9.2 KiB
Embedded Systems Reverse Engineering
Week 5
Integers and Floats in Embedded Systems: Debugging and Hacking Integers and Floats w/ Intermediate GPIO Output Assembler Analysis
Non-Credit Practice Exercise 3: Analyze the Double Binary in Ghidra
Objective
Import and analyze the 0x0011_double-floating-point-data-type.bin binary in Ghidra to understand how doubles differ from floats at the binary level, observe that both register words carry non-zero data when the fractional part is complex, and decode the IEEE 754 double-precision encoding of 42.52525 from two 32-bit registers.
Prerequisites
- Completed Exercises 1 and 2 (float analysis and patching)
- Understanding of IEEE 754 double-precision format from Week 5 Part 3
- Understanding of register pairs (
r2:r3) from Exercise 1 - Basic Ghidra navigation skills
Task Description
You will import the double binary into Ghidra, resolve function names, identify that 42.52525 requires non-zero data in both r2 and r3 (unlike 42.5 which had r2 = 0), and decode the full 64-bit double 0x4045433B645A1CAC field by field to confirm it represents 42.52525.
Step-by-Step Instructions
Step 1: Flash the Original Binary
Before analysis, verify the program works:
- Hold BOOTSEL and plug in your Pico 2
- Flash
0x0011_double-floating-point-data-type.uf2to the RPI-RP2 drive - Open your serial monitor
Expected output:
fav_num: 42.525250
fav_num: 42.525250
fav_num: 42.525250
...
Step 2: Create a New Ghidra Project
- Launch Ghidra:
ghidraRun - Click File → New Project
- Select Non-Shared Project
- Project Name:
week05-ex03-double-analysis - Click Finish
Step 3: Import and Configure the Binary
- Drag and drop
0x0011_double-floating-point-data-type.bininto Ghidra - Set Language: ARM Cortex 32 little endian default
- Click Options…
- Block Name:
.text - Base Address:
10000000
- Block Name:
- Click OK on all dialogs
- Double-click the file and click Yes to analyze
Step 4: Locate and Rename Functions
Identify the main function and standard library calls:
Rename main:
- Click on
FUN_10000238 - Right-click → Edit Function Signature
- Change to:
int main(void) - Click OK
Rename stdio_init_all:
- Click on
FUN_10002f64 - Right-click → Edit Function Signature
- Change to:
bool stdio_init_all(void) - Click OK
Rename printf:
- Click on
FUN_100030f4 - Right-click → Edit Function Signature
- Change to:
int printf(char *format,...) - Check the Varargs checkbox
- Click OK
Step 5: Observe the Decompiled Code
After renaming, the decompiled main should look like:
int main(void)
{
undefined4 uVar1;
undefined4 uVar2;
undefined4 extraout_r1;
undefined4 uVar3;
undefined4 extraout_r1_00;
uVar2 = DAT_10000258;
uVar1 = DAT_10000254;
stdio_init_all();
uVar3 = extraout_r1;
do {
printf(DAT_10000250,uVar3,uVar1,uVar2);
uVar3 = extraout_r1_00;
} while( true );
}
Critical observation: There are now two non-zero data constants — DAT_10000254 and DAT_10000258. This is the key difference from the float exercise!
Step 6: Examine the Assembly Listing
Click on the Listing window and find the main function assembly:
10000238 push {r3,r4,r5,lr}
1000023a adr r5, [0x10000254]
1000023c ldrd r4, r5, [r5, #0x0] = 645A1CACh / 4045433Bh
10000240 bl stdio_init_all
Key instruction: ldrd r4, r5, [r5, #0x0]
This is a load register double instruction — it loads two consecutive 32-bit words in a single instruction:
r4gets the low word:0x645A1CACr5gets the high word:0x4045433B
Later in the loop:
10000244 mov r2, r4 ; r2 = 0x645A1CAC (low)
10000246 mov r3, r5 ; r3 = 0x4045433B (high)
Step 7: Identify the Register Pair
| Register | Value | Role |
|---|---|---|
r2 |
0x645A1CAC |
Low 32 bits |
r3 |
0x4045433B |
High 32 bits |
Together: 0x4045433B645A1CAC
Compare to the float exercise:
- Float
42.5: r2 =0x00000000, r3 =0x40454000— low word is all zeros - Double
42.52525: r2 =0x645A1CAC, r3 =0x4045433B— both words are non-zero!
This happens because 42.52525 has a repeating binary fraction that needs all 52 mantissa bits.
Step 8: Decode the Sign Bit
Convert r3 to binary and check bit 31:
r3 = 0x4045433B = 0100 0000 0100 0101 0100 0011 0011 1011
^
bit 31 = 0 → Positive number ✓
Step 9: Decode the Exponent
Extract bits 30–20 from r3:
0x4045433B: 0 10000000100 01010100001100111011
^ ^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^
sign exponent mantissa (top 20)
Exponent bits: 10000000100 = 2¹⁰ + 2² = 1024 + 4 = 1028
\text{real exponent} = 1028 - 1023 = \mathbf{5}
Step 10: Decode the Mantissa
High 20 bits (r3 bits 19–0):
0 1 0 1 0 1 0 0 0 0 1 1 0 0 1 1 1 0 1 1
Low 32 bits (all of r2 = 0x645A1CAC):
0 1 1 0 0 1 0 0 0 1 0 1 1 0 1 0 0 0 0 1 1 1 0 0 1 0 1 0 1 1 0 0
With the implied leading 1:
1.0101010000110011101101100100010110100001110010101100
Step 11: Reconstruct the Integer Part
1.0101010000110011..._2 \times 2^5
Shift the binary point 5 places right → 101010.1000011001110110...
Integer part 101010:
| Bit | Power | Value |
|---|---|---|
| 1 | 2⁵ | 32 |
| 0 | 2⁴ | 0 |
| 1 | 2³ | 8 |
| 0 | 2² | 0 |
| 1 | 2¹ | 2 |
| 0 | 2⁰ | 0 |
32 + 8 + 2 = \mathbf{42}
Step 12: Approximate the Fractional Part
The first few fractional bits .10000110011...:
| Bit | Power | Decimal |
|---|---|---|
| 1 | 2⁻¹ | 0.5 |
| 0 | 2⁻² | 0 |
| 0 | 2⁻³ | 0 |
| 0 | 2⁻⁴ | 0 |
| 0 | 2⁻⁵ | 0 |
| 1 | 2⁻⁶ | 0.015625 |
| 1 | 2⁻⁷ | 0.0078125 |
| 0 | 2⁻⁸ | 0 |
| 0 | 2⁻⁹ | 0 |
| 1 | 2⁻¹⁰ | 0.0009765625 |
| 1 | 2⁻¹¹ | 0.00048828125 |
First 11 bits sum ≈ 0.5249. The remaining 36 fractional bits refine this to ≈ 0.52525.
42 + 0.52525 = \mathbf{42.52525} ✓
Step 13: Find the Format String
In the Listing view, locate:
s_fav_num:_%lf_100034b0 ds "fav_num: %lf\r\n"
Note the %lf format specifier — this program explicitly uses double, unlike the float program which used %f.
Step 14: Document Your Findings
| Item | Float (42.5) |
Double (42.52525) |
|---|---|---|
| r2 (low word) | 0x00000000 |
0x645A1CAC |
| r3 (high word) | 0x40454000 |
0x4045433B |
| Low word is zero? | Yes | No |
| Words to patch | 1 (r3 only) | 2 (both r2 and r3) |
| Format specifier | %f |
%lf |
| Assembly load instruction | movs + ldr |
ldrd (load double) |
Expected Output
After completing this exercise, you should understand:
- How the compiler loads a 64-bit double using
ldrd(load register double) - Why
42.52525requires non-zero data in both registers while42.5does not - How to decode a complex IEEE 754 double with a repeating binary fraction
- The differences between float and double handling at the assembly level
Questions for Reflection
Question 1: Why does 42.5 have a zero low word but 42.52525 does not?
Question 2: The assembly uses ldrd r4, r5, [r5, #0x0] instead of two separate ldr instructions. What is the advantage?
Question 3: Both the float and double programs have the same exponent (stored as 1028, real exponent 5). Why?
Question 4: If you were patching this double, how many data constants would you need to modify compared to the float exercise?
Tips and Hints
- Use Python to verify:
import struct; struct.pack('>d', 42.52525).hex()gives4045433b645a1cac - The
ldrdinstruction always loads the lower-addressed word into the first register - A repeating binary fraction (like
0.52525) can never be represented exactly — double precision uses the closest 52-bit approximation - Compare data addresses: the float binary has one
DAT_constant; the double binary has two consecutive ones
Next Steps
- Proceed to Exercise 4 to patch both register words
- Compare the mantissa of
42.5(clean:010101 000...) vs.42.52525(complex:0101010000110011...) - Think about what values would have a zero low word (hint: powers of 2, halves, quarters)
Additional Challenge
Using the 52-bit mantissa 0101010000110011101101100100010110100001110010101100, manually sum the first 20 fractional bits to see how close you get to 0.52525. How many bits of precision does it take to get within 0.001 of the true value?