Military Grade Encryption: Reverse Engineering Nonstandard Embedded AES Implementations

James Every
Black Friday, 2023

Abstract

This page is a walkthrough for Baku, the fifth in a recent series of reverse engineering challenges from the embedded systems division at NCC Group.

Executive Summary

This challenge uses a nonstandard AES implementation running in ECB mode as an authentication mechanism. Successfully circumventing this protection will directly trigger the unlock interrupt.

Overview

First Working Exploit: 0514 UTC, March 8th, 2023
Blockchain Timestamp: 0709 UTC, March 8th, 2023
Pastebin Timestamp: pastebin.com/hhUbv9Nb
Cryptographic Proof of Existence: solution.txt solution.txt.ots
Solve Count At Time Of Writing: 54
Solves Per month: 4.22
Reading Time: 23 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 fifth 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

The Ghidra flow control graph for the main function is as follows.


Main function flow control graph.

Running the firmware will produce the following output at the console.

SCAN SECURITY DEVICE

Supplying "password" at the prompt causes the I/O console to print the above message again, with no visible effect otherwise.

The initial assumption is naturally that this is an ECB oracle, but one of those, by definition, must return encrypted or decrypted data. This system does not seem to have status messages of any kind—evidenced by the defined strings.

Status Messages

Defined Strings
Address String
487a "SCAN SECURITY DEVICE"
488f "ACCESS GRANTED!"
489f "ACCESS GRANTED"

The repetition of "ACCESS GRANTED" and the subtle difference between the two instances is worth noting. The fact that there are multiple similar strings suggests that the firmware references them for different purposes. Several lines in the main function indicate as much.

444e:  mov	#0x487a "SCAN SECURITY DEVICE", r15
4452:  call	#0x44d6 <puts>
4466:  call	#0x46fa <aes_ecb_decrypt>
446a:  mov	#0x10, r13
446e:  mov	sp, r14
4470:  mov	#0x488f "ACCESS GRANTED!", r15
4474:  call	#0x47ee <memcmp>
447c:  mov	#0x489f "ACCESS GRANTED", r15
4480:  call	#0x44d6 <puts>

The first and third strings appear to be printed to the console with puts, while the second is compared to something else (probably the output from aes_ecb_decrypt) using memcmp. The firmware may reference this string as a key or password.

Developer Psychology

Before getting into a more detailed analysis, it is reasonable to assume a few things.

  1. The aes_ecb_decrypt function takes some user input and processes it somehow.
  2. The firmware compares the result of this black box operation to the string "ACCESS GRANTED!".
  3. If the string matches, the unlock interrupt is triggered.

Ignoring the name of the function and the value the result is being compared to, this is strangely reminiscent of a hashing-based password authentication scheme. The implementation confuses irreversible hashing algorithms with reversible encryption algorithms. An encrypted password is possible to recover for anyone who knows the key. The key typically resides in the same volatile memory as the firmware on this class of embedded device, which means that it is possible to extract the key, reverse the encryption operation to obtain the original password and authenticate to the device using that credential.

The main exception to this rule is when the platform stores the key in a secure enclave, TPM, or HSM, which is unlikely because the decryption function would need a visible way to interface with that system. That would require calling a new interrupt (which would most likely be undocumented). No such interrupt calls exist, which can be verified by examining the incoming references to the interrupt call gate at address 0x10.


Ghidra call tree for the interrupt call gate.

If the firmware stores the key in an inaccessible external device (i.e., an HSM), the decryption operation performed by aes_ecb_decrypt would require an interrupt call to either read the key or have the external device itself decrypt the data.

The following snippets are all of the calls to address 0x10 that exist in the firmware.

4484: push	#0x0
4486: push	#0x0
4488: push	#0x7f
448c: call	#0x44a0 <INT>
44b8: push	r14
44ba: push	r15
44bc: push	#0x2
44be: call	#0x44a0 <INT>
44c8: sxt	r15
44ca: push	r15
44cc: push	#0x0
44ce: call	#0x44a0 <INT>

Interrupts 0x0, 0x2, and 0x7F are all documented in the supplied manual PDF. None of them have anything to do with secure enclaves, HSMs, or TPMs. With that possibility ruled out, the only reasonable goal is cryptographic key extraction.

The flaw in this implementation is that the developers opted to use encryption where they should have used hashing. The latter approach is superior because, even when hash extraction is feasible, brute-forcing is still required to locate an input that could generate that hash—which might not be cryptographically feasible given a sufficiently long password. There are other flaws with this implementation. A global hardcoded credential is still a single point of failure, regardless of the choice of hashing or encryption.

Implementing AES-ECB in Python

AES is a symmetric cipher. Both encrypt and decrypt operations take two parameters: a key and a message. The constraints are that the message length must be a multiple of 16, and the key must be 16, 24, or 32 bytes long.

Proceeding from the assumption that aes_ecb_decrypt will be similar to any other AES-ECB implementation, the following Python code illustrates roughly how the cipher works.

from Crypto.Cipher import AES
import binascii

def decrypt(key, message):
	cipher = AES.new(key, AES.MODE_ECB)
	return cipher.decrypt(message)

def encrypt(key, message):
	cipher = AES.new(key, AES.MODE_ECB)
	return cipher.encrypt(message)

Executing the above functions with dummy messages and keys produces the following output at a Python REPL.

$ python3 -i ecb.py
>>> ciphertext = encrypt(b'ABCDEFGHIJKLMNOP', b'AAAAAAAAAAAAAAAA')
>>> binascii.hexlify(ciphertext)
b'a8e78383ad04d6be3e6e66a57ac8a105'

>>> decrypt(b'ABCDEFGHIJKLMNOP', ciphertext)
b'AAAAAAAAAAAAAAAA'

Identifying Parameters

The "ACCESS GRANTED!" string at address 0x488f is 16 bytes long (counting the null terminator), making it plausible as either a key or a message.

4880: 4543 5552 4954 5920 4445 5649 4345 0041   ECURITY DEVICE.A
4890: 4343 4553 5320 4752 414e 5445 4421 0041   CCESS GRANTED!.A

The firmware moves a pointer to address 0x48ae into R15 shortly before the call to aes_ecb_decrypt, which suggests that it is the second parameter.


Second parameter for aes_ecb_decrypt.

