This page is a walkthrough for Cold Lake, the third in a recent series of reverse engineering challenges from the embedded systems division at NCC Group.
This version removes the ed25519 private key from memory and modifies the secure boot implementation. The goal is once again to circumvent the signature check to achieve arbitrary code execution.
There is a known issue with Android lacking a true monotype system font, which breaks many of the extended ASCII character set diagrams below. Please view this page on a Chrome or Firefox-based desktop browser to avoid rendering issues. Ideally on a Linux host.
The following is a walkthrough for the third in the new series of Microcorruption challenges. The original CTF-turned-wargame was developed a decade ago by Matasano and centered around a deliberately vulnerable smart lock. The goal for each challenge was simple: write a software exploit to trigger an unlock.
NCC Group later acquired Matasano. They continued maintaining the wargame and added half a dozen new challenges on October 28th, 2022, of which this is one.
The emulated device runs on the MSP430 instruction set architecture. It uses a 16-bit little-endian processor and has 64 kilobytes of RAM. The official manual includes the details, but relevant functionality is summarized below.
Several separate windows control the debugger functionality.
A user input prompt like the following is the device's external communication interface.
The equivalent of popping a shell on this system is calling interrupt 0x7F
. On earlier challenges in the series, there is a dedicated function called unlock_door
that does this.
Executing the following shellcode is functionally equivalent to calling the unlock_door
function.
3240 00ff mov #0xff00, sr
b012 1000 call #0x10
324000ffb0121000
The following message is displayed in the interface when the interrupt is called successfully.
The debug payload has three separate segments. The example payload is below.
Load address:
8000
Program text:
3540088000450545054505450545054505450f433041
Signature:
8605e027f42368ea6bba9de66409f6a8ddedcd49614a4648281c47a7b4ad252f5639069b17ba8ff104d371e2d8a625b038f0750667364087e7987e40ea81510f
Running the firmware with the example debug payload will produce the following output at the I/O console.
Welcome to the secure program loader.
Please enter second stage load address.
Please enter the second stage program.
Please enter program signature.
Load address outside allowed range of 0x8000-0xF000
Please enter second stage load address.
Something is immediately wrong, as the debug payload prints the above error message and does not reach the second stage load address at address 0x8000
.
The payload format is identical to St. John's except for being broken into three sections. Given that the error message is about the load address, something must be wrong with the first two-byte payload section. The code that parses these bytes seems to load them as a word rather than individual bytes.
The load address should be in little-endian format: 0x0080
instead of 0x8000
.
Load address:
0080
Program text:
3540088000450545054505450545054505450f433041
Signature:
8605e027f42368ea6bba9de66409f6a8ddedcd49614a4648281c47a7b4ad252f5639069b17ba8ff104d371e2d8a625b038f0750667364087e7987e40ea81510f
Making this tweak and re-submitting the three-stage payload results in the following output.
Welcome to the secure program loader.
Please enter second stage load address.
Please enter the second stage program.
Please enter program signature.
Signature valid, executing payload
ACCESS DENIED
Please enter second stage load address.
This results in the signature check passing and the code jumping to address 0x8000
. The example debug payload is now functional.
The first goal is to compare the implementation with the one from St. John's and see what has changed. The flow control graph for the main function is as follows.
The defined strings are listed in the table below, as well as the address of the conditional block that prints them in the main
function.
String Address | String | Address of Conditional Block Containing Referencing Code |
---|---|---|
4656 | "Welcome to the secure program loader." | 443e |
473f | "Signature valid, executing payload" | 44ea |
46a4 | "Please enter the second stage program." | 444a |
467c | "Please enter second stage load address." | 444a |
46cb | "Please enter program signature." | 444a |
46eb | "Load address outside allowed range of 0x8000-0xF000" | 44b2 |
471f | "Incorrect signature, continuing" | 44e0 |
4762 | "ACCESS GRANTED" | 44fa |
4771 | "ACCESS DENIED" | 4518 |
Reaching the conditional block at address 0x44ea
is the objective, with the debug payload executing at address 0x44f2
.
The misalignment check from St. John's is no longer present. The invalid payload length check is also missing, and the payload format does not include a size field at all (with the maximum value being hardcoded to 0x100
). All of this can be verified by examining the parameters passed to verify_ed25519
.
The next step is ensuring that signature verification works properly, accomplished by flipping bits in each payload section during each respective run.
Payload:
Load address:
0080
Program text:
ff40088000450545054505450545054505450f433041
Signature:
8605e027f42368ea6bba9de66409f6a8ddedcd49614a4648281c47a7b4ad252f5639069b17ba8ff104d371e2d8a625b038f0750667364087e7987e40ea81510f
I/O console output:
Incorrect signature, continuing
Payload:
Load address:
0080
Program text:
3540088000450545054505450545054505450f433041
Signature:
ff05e027f42368ea6bba9de66409f6a8ddedcd49614a4648281c47a7b4ad252f5639069b17ba8ff104d371e2d8a625b038f0750667364087e7987e40ea81510f
I/O console output:
Incorrect signature, continuing
Payload:
Load address:
0090
Program text:
3540088000450545054505450545054505450f433041
Signature:
8605e027f42368ea6bba9de66409f6a8ddedcd49614a4648281c47a7b4ad252f5639069b17ba8ff104d371e2d8a625b038f0750667364087e7987e40ea81510f
I/O console output:
Signature valid, executing payload
The load address is not signed, allowing alteration without affecting the signature check. This bug permits passing arbitrary values as a load address, which implies that it might be possible to write the debug payload to any offset in memory—effectively acting as a random write primitive.
This payload attempts to overwrite the entrypoint at address 0x4400
as a proof of concept.
Load address:
0044
Program text:
3540088000450545054505450545054505450f433041
Signature:
8605e027f42368ea6bba9de66409f6a8ddedcd49614a4648281c47a7b4ad252f5639069b17ba8ff104d371e2d8a625b038f0750667364087e7987e40ea81510f
I/O console output:
Load address outside allowed range of 0x8000-0xF000
The restriction on the address range rules out random writes, suggesting the presence of a different vulnerability.
One behavior is worth observing: the executable part of the payload is stored at the load address before the signature check occurs, and the implementation does not overwrite this region with zeros after signature verification failure. The following payloads execute sequentially during a single run.
Payload:
Load address:
0080
Program text:
aaaaaaaaaaaaaaaa
Signature:
ff
I/O console output:
Incorrect signature, continuing
Payload:
Load address:
1080
Program text:
bbbbbbbbbbbbbbbb
Signature:
ff
I/O console output:
Incorrect signature, continuing
Payload:
Load address:
2080
Program text:
cccccccccccccccc
Signature:
ff
I/O console output:
Incorrect signature, continuing
The payload's executable code segment is written to the load address regardless of whether the signature check passes, so the signature field is set to a dummy value of 0xFF.
The code segment fails to execute but is not overwritten with zeros when the signature check fails.
8000: aaaa aaaa aaaa aaaa 0000 0000 0000 0000 ................
8010: bbbb bbbb bbbb bbbb 0000 0000 0000 0000 ................
8020: cccc cccc cccc cccc 0000 0000 0000 0000 ................
8030: 0000 0000 0000 0000 0000 0000 0000 0000 ................
This behavior enables an attacker to spray arbitrary data into the 0x8000-0xF000
range, which is not immediately useful because it does not provide a way to redirect execution flow. However, it is still a potentially exploitable design flaw that is abusable in conjunction with a second vulnerability.
The implementation seems sound based on a review of the disassembly. A vulnerability would thus lie in one of two places: the ed25519 backend code or the example debug payload itself. Given the choice between attacking the cryptographic implementation or a logical flaw in the debug payload, the latter requires less effort.
Load Address | Executable code | Signature |
---|---|---|
0080 |
3540088000450545054505450545054505450f433041 |
8605e027f42368ea6... |
The executable code in the example debug payload disassembles to the following.
3540 0880 mov #0x8008, r5
0045 br r5
0545 mov r5, r5
0545 mov r5, r5
0545 mov r5, r5
0545 mov r5, r5
0545 mov r5, r5
0545 mov r5, r5
0f43 clr r15
3041 ret
Opcodes | Disassembly |
---|---|
3540 0880 |
mov #0x8008, r5 |
0045 |
br r5 |
The issue stems from a lack of position independence in the payload code. The following explanation is helpful for those unfamiliar with the difference between absolute and position-independent code:
In computing, position-independent code (PIC) or position-independent executable (PIE) is a body of machine code that, being placed somewhere in the primary memory, executes properly regardless of its absolute address. [...] This differs from absolute code, which must be loaded at a specific location to function correctly, and load-time locatable (LTL) code, in which a linker or program loader modifies a program before execution, so it can be run only from a particular memory location. Generating position-independent code is often the default behavior for compilers, but they may place restrictions on the use of some language features, such as disallowing use of absolute addresses (position-independent code has to use relative addressing). — Wikipedia
The payload code is always assumed to be loaded at address 0x8000, even though the firmware allows any address in the 0x8000-0xF000
range. The payload executable segment code is not position-independent even though the "secure program loader" assumes otherwise.
The code behaves as follows if it is loaded at address 0x8000
as expected.
Changing the example payload load address to 0x9000
results in the following behavior.
Execution will branch to null memory at address 0x8008
(the value 0x0000
disassembles to the RRC PC
instruction) and promptly crash.
This crash is not exploitable in isolation but can be combined with the previous vulnerability to achieve code execution. Spraying malicious code at address 0x8008
beforehand is the only requirement. The behavior will then be as follows:
This exploit should trigger the unlock interrupt, return from the interrupt callgate, and crash. In theory, this is proof of arbitrary code execution.
First, store the shellcode at address 0x8008
.
Load address:
0880
Program text:
324000ffb0121000
Signature:
ff
After the signature verification fails and main
loops, provide the legitimate debug payload with a deliberately altered load address.
Load address:
0090
Program text:
3540088000450545054505450545054505450f433041
Signature:
8605e027f42368ea6bba9de66409f6a8ddedcd49614a4648281c47a7b4ad252f5639069b17ba8ff104d371e2d8a625b038f0750667364087e7987e40ea81510f
Execution branches to address 0x8008
, the malicious shellcode executes, and the unlock interrupt is triggered.
Door Unlocked The CPU completed in 24747 cycles.
While this exploit is relatively straightforward to write, the cause of this type of underlying vulnerability may be subtle. The debug payload is relatively short, and the assembly may be hand-written, but larger payloads tend towards implementation in C. It is possible to introduce this vulnerability by changing one compiler flag.
The naive answer is to fix the toolchain (i.e., code should be truly position-independent), but superior approaches exist.
The vulnerabilities in previous firmware versions are usually symptoms of systemic problems. Lack of developer education, technical controls, and standard operating procedures are likely culprits. The vulnerabilities are obvious enough that even rudimentary best practices would likely catch them.
This problem is nuanced because it can exist even with all of the above measures present, necessitating dedicated security staff (separate from the developers) to perform code reviews during development and before releases.
Specific vulnerabilities aside, this implementation is overly complex and violates the Principle of Least Functionality. This is a concept related to (but not to be confused with) the Principle of Least Privilege. Multiple features should not exist.
While this does technically patch the vulnerability, it is not an optimal solution compared to the previous alternatives. It is unreasonable to rely on the developers responsible for writing the debug payloads to anticipate issues like this. It is reasonable to treat payload developers as third parties (even if they are part of the same team) for reasons purely related to quality assurance—let alone security. The implementation itself is not technically vulnerable as designed, but it is difficult for developers to avoid compiling vulnerable code that will run within it.
Implementations should attempt to minimize weird corner cases. It is not incumbent on developers to enumerate or understand them. A system that is easy to misconfigure due to excessive complexity is broken.