Running with a peripheral device
Ryan Torvik - May 24, 2023 - 5 minute read
Last time, we created our tenkey calculator device in python. Now let’s hook that device into our Emerson project and run our firmware again.
Add our device to the project YAML
In order to let the processor communicate with our device, we have to add it to the memory map. Last time, we saw that the firmware is trying to communicate to a device that’s mapped in at address 0x2000_0000
. Now that we’ve created that device, let’s drop it in our system configuration YAML.
name: tenkey
processor:
architecture: generic.mips32
initial_registers:
pc: 0x1fc00000
sp: 0x1000
details:
keystone: mips32be
devices:
- name: ram
start_address: 0x00000000
size: 0x02000000 # 32MB of ram
kind: generic.sparse_ram
endian: big
- name: flash
start_address: 0x1fc00000
size: 0x00400000
kind: generic.rom
endian: big
details:
backing_file: flash.img
- name: tenkey_device
start_address: 0x20000000
size: 0x8
kind: plugin.python
endian: little
details:
path: tenkey.py
You’ll see that we added our tenkey_device
at address 0x2000_0000
and made it only size 8
. The size just tells us the range of addresses that will be sent to this device. Since we are only need 4 byte accesses at 0x2000_0000
and 0x2000_0004
and this is a toy example, we’ll just put the size at 8. Real systems have lots more that go into determining the size of the memory windows for a device. Finally, we set the path to our python script relative to the project directory.
Run Emerson
Just like before, we’ll run our Emerson docker image, forward the port Emerson is listening on, and map in our tenkey project directory.
You’ll notice that there is a .snap
file in the tenkey directory. When we previously HaltedOnError, Emerson automatically took a snapshot so we can review what happened later. If we had enabled save points, we would be able to rewind more thoroughly review the error.
Just like before, we load our project up.
Now that it’s running, let’s figure out if our firmware does the calculation we expect it to.
Here’s the original source of the main function.
int main(void) {
int nums[10] = {1};
int last = 0, current = 1;
const int LEN = sizeof(nums)/sizeof(int);
for (int i = 0; i < LEN - 1; i++) {
load_register(nums[i]);
select_add();
nums[i+1] = read_result();
}
return nums[LEN - 1];
}
Our Emerson project is using the actual compiled instructions, so we need a way to affiliate our source code with the instructions that Emerson is going to run. We are returning from main the result of our fibonacci calculation. We need to figure out where the return value of main is stored. Then we can breakpoint it and see what the result is.
For a refresher on how we compiled the code for different scenarios, see our post on Looking at Firmware. We are going to open the PE file version of our executable code in BinaryNinja.
View the code in Binary Ninja
Binja has several different intermediate representations to give you different ways of looking at a target binary. It’s High-IL takes the assembly bytes, does some thorough analysis of it, and generates C code that very closely resembles our source code.
You can see that the instructions that do the return start at offset 0x6c0. The Medium Level IL gives a view that maps register names to the variables it has figured out. It also flattens out the loops into if-then-branch constructs which more accurately represent how the instructions actually work.
You can see in the Medium Level IL that the value in $v0_2
is being returned from this function. This probably refers to the register v0
. Even if we don’t know semantics of a MIPS call return, we can figure out what’s happening with BinaryNinja.
The Low Level IL gives even more architecture specific information; inferring where variables are stored on the stack, etc.
And finally, the disassembly view gives us the actual instructions that are in this section of the compiled code.
You can see here that we return the value in the register v0
. Looking at our original source code, we can see that after we are done computing the fibonacci number, we return it from main. So, if we put a breakpoint on the instruction that sets the value in v0
, step forward one, and v0
should have our computed fibonacci number.
Set a breakpoint and see what happens
You’ll recall, we compiled our firmware in 2 ways. We compiled it as a standard PE file (above) that can be loaded into Binja Cloud. We also stripped out our code and made a Position Independent Executable (PIE). In our system configuration, we loaded the PIE at 0x1fc0_0000
. Doing some quick arithmetic, 0x6b0 - 0x620 = 0x90
. So, we will set our breakpoint at 0x1fc0_0090
.
To do that we type bp 0x1fc00094
into the Emerson console. Then click on the Go
button.
See where we’ve stopped. Notice the value in v0
. The next instruction is going to overwrite it with the value in 0x3c(sp)
which is sp + 0x3c
. Since sp
is 0xFB0
we’ll look at the memory starting at 0xFEC
. You’ll notice that this is the same value we are currently holding in v0
. We’ll step through this just to make sure.
If we step forward one, v0
is still 0x100
, that’s 256 in decimal. That’s the result of our fibonacci calculation!
But, is that right value? We are supposed to be calculating the 10th fibonacci number. Did we do that?
The result
55 ≠ 256
Nope.
Well, that’s unfortunate. Something is amiss. Either we’re looking in the wrong place, our device is doing the wrong thing, or our firmware is incorrect (or a combination of the three). Next time, we’ll spend some time debugging and setting up tests for our firmware.