The data at this location appears random, and it is not immediately clear how long it is.

48a0: 4343 4553 5320 4752 414e 5445 4400 7f78
48b0: 75e0 c977 d30c e85e ca19 d022 11f7 4b53
48c0: 0b31 b5cd 58d3 f59d c5a9 c583 c4f3 6f1a
48d0: f5bb fe9e 53e2 4050 9d7a 301e 015a 6259
48e0: a739 9184 a659 bece ce98 704e 9c20 5393
48f0: 45a8 f3dd 0160 2f4a 68c1 ce80 52b8 7007
4900: 6c8b a04e 44c8 dc97 69a1 e1ca 3a79 ff47
4910: b02e d049 2843 7cd9 2d69 3d5d 53d8 d980
4920: 482f 2f0e 986d ac90 052a 4184 7eb1 7dcd
4930: 0f8e f68e d042 839e 9d47 ed14 7b9b f213
4940: 8f14 8b43 dfcc 7510 4d05 6e8a e6dc 7b2f
4950: 0d18 8af1 fa20 493c d251 f10b bcb5 5209
4960: 6ad5 3036 a538 bf40 a39e 81f3 d7fb 7ce3
4970: 3982 9b2f ff87 348e 4344 c4de e9cb 547b
4980: 9432 a6c2 233d ee4c 950b 42fa c34e 082e
4990: a166 28d9 24b2 765b a249 6d8b d125 72f8
49a0: f664 8668 9816 d4a4 5ccc 5d65 b692 6c70
49b0: 4850 fded b9da 5e15 4657 a78d 9d84 90d8
49c0: ab00 8cbc d30a f7e4 5805 b8b3 4506 d02c
49d0: 1e8f ca3f 0f02 c1af bd03 0113 8a6b 3a91
49e0: 1141 4f67 dcea 97f2 cfce f0b4 e673 96ac
49f0: 7422 e7ad 3585 e2f9 37e8 1c75 df6e 47f1
4a00: 1a71 1d29 c589 6fb7 620e aa18 be1b fc56
4a10: 3e4b c6d2 7920 9adb c0fe 78cd 5af4 1fdd
4a20: a833 8807 c731 b112 1059 2780 ec5f 6051
4a30: 7fa9 19b5 4a0d 2de5 7a9f 93c9 9cef a0e0
4a40: 3b4d ae2a f5b0 c8eb bb3c 8353 9961 172b
4a50: 047e ba77 d626 e169 1463 5521 0c7d 0000
4a60: 0000 0000 0000 0000 0000 0000 0000 0000

There are 432 bytes of data between address 48ae and the first null word, equivalent to twenty-seven 16-byte chunks.

python3
>>> import binascii
>>> data = b''
...
... data += (binascii.unhexlify('7f7875e0c977d30ce85eca19d02211f7'))
... data += (binascii.unhexlify('4b530b31b5cd58d3f59dc5a9c583c4f3'))
... data += (binascii.unhexlify('6f1af5bbfe9e53e240509d7a301e015a'))
... data += (binascii.unhexlify('6259a7399184a659becece98704e9c20'))
... data += (binascii.unhexlify('539345a8f3dd01602f4a68c1ce8052b8'))
... data += (binascii.unhexlify('70076c8ba04e44c8dc9769a1e1ca3a79'))
... data += (binascii.unhexlify('ff47b02ed04928437cd92d693d5d53d8'))
... data += (binascii.unhexlify('d980482f2f0e986dac90052a41847eb1'))
... data += (binascii.unhexlify('7dcd0f8ef68ed042839e9d47ed147b9b'))
... data += (binascii.unhexlify('f2138f148b43dfcc75104d056e8ae6dc'))
... data += (binascii.unhexlify('7b2f0d188af1fa20493cd251f10bbcb5'))
... data += (binascii.unhexlify('52096ad53036a538bf40a39e81f3d7fb'))
... data += (binascii.unhexlify('7ce339829b2fff87348e4344c4dee9cb'))
... data += (binascii.unhexlify('547b9432a6c2233dee4c950b42fac34e'))
... data += (binascii.unhexlify('082ea16628d924b2765ba2496d8bd125'))
... data += (binascii.unhexlify('72f8f66486689816d4a45ccc5d65b692'))
... data += (binascii.unhexlify('6c704850fdedb9da5e154657a78d9d84'))
... data += (binascii.unhexlify('90d8ab008cbcd30af7e45805b8b34506'))
... data += (binascii.unhexlify('d02c1e8fca3f0f02c1afbd0301138a6b'))
... data += (binascii.unhexlify('3a9111414f67dcea97f2cfcef0b4e673'))
... data += (binascii.unhexlify('96ac7422e7ad3585e2f937e81c75df6e'))
... data += (binascii.unhexlify('47f11a711d29c5896fb7620eaa18be1b'))
... data += (binascii.unhexlify('fc563e4bc6d279209adbc0fe78cd5af4'))
... data += (binascii.unhexlify('1fdda8338807c731b11210592780ec5f'))
... data += (binascii.unhexlify('60517fa919b54a0d2de57a9f93c99cef'))
... data += (binascii.unhexlify('a0e03b4dae2af5b0c8ebbb3c83539961'))
... data += (binascii.unhexlify('172b047eba77d626e169146355210c7d'))

>>> len(data)
432

>>> len(data)/16
27.0

Supposing the aes_ecb_decrypt function does indeed perform a decryption operation, a reasonable starting point would be to assume that the data at address 0x48ae is a 16, 24, or 32-byte key.

16 Byte Key
48a0: 4343 4553 5320 4752 414e 5445 4400 7f78
48b0: 75e0 c977 d30c e85e ca19 d022 11f7 4b53
48c0: 0b31 b5cd 58d3 f59d c5a9 c583 c4f3 6f1a
24 Byte Key
48a0: 4343 4553 5320 4752 414e 5445 4400 7f78
48b0: 75e0 c977 d30c e85e ca19 d022 11f7 4b53
48c0: 0b31 b5cd 58d3 f59d c5a9 c583 c4f3 6f1a
32 Byte Key
48a0: 4343 4553 5320 4752 414e 5445 4400 7f78
48b0: 75e0 c977 d30c e85e ca19 d022 11f7 4b53
48c0: 0b31 b5cd 58d3 f59d c5a9 c583 c4f3 6f1a

