Cutting Bootstraps with Occam's Razor: Exploiting Logic Flaws in Secure Boot

James Every
Black Friday, 2023

Abstract

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.

Executive Summary

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.

Overview

First Working Exploit: 0901 UTC, March 9th, 2023
Blockchain Timestamp: 1114 UTC, March 9th, 2023
Pastebin Timestamp: pastebin.com/vmXquD91
Cryptographic Proof of Existence: solution.txt solution.txt.ots
Solve Count At Time Of Writing: Unknown A backend bug breaks the solve counters for Cold Lake and Churchill.
Solves Per month: Unknown
Reading Time: 8 minutes
Rendering Note:

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.

Background

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.

System Architecture

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.

Interface

Several separate windows control the debugger functionality.


Debugger GUI.

A user input prompt like the following is the device's external communication interface.


Popup triggered by getsn interrupt.

Exploit Development Objective

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.


The unlock door function.

Executing the following shellcode is functionally equivalent to calling the unlock_door function.

Disassembly
3240 00ff      mov     #0xff00, sr
b012 1000      call    #0x10
Assembly
324000ffb0121000

The following message is displayed in the interface when the interrupt is called successfully.


Unlock status message.

High-Level Analysis

Sample Payload

The debug payload has three separate segments. The example payload is below.

Load address:
8000

Program text:
3540088000450545054505450545054505450f433041

Signature:
8605e027f42368ea6bba9de66409f6a8ddedcd49614a4648281c47a7b4ad252f5639069b17ba8ff104d371e2d8a625b038f0750667364087e7987e40ea81510f

Sample Payload

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.

Debugging

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.



Load address parsing code.

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.

Static Analysis

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.


Main function flow control graph.

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.


Defined Strings
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.



Target conditional block for code execution.

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.



Hardcoded size.

Dynamic Analysis

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

Unsigned Load Address

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.

Spraying Arbitrary Data

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.

Result

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.

Debug Payload 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.

Sample Debug Payload Format

Parsing Format
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

Vulnerable Branch

Vulnerable Code in Debug Payload
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.


Payload behavior if it is loaded at address 0x8000.

Changing the example payload load address to 0x9000 results in the following behavior.

Payload behavior if it is loaded at address 0x9000.

Execution will branch to null memory at address 0x8008 (the value 0x0000 disassembles to the RRC PC instruction) and promptly crash.

Exploitation

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:


Payload behavior if it is loaded at address 0x9000 and shellcode is sprayed at 0x8008.

This exploit should trigger the unlock interrupt, return from the interrupt callgate, and crash. In theory, this is proof of arbitrary code execution.

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.

Remediation

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.

1. Perform continuous security testing during development.

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.

2. Apply the Principle of Least Functionality.

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.

  1. The load address should be impossible to alter.
  2. Sign the entire payload as a best practice. It should also be impossible to specify arbitrary load addresses in the first place. The implementation should load debug payloads at a fixed address.
  3. Avoid writing untrusted input to the load address before signature verification.
  4. This logic is similar to the Cryptographic Doom Principle.
  5. Overwrite input buffers with zeroes between iterations.
  6. Data should not persist in memory past its lifetime, regardless of whether the signature verification check passes.
3. Ensure all debug payload code is truly position-independent.

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.