If the "ACCESS GRANTED!" string is passed through the Python encryption function using this key, the resulting data should be the unlock password. If the theory is correct, one of the following strings will successfully trigger the unlock interrupt.

>>> ciphertext = encrypt(data[0:16], b"ACCESS GRANTED!\x00")
>>> binascii.hexlify(ciphertext)
b'6e8cbcfc2f4ec33c47c6587da6547e15'
>>> ciphertext = encrypt(data[0:24], b"ACCESS GRANTED!\x00")
>>> binascii.hexlify(ciphertext)
b'ab5ee8d8adc30edf8e4bcef8908aade5'
>>> ciphertext = encrypt(data[0:32], b"ACCESS GRANTED!\x00")
>>> binascii.hexlify(ciphertext)
b'bb8b973d8b8627ba250fc061ff55faf4'

Unfortunately, all of these inputs have the same result.

SCAN SECURITY DEVICE
SCAN SECURITY DEVICE
SCAN SECURITY DEVICE

Execution does not reach the code that triggers the unlock interrupt.

Identifying the Search Space

Assuming this is a standard AES implementation, there are far too many possible function argument combinations to test manually. The implementation may pass the data at address 0x48ae as a key or a message. If it is a key, it is unclear whether the key is 16, 24, or 32 bytes long, and it is not clear whether it is at a 16-byte offset within that blob of data.

>>> key_sizes = [16, 24, 32]
>>> data_length = 432
>>> combinations = 0
>>> for size in key_sizes:
...     combinations += data_length-size
... 
>>> combinations
1224

On top of this, the data might be a series of little-endian 16-bit integers (which might require swapping every second byte before passing the key to the Python ECB function), and the function name might not be accurate (i.e., it is decrypting rather than encrypting). Assuming the message might be 16, 24, or 32 bytes long, the number of possible combinations is as follows.

>>> combinations*(2**3)
9792

Automating the generation of all possible combinations is possible, but this would still require manual data entry. Crudely instrumenting the system would be feasible by wrapping a local emulator with something like pwntools. Older emulators written for this system should still work because there are no new undocumented interrupt calls.

The problem with that approach is that it assumes the AES implementation is standard. Given that this entire system relies on security through obscurity, the developers tweaking the cryptographic implementation I.e., flipping a few bits in the SBOX or changing the number of round keys. is not out of the question. It is worth understanding the possible low-level alterations before automating the search.

AES Background

There are a few good references for understanding how AES works. Wikipedia is generally a good place to start for a high-level overview. There is also a writeup by Henrique Marcomini detailing a Python implementation of AES-128, which is helpful for comprehension of how the algorithm works at a low level.

There are several things to note. From Wikipedia:

The key size used for an AES cipher specifies the number of transformation rounds that convert the input, called the plaintext, into the final output, called the ciphertext. The number of rounds is as follows:

Wikipedia also has the following high-level description of the algorithm:

  1. KeyExpansion – round keys are derived from the cipher key using the AES key schedule. AES requires a separate 128-bit round key block for each round plus one more.
  2. Initial round key addition:
    1. AddRoundKey – each byte of the state is combined with a byte of the round key using bitwise xor.
  3. 9, 11 or 13 rounds:
    1. SubBytes – a non-linear substitution step where each byte is replaced with another according to a lookup table.
    2. ShiftRows – a transposition step where the last three rows of the state are shifted cyclically a certain number of steps.
    3. MixColumns – a linear mixing operation which operates on the columns of the state, combining the four bytes in each column.
    4. AddRoundKey
  4. Final round (making 10, 12 or 14 rounds in total):
    1. SubBytes
    2. ShiftRows
    3. AddRoundKey

Static Analysis

The flow control graph for the aes_ecb_decrypt function is as follows.


Flow control graph for the aes_ecb_decrypt function.

One pattern to observe in the raw disassembly is the calls to the aesdec128 function, of which there are nine. The decompilation below has these calls highlighted.
The Ghidra decompiler has problems correctly inferring function signatures on the MSP430 ISA. Decompilation should be assumed to be far less accurate than usual on this architecture.

Flow control graph for the aes_ecb_decrypt function.

There is also a call to the xor128 function just before that, which resembles the "initial round key addition" step.


aes_ecb_decrypt: last xor128 call

The "final round" step is not the same as the others because another iteration of AddRoundKey follows it, which implies that there should be another xor128 call near the end of the function. That call is present on line 40, suggesting that the code from lines 18 to 40 constitutes the final round, bringing the total to ten.


aes_ecb_decrypt: last xor128 call

The presence of ten rounds suggests that the implementation uses 128-bit keys.

Identifying Non-Standard Implementation Details

The implementation seems to be standard based on superficial code flow, which suggests a change in part of the algorithm that would not be visible based on looking at disassembly—for instance, a global variable. The logical candidate for this would be an SBOX.

The writeup by Henrique Marcomini includes a Python array for both the SBOX and the reverse SBOX.

aes_sbox = [
    [int('63', 16), int('7c', 16), int('77', 16), int('7b', 16), int('f2', 16), int('6b', 16), int('6f', 16), int('c5', 16), int(
        '30', 16), int('01', 16), int('67', 16), int('2b', 16), int('fe', 16), int('d7', 16), int('ab', 16), int('76', 16)],
    [int('ca', 16), int('82', 16), int('c9', 16), int('7d', 16), int('fa', 16), int('59', 16), int('47', 16), int('f0', 16), int(
        'ad', 16), int('d4', 16), int('a2', 16), int('af', 16), int('9c', 16), int('a4', 16), int('72', 16), int('c0', 16)],
    [int('b7', 16), int('fd', 16), int('93', 16), int('26', 16), int('36', 16), int('3f', 16), int('f7', 16), int('cc', 16), int(
        '34', 16), int('a5', 16), int('e5', 16), int('f1', 16), int('71', 16), int('d8', 16), int('31', 16), int('15', 16)],
    [int('04', 16), int('c7', 16), int('23', 16), int('c3', 16), int('18', 16), int('96', 16), int('05', 16), int('9a', 16), int(
        '07', 16), int('12', 16), int('80', 16), int('e2', 16), int('eb', 16), int('27', 16), int('b2', 16), int('75', 16)],
    [int('09', 16), int('83', 16), int('2c', 16), int('1a', 16), int('1b', 16), int('6e', 16), int('5a', 16), int('a0', 16), int(
        '52', 16), int('3b', 16), int('d6', 16), int('b3', 16), int('29', 16), int('e3', 16), int('2f', 16), int('84', 16)],
    [int('53', 16), int('d1', 16), int('00', 16), int('ed', 16), int('20', 16), int('fc', 16), int('b1', 16), int('5b', 16), int(
        '6a', 16), int('cb', 16), int('be', 16), int('39', 16), int('4a', 16), int('4c', 16), int('58', 16), int('cf', 16)],
    [int('d0', 16), int('ef', 16), int('aa', 16), int('fb', 16), int('43', 16), int('4d', 16), int('33', 16), int('85', 16), int(
        '45', 16), int('f9', 16), int('02', 16), int('7f', 16), int('50', 16), int('3c', 16), int('9f', 16), int('a8', 16)],
    [int('51', 16), int('a3', 16), int('40', 16), int('8f', 16), int('92', 16), int('9d', 16), int('38', 16), int('f5', 16), int(
        'bc', 16), int('b6', 16), int('da', 16), int('21', 16), int('10', 16), int('ff', 16), int('f3', 16), int('d2', 16)],
    [int('cd', 16), int('0c', 16), int('13', 16), int('ec', 16), int('5f', 16), int('97', 16), int('44', 16), int('17', 16), int(
        'c4', 16), int('a7', 16), int('7e', 16), int('3d', 16), int('64', 16), int('5d', 16), int('19', 16), int('73', 16)],
    [int('60', 16), int('81', 16), int('4f', 16), int('dc', 16), int('22', 16), int('2a', 16), int('90', 16), int('88', 16), int(
        '46', 16), int('ee', 16), int('b8', 16), int('14', 16), int('de', 16), int('5e', 16), int('0b', 16), int('db', 16)],
    [int('e0', 16), int('32', 16), int('3a', 16), int('0a', 16), int('49', 16), int('06', 16), int('24', 16), int('5c', 16), int(
        'c2', 16), int('d3', 16), int('ac', 16), int('62', 16), int('91', 16), int('95', 16), int('e4', 16), int('79', 16)],
    [int('e7', 16), int('c8', 16), int('37', 16), int('6d', 16), int('8d', 16), int('d5', 16), int('4e', 16), int('a9', 16), int(
        '6c', 16), int('56', 16), int('f4', 16), int('ea', 16), int('65', 16), int('7a', 16), int('ae', 16), int('08', 16)],
    [int('ba', 16), int('78', 16), int('25', 16), int('2e', 16), int('1c', 16), int('a6', 16), int('b4', 16), int('c6', 16), int(
        'e8', 16), int('dd', 16), int('74', 16), int('1f', 16), int('4b', 16), int('bd', 16), int('8b', 16), int('8a', 16)],
    [int('70', 16), int('3e', 16), int('b5', 16), int('66', 16), int('48', 16), int('03', 16), int('f6', 16), int('0e', 16), int(
        '61', 16), int('35', 16), int('57', 16), int('b9', 16), int('86', 16), int('c1', 16), int('1d', 16), int('9e', 16)],
    [int('e1', 16), int('f8', 16), int('98', 16), int('11', 16), int('69', 16), int('d9', 16), int('8e', 16), int('94', 16), int(
        '9b', 16), int('1e', 16), int('87', 16), int('e9', 16), int('ce', 16), int('55', 16), int('28', 16), int('df', 16)],
    [int('8c', 16), int('a1', 16), int('89', 16), int('0d', 16), int('bf', 16), int('e6', 16), int('42', 16), int('68', 16), int(
        '41', 16), int('99', 16), int('2d', 16), int('0f', 16), int('b0', 16), int('54', 16), int('bb', 16), int('16', 16)]
]

reverse_aes_sbox = [
    [int('52', 16), int('09', 16), int('6a', 16), int('d5', 16), int('30', 16), int('36', 16), int('a5', 16), int('38', 16), int(
        'bf', 16), int('40', 16), int('a3', 16), int('9e', 16), int('81', 16), int('f3', 16), int('d7', 16), int('fb', 16)],
    [int('7c', 16), int('e3', 16), int('39', 16), int('82', 16), int('9b', 16), int('2f', 16), int('ff', 16), int('87', 16), int(
        '34', 16), int('8e', 16), int('43', 16), int('44', 16), int('c4', 16), int('de', 16), int('e9', 16), int('cb', 16)],
    [int('54', 16), int('7b', 16), int('94', 16), int('32', 16), int('a6', 16), int('c2', 16), int('23', 16), int('3d', 16), int(
        'ee', 16), int('4c', 16), int('95', 16), int('0b', 16), int('42', 16), int('fa', 16), int('c3', 16), int('4e', 16)],
    [int('08', 16), int('2e', 16), int('a1', 16), int('66', 16), int('28', 16), int('d9', 16), int('24', 16), int('b2', 16), int(
        '76', 16), int('5b', 16), int('a2', 16), int('49', 16), int('6d', 16), int('8b', 16), int('d1', 16), int('25', 16)],
    [int('72', 16), int('f8', 16), int('f6', 16), int('64', 16), int('86', 16), int('68', 16), int('98', 16), int('16', 16), int(
        'd4', 16), int('a4', 16), int('5c', 16), int('cc', 16), int('5d', 16), int('65', 16), int('b6', 16), int('92', 16)],
    [int('6c', 16), int('70', 16), int('48', 16), int('50', 16), int('fd', 16), int('ed', 16), int('b9', 16), int('da', 16), int(
        '5e', 16), int('15', 16), int('46', 16), int('57', 16), int('a7', 16), int('8d', 16), int('9d', 16), int('84', 16)],
    [int('90', 16), int('d8', 16), int('ab', 16), int('00', 16), int('8c', 16), int('bc', 16), int('d3', 16), int('0a', 16), int(
        'f7', 16), int('e4', 16), int('58', 16), int('05', 16), int('b8', 16), int('b3', 16), int('45', 16), int('06', 16)],
    [int('d0', 16), int('2c', 16), int('1e', 16), int('8f', 16), int('ca', 16), int('3f', 16), int('0f', 16), int('02', 16), int(
        'c1', 16), int('af', 16), int('bd', 16), int('03', 16), int('01', 16), int('13', 16), int('8a', 16), int('6b', 16)],
    [int('3a', 16), int('91', 16), int('11', 16), int('41', 16), int('4f', 16), int('67', 16), int('dc', 16), int('ea', 16), int(
        '97', 16), int('f2', 16), int('cf', 16), int('ce', 16), int('f0', 16), int('b4', 16), int('e6', 16), int('73', 16)],
    [int('96', 16), int('ac', 16), int('74', 16), int('22', 16), int('e7', 16), int('ad', 16), int('35', 16), int('85', 16), int(
        'e2', 16), int('f9', 16), int('37', 16), int('e8', 16), int('1c', 16), int('75', 16), int('df', 16), int('6e', 16)],
    [int('47', 16), int('f1', 16), int('1a', 16), int('71', 16), int('1d', 16), int('29', 16), int('c5', 16), int('89', 16), int(
        '6f', 16), int('b7', 16), int('62', 16), int('0e', 16), int('aa', 16), int('18', 16), int('be', 16), int('1b', 16)],
    [int('fc', 16), int('56', 16), int('3e', 16), int('4b', 16), int('c6', 16), int('d2', 16), int('79', 16), int('20', 16), int(
        '9a', 16), int('db', 16), int('c0', 16), int('fe', 16), int('78', 16), int('cd', 16), int('5a', 16), int('f4', 16)],
    [int('1f', 16), int('dd', 16), int('a8', 16), int('33', 16), int('88', 16), int('07', 16), int('c7', 16), int('31', 16), int(
        'b1', 16), int('12', 16), int('10', 16), int('59', 16), int('27', 16), int('80', 16), int('ec', 16), int('5f', 16)],
    [int('60', 16), int('51', 16), int('7f', 16), int('a9', 16), int('19', 16), int('b5', 16), int('4a', 16), int('0d', 16), int(
        '2d', 16), int('e5', 16), int('7a', 16), int('9f', 16), int('93', 16), int('c9', 16), int('9c', 16), int('ef', 16)],
    [int('a0', 16), int('e0', 16), int('3b', 16), int('4d', 16), int('ae', 16), int('2a', 16), int('f5', 16), int('b0', 16), int(
        'c8', 16), int('eb', 16), int('bb', 16), int('3c', 16), int('83', 16), int('53', 16), int('99', 16), int('61', 16)],
    [int('17', 16), int('2b', 16), int('04', 16), int('7e', 16), int('ba', 16), int('77', 16), int('d6', 16), int('26', 16), int(
        'e1', 16), int('69', 16), int('14', 16), int('63', 16), int('55', 16), int('21', 16), int('0c', 16), int('7d', 16)]
]

After converting this to raw bytes, one possibility is to search memory for matching data. This objective is achievable by stepping until the call to aes_ecb_decrypt, downloading a memory snapshot at that stage in execution, reading it into a giant Python bytestring, and using the "find" method to locate substrings that match either the regular or reverse SBOX. A constrained search of only the data from address 0x48ae is also possible. The code below implements the latter option.

r = []
for x in reverse_aes_sbox:
    for i in x:
        r.append(i)
g = b''
for i in [x.to_bytes(1, "big") for x in r]:
    g += i
print(data.find(g))


r = []
for x in aes_sbox:
    for i in x:
        r.append(i)
g = b''
for i in [x.to_bytes(1, "big") for x in r]:
    g += i
print(data.find(g))

The aes_sbox and reverse_aes_sbox variables are taken verbatim from the Marcomini writeup. The data variable was defined previously as a bytestring containing the data from address 0x48ae. Running this code produces the following output:

$ python3 sbox_search.py 
176
-1

This output indicates that the script located the reverse SBOX starting at the 176th byte from the beginning of the data blob at address 0x48ae. The regular SBOX is not present.

48a0: 4343 4553 5320 4752 414e 5445 4400 7f78
48b0: 75e0 c977 d30c e85e ca19 d022 11f7 4b53
48c0: 0b31 b5cd 58d3 f59d c5a9 c583 c4f3 6f1a
48d0: f5bb fe9e 53e2 4050 9d7a 301e 015a 6259
48e0: a739 9184 a659 bece ce98 704e 9c20 5393
48f0: 45a8 f3dd 0160 2f4a 68c1 ce80 52b8 7007
4900: 6c8b a04e 44c8 dc97 69a1 e1ca 3a79 ff47
4910: b02e d049 2843 7cd9 2d69 3d5d 53d8 d980
4920: 482f 2f0e 986d ac90 052a 4184 7eb1 7dcd
4930: 0f8e f68e d042 839e 9d47 ed14 7b9b f213
4940: 8f14 8b43 dfcc 7510 4d05 6e8a e6dc 7b2f
4950: 0d18 8af1 fa20 493c d251 f10b bcb5 5209
4960: 6ad5 3036 a538 bf40 a39e 81f3 d7fb 7ce3
4970: 3982 9b2f ff87 348e 4344 c4de e9cb 547b
4980: 9432 a6c2 233d ee4c 950b 42fa c34e 082e
4990: a166 28d9 24b2 765b a249 6d8b d125 72f8
49a0: f664 8668 9816 d4a4 5ccc 5d65 b692 6c70
49b0: 4850 fded b9da 5e15 4657 a78d 9d84 90d8
49c0: ab00 8cbc d30a f7e4 5805 b8b3 4506 d02c
49d0: 1e8f ca3f 0f02 c1af bd03 0113 8a6b 3a91
49e0: 1141 4f67 dcea 97f2 cfce f0b4 e673 96ac
49f0: 7422 e7ad 3585 e2f9 37e8 1c75 df6e 47f1
4a00: 1a71 1d29 c589 6fb7 620e aa18 be1b fc56
4a10: 3e4b c6d2 7920 9adb c0fe 78cd 5af4 1fdd
4a20: a833 8807 c731 b112 1059 2780 ec5f 6051
4a30: 7fa9 19b5 4a0d 2de5 7a9f 93c9 9cef a0e0
4a40: 3b4d ae2a f5b0 c8eb bb3c 8353 9961 172b
4a50: 047e ba77 d626 e169 1463 5521 0c7d 0000
4a60: 0000 0000 0000 0000 0000 0000 0000 0000

The solitary reverse SBOX indicates that the algorithm performs AES ECB decryption rather than encryption, reinforcing the conjecture that the implementation is standard and implying that the first 176 bytes contain the key.

48a0: 4343 4553 5320 4752 414e 5445 4400 7f78
48b0: 75e0 c977 d30c e85e ca19 d022 11f7 4b53
48c0: 0b31 b5cd 58d3 f59d c5a9 c583 c4f3 6f1a
48d0: f5bb fe9e 53e2 4050 9d7a 301e 015a 6259
48e0: a739 9184 a659 bece ce98 704e 9c20 5393
48f0: 45a8 f3dd 0160 2f4a 68c1 ce80 52b8 7007
4900: 6c8b a04e 44c8 dc97 69a1 e1ca 3a79 ff47
4910: b02e d049 2843 7cd9 2d69 3d5d 53d8 d980
4920: 482f 2f0e 986d ac90 052a 4184 7eb1 7dcd
4930: 0f8e f68e d042 839e 9d47 ed14 7b9b f213
4940: 8f14 8b43 dfcc 7510 4d05 6e8a e6dc 7b2f
4950: 0d18 8af1 fa20 493c d251 f10b bcb5 5209
4960: 6ad5 3036 a538 bf40 a39e 81f3 d7fb 7ce3
4970: 3982 9b2f ff87 348e 4344 c4de e9cb 547b
4980: 9432 a6c2 233d ee4c 950b 42fa c34e 082e
4990: a166 28d9 24b2 765b a249 6d8b d125 72f8
49a0: f664 8668 9816 d4a4 5ccc 5d65 b692 6c70
49b0: 4850 fded b9da 5e15 4657 a78d 9d84 90d8
49c0: ab00 8cbc d30a f7e4 5805 b8b3 4506 d02c
49d0: 1e8f ca3f 0f02 c1af bd03 0113 8a6b 3a91
49e0: 1141 4f67 dcea 97f2 cfce f0b4 e673 96ac
49f0: 7422 e7ad 3585 e2f9 37e8 1c75 df6e 47f1
4a00: 1a71 1d29 c589 6fb7 620e aa18 be1b fc56
4a10: 3e4b c6d2 7920 9adb c0fe 78cd 5af4 1fdd
4a20: a833 8807 c731 b112 1059 2780 ec5f 6051
4a30: 7fa9 19b5 4a0d 2de5 7a9f 93c9 9cef a0e0
4a40: 3b4d ae2a f5b0 c8eb bb3c 8353 9961 172b
4a50: 047e ba77 d626 e169 1463 5521 0c7d 0000
4a60: 0000 0000 0000 0000 0000 0000 0000 0000

Key Expansion Omission

Double checking the Wikipedia overview, a step is notably missing from the aes_ecb_decrypt function: KeyExpansion.

Round keys are derived from the cipher key using the AES key schedule. AES requires a separate 128-bit round key block for each round plus one more. — Wikipedia

Assuming there are ten rounds (which seems to be the case), there should be eleven round keys that are 16 bytes long. The total length of an array containing these keys would be 176 bytes—the exact size of the data located before the start of the reverse SBOX. The hypothesis that this data is a round key array is supported by observing the runtime behavior of aes_ecb_decrypt.

470a: mov	r10, r14
470c: add	#0x10, r14
4710: mov	r11, r15
4712: call	#0x452c <aesdec128>

Breaking at address 0x4712 and inspecting the register state yields the following:

pc  4712  sp 43ea  sr 0000  cg 0000
r04 0000 r05 5a08 r06 0000 r07 0000 
r08 0000 r09 0000 r10 48ae r11 43f0 
r12 48bd r13 0010 r14 48be r15 43f0 

The pointer into the round key array (R14) contains address 0x48ae at the start and every iteration of aesdec128 increments that pointer by 0x10 (decimal 16).


        ┌────┬─────────────────────────────┐
        │4716│mov       r10, r14           │
 ┌───┐  ├────┼─────────────────────────────┤
 │PC¹├─►│4718│add       #0x20, r14         │
 └───┘  ├────┼─────────────────────────────┤
        │471c│mov       r11, r15           │
        ├────┼─────────────────────────────┤
        │471e│call      #0x452c <aesdec128>│
        └────┴─────────────────────────────┘

        ┌────┬───────────────────────────────────────┐
        │48a0│4343 4553 5320 4752 414e 5445 4400 7f78│
        ├────┼───────────────────────────────────────┤
        │48b0│75e0 c977 d30c e85e ca19 d022 11f7 4b53│
┌────┐  ├────┼───────────────────────────────────────┤
│R14¹├─►│48c0│0b31 b5cd 58d3 f59d c5a9 c583 c4f3 6f1a│
└────┘  ├────┼───────────────────────────────────────┤
	│48d0│f5bb fe9e 53e2 4050 9d7a 301e 015a 6259│
        ├────┼───────────────────────────────────────┤
        │48e0│a739 9184 a659 bece ce98 704e 9c20 5393│
        ├────┼───────────────────────────────────────┤
        │48f0│45a8 f3dd 0160 2f4a 68c1 ce80 52b8 7007│
        ├────┼───────────────────────────────────────┤
        │4900│6c8b a04e 44c8 dc97 69a1 e1ca 3a79 ff47│
        ├────┼───────────────────────────────────────┤
        │4910│b02e d049 2843 7cd9 2d69 3d5d 53d8 d980│
        ├────┼───────────────────────────────────────┤
        │4920│482f 2f0e 986d ac90 052a 4184 7eb1 7dcd│
        ├────┼───────────────────────────────────────┤
        │4930│0f8e f68e d042 839e 9d47 ed14 7b9b f213│
        ├────┼───────────────────────────────────────┤
        │4940│8f14 8b43 dfcc 7510 4d05 6e8a e6dc 7b2f│
        ├────┼───────────────────────────────────────┤
        │4950│0d18 8af1 fa20 493c d251 f10b bcb5 5209│
        └────┴───────────────────────────────────────┘

        ┌────┬─────────────────────────────┐
        │4722│mov       r10, r14           │
 ┌───┐  ├────┼─────────────────────────────┤
 │PC²├─►│4724│add       #0x30, r14         │
 └───┘  ├────┼─────────────────────────────┤
        │4728│mov       r11, r15           │
        ├────┼─────────────────────────────┤
        │472a│call      #0x452c <aesdec128>│
        └────┴─────────────────────────────┘

        ┌────┬───────────────────────────────────────┐
        │48a0│4343 4553 5320 4752 414e 5445 4400 7f78│
        ├────┼───────────────────────────────────────┤
        │48b0│75e0 c977 d30c e85e ca19 d022 11f7 4b53│
        ├────┼───────────────────────────────────────┤
        │48c0│0b31 b5cd 58d3 f59d c5a9 c583 c4f3 6f1a│
┌────┐  ├────┼───────────────────────────────────────┤
│R14²├─►│48d0│f5bb fe9e 53e2 4050 9d7a 301e 015a 6259│
└────┘  ├────┼───────────────────────────────────────┤
	│48e0│a739 9184 a659 bece ce98 704e 9c20 5393│
        ├────┼───────────────────────────────────────┤
        │48f0│45a8 f3dd 0160 2f4a 68c1 ce80 52b8 7007│
        ├────┼───────────────────────────────────────┤
        │4900│6c8b a04e 44c8 dc97 69a1 e1ca 3a79 ff47│
        ├────┼───────────────────────────────────────┤
        │4910│b02e d049 2843 7cd9 2d69 3d5d 53d8 d980│
        ├────┼───────────────────────────────────────┤
        │4920│482f 2f0e 986d ac90 052a 4184 7eb1 7dcd│
        ├────┼───────────────────────────────────────┤
        │4930│0f8e f68e d042 839e 9d47 ed14 7b9b f213│
        ├────┼───────────────────────────────────────┤
        │4940│8f14 8b43 dfcc 7510 4d05 6e8a e6dc 7b2f│
        ├────┼───────────────────────────────────────┤
        │4950│0d18 8af1 fa20 493c d251 f10b bcb5 5209│
        └────┴───────────────────────────────────────┘

        ┌────┬─────────────────────────────┐
        │472e│mov       r10, r14           │
 ┌───┐  ├────┼─────────────────────────────┤
 │PC³├─►│4730│add       #0x40, r14         │
 └───┘  ├────┼─────────────────────────────┤
        │4734│mov       r11, r15           │
        ├────┼─────────────────────────────┤
        │4736│call      #0x452c <aesdec128>│
        └────┴─────────────────────────────┘

        ┌────┬───────────────────────────────────────┐
        │48a0│4343 4553 5320 4752 414e 5445 4400 7f78│
        ├────┼───────────────────────────────────────┤
        │48b0│75e0 c977 d30c e85e ca19 d022 11f7 4b53│
        ├────┼───────────────────────────────────────┤
        │48c0│0b31 b5cd 58d3 f59d c5a9 c583 c4f3 6f1a│
        ├────┼───────────────────────────────────────┤
        │48d0│f5bb fe9e 53e2 4050 9d7a 301e 015a 6259│
┌────┐  ├────┼───────────────────────────────────────┤
│R14³├─►│48e0│a739 9184 a659 bece ce98 704e 9c20 5393│
└────┘  ├────┼───────────────────────────────────────┤
	│48f0│45a8 f3dd 0160 2f4a 68c1 ce80 52b8 7007│
        ├────┼───────────────────────────────────────┤
        │4900│6c8b a04e 44c8 dc97 69a1 e1ca 3a79 ff47│
        ├────┼───────────────────────────────────────┤
        │4910│b02e d049 2843 7cd9 2d69 3d5d 53d8 d980│
        ├────┼───────────────────────────────────────┤
        │4920│482f 2f0e 986d ac90 052a 4184 7eb1 7dcd│
        ├────┼───────────────────────────────────────┤
        │4930│0f8e f68e d042 839e 9d47 ed14 7b9b f213│
        ├────┼───────────────────────────────────────┤
        │4940│8f14 8b43 dfcc 7510 4d05 6e8a e6dc 7b2f│
        ├────┼───────────────────────────────────────┤
        │4950│0d18 8af1 fa20 493c d251 f10b bcb5 5209│
        └────┴───────────────────────────────────────┘

This pattern suggests that each 16-byte chunk is a different round key. Whether as an optimization or a deliberate reverse engineering countermeasure, the developers have hard-coded the array of round keys and skipped the key expansion step entirely.

The next step is determining how to reverse the key expansion process to recover the original key. A third writeup at this link describes the AES-128 key expansion algorithm.

The cipher key K is actually the first round key: K0 = K.
K0 = K is only applicable for AES-128. — Braincoke

If the implementation is standard, the first round key in the array is the cipher key.

48a0: 4343 4553 5320 4752 414e 5445 4400 7f78
48b0: 75e0 c977 d30c e85e ca19 d022 11f7 4b53
48c0: 0b31 b5cd 58d3 f59d c5a9 c583 c4f3 6f1a
48d0: f5bb fe9e 53e2 4050 9d7a 301e 015a 6259
48e0: a739 9184 a659 bece ce98 704e 9c20 5393
48f0: 45a8 f3dd 0160 2f4a 68c1 ce80 52b8 7007
4900: 6c8b a04e 44c8 dc97 69a1 e1ca 3a79 ff47
4910: b02e d049 2843 7cd9 2d69 3d5d 53d8 d980
4920: 482f 2f0e 986d ac90 052a 4184 7eb1 7dcd
4930: 0f8e f68e d042 839e 9d47 ed14 7b9b f213
4940: 8f14 8b43 dfcc 7510 4d05 6e8a e6dc 7b2f
4950: 0d18 8af1 fa20 493c d251 f10b bcb5 5209
4960: 6ad5 3036 a538 bf40 a39e 81f3 d7fb 7ce3
4970: 3982 9b2f ff87 348e 4344 c4de e9cb 547b
4980: 9432 a6c2 233d ee4c 950b 42fa c34e 082e
4990: a166 28d9 24b2 765b a249 6d8b d125 72f8
49a0: f664 8668 9816 d4a4 5ccc 5d65 b692 6c70
49b0: 4850 fded b9da 5e15 4657 a78d 9d84 90d8
49c0: ab00 8cbc d30a f7e4 5805 b8b3 4506 d02c
49d0: 1e8f ca3f 0f02 c1af bd03 0113 8a6b 3a91
49e0: 1141 4f67 dcea 97f2 cfce f0b4 e673 96ac
49f0: 7422 e7ad 3585 e2f9 37e8 1c75 df6e 47f1
4a00: 1a71 1d29 c589 6fb7 620e aa18 be1b fc56
4a10: 3e4b c6d2 7920 9adb c0fe 78cd 5af4 1fdd
4a20: a833 8807 c731 b112 1059 2780 ec5f 6051
4a30: 7fa9 19b5 4a0d 2de5 7a9f 93c9 9cef a0e0
4a40: 3b4d ae2a f5b0 c8eb bb3c 8353 9961 172b
4a50: 047e ba77 d626 e169 1463 5521 0c7d 0000
4a60: 0000 0000 0000 0000 0000 0000 0000 0000

Proceeding from the assumption that this implementation is AES-128 (rather than AES-192 or AES-256), that begs the question: if the first 16-byte round key in the 176-byte array is the encryption key itself, why did the previous attack fail when attempting to use that key?

>>> ciphertext = encrypt(data[0:16], b"ACCESS GRANTED!\x00")
>>> binascii.hexlify(ciphertext)
b'6e8cbcfc2f4ec33c47c6587da6547e15'

Encrypting the string "ACCESS GRANTED!" with the first 16-byte key in the array and supplying it at the I/O console should trigger the unlock interrupt.

I/O Console Output
SCAN SECURITY DEVICE

The unlock is not triggered, disproving that theory.

Reversible Algorithms

For a reversible algorithm to function, it must be possible to take the output and run it through the algorithm "backward" to produce the original input. The AES encryption algorithm starts at the first round key and iterates through the array until it reaches the last. Even without knowing precisely how this works, the equivalent decryption algorithm must logically start at the final round key and iterate successively backward until it reaches the first. The implication is that a hard-coded round key array for an AES decryption algorithm reverses the element order. Thus, the last round key used by the decryption algorithm must be the original encryption key. If there are eleven 16-byte round keys for AES-128, the last one will start 160 bytes into the array and will be the last element in memory before the start of the SBOX.

48a0: 4343 4553 5320 4752 414e 5445 4400 7f78
48b0: 75e0 c977 d30c e85e ca19 d022 11f7 4b53
48c0: 0b31 b5cd 58d3 f59d c5a9 c583 c4f3 6f1a
48d0: f5bb fe9e 53e2 4050 9d7a 301e 015a 6259
48e0: a739 9184 a659 bece ce98 704e 9c20 5393
48f0: 45a8 f3dd 0160 2f4a 68c1 ce80 52b8 7007
4900: 6c8b a04e 44c8 dc97 69a1 e1ca 3a79 ff47
4910: b02e d049 2843 7cd9 2d69 3d5d 53d8 d980
4920: 482f 2f0e 986d ac90 052a 4184 7eb1 7dcd
4930: 0f8e f68e d042 839e 9d47 ed14 7b9b f213
4940: 8f14 8b43 dfcc 7510 4d05 6e8a e6dc 7b2f
4950: 0d18 8af1 fa20 493c d251 f10b bcb5 5209
4960: 6ad5 3036 a538 bf40 a39e 81f3 d7fb 7ce3
4970: 3982 9b2f ff87 348e 4344 c4de e9cb 547b
4980: 9432 a6c2 233d ee4c 950b 42fa c34e 082e
4990: a166 28d9 24b2 765b a249 6d8b d125 72f8
49a0: f664 8668 9816 d4a4 5ccc 5d65 b692 6c70
49b0: 4850 fded b9da 5e15 4657 a78d 9d84 90d8
49c0: ab00 8cbc d30a f7e4 5805 b8b3 4506 d02c
49d0: 1e8f ca3f 0f02 c1af bd03 0113 8a6b 3a91
49e0: 1141 4f67 dcea 97f2 cfce f0b4 e673 96ac
49f0: 7422 e7ad 3585 e2f9 37e8 1c75 df6e 47f1
4a00: 1a71 1d29 c589 6fb7 620e aa18 be1b fc56
4a10: 3e4b c6d2 7920 9adb c0fe 78cd 5af4 1fdd
4a20: a833 8807 c731 b112 1059 2780 ec5f 6051
4a30: 7fa9 19b5 4a0d 2de5 7a9f 93c9 9cef a0e0
4a40: 3b4d ae2a f5b0 c8eb bb3c 8353 9961 172b
4a50: 047e ba77 d626 e169 1463 5521 0c7d 0000
4a60: 0000 0000 0000 0000 0000 0000 0000 0000

Testing this theory can be accomplished with the following code in the Python REPL.

>>> binascii.hexlify(encrypt(data[160:176], b"ACCESS GRANTED!\x00"))
b'aaf7e3ad17bcfd3240422d65fe3ea1b7'

Providing the resulting hex string at the program prompt results in successful authentication and an unlock interrupt call. The final payload, sans quotes, is as follows:

aaf7e3ad17bcfd3240422d65fe3ea1b7
Door Unlocked
The CPU completed in 24284 cycles.

Remediation

The core problem is that the developers used encryption when they should have used hashing. The Adobe breach is a famous example of this flaw. It is common in many systems, but there are two frequent variations.

1. Authentication.

Hardcoded symmetric encryption keys are insecure credentials for this variety of authentication. As demonstrated above, anyone can extract the key from firmware and reverse the operation to derive the original password. Conventional hashing algorithms or key derivation functions (KDFs) are significantly more secure alternatives, although—in both cases—passwords must still be unique per device.

2. Encryption at rest.

A common practice (in some circles) is using AES with hardcoded keys for "encryption at rest." The implementation often uses a global key to decrypt persistent data on every run. Anyone capable of acquiring the compiled binary can extract the key and decrypt this data with relatively little effort.

Hashing and KDFs work well if the implementation uses unique credentials per system, but offline password brute-forcing is still a risk if the data is of sufficient value (e.g., PII or secondary global secrets). There are two potential ways to solve this problem:

  1. Cloud-based authentication.
  2. Hardware-backed authentication.

Both options enable rate limiting, lockouts, and programmatic key destruction if necessary, which prevents an attacker from employing offline brute-forcing.