Blue Teaming
Odysseus (c4n0pus),
Dec 20
2024
Odysseus (c4n0pus), Forensic Content Engineer at Hack The Box, deep dives into Signal’s move to safeStorage API and how he created the “Signaling Victorious” University CTF Challenge. Note: If you’re only interested in the Signal parts, skip to “Signal source code deep-dive” and “solve” sections.
For the past three years, I’ve been working on the content side of CTFs, creating (Forensic) challenges. One thing friends and colleagues always ask me is where the ideas come from, and because of the nature of the category (memory snapshots, disk images, virtual machines, etc.), what is the process of creating these challenges.
Now, since 90% of my challenges are of the “malware analysis” type, or weaponization of an obscure technique, I wanted University CTF this year to have a more “data” oriented challenge, with the implication of the relevant security systems of course.
In this blog post, I will be going over how this idea came to be, how it was implemented (including the pitfalls I faced while developing it), and of course, how to solve the challenge!
As many of you already know, the Forensic category in the CTF scene has a bit of a reputation for being overly reliant guesswork—something I completely agree with. One of the main issues I’ve noticed with Forensic challenges is the lack of meaningful flow.
You’re often handed some evidence with no context about what the data represents, no clear indication of what to do with it, or worse, no idea how it fits into the overall solution chain.
For example:
You’re given a network capture, but there’s no meaningful data in the TCP packets. The solution involves figuring out guessing that for every two packets, the difference in port numbers represents one character of the flag.
Or you retrieve an encrypted zip file, only to realize it’s the final step of the challenge. Yet you spend hours hunting for the password because the method of hiding it was unnecessarily obscure and impossible to deduce intuitively.
Now, don’t get me wrong—as a forensic analyst, it’s part of the job to solve “puzzles” like these (talking about the second bullet point). However, in the context of a CTF competition, where time management is critical, I believe the focus should shift towards methodology, technique, and tooling, rather than trying to reverse-engineer guess some obscure XOR-ing pattern…
This is why I always strive to incorporate a consistent “storyline” in my challenges. My goal is to create a logical flow that guides players through the artifact-handling process, making the experience engaging and rewarding. A challenge should be both methodical and grounded in practical techniques, not arbitrary guesswork.
A prime example of a well-crafted challenge is Securinets’ 2023 Jackpot. While I didn’t manage to solve it (I made it as far as the Metamask step but got stuck due to my lack of familiarity with the possibilities of hiberfil.sys), it left a lasting impression. This challenge even inspired my creation of Cyber Apocalypse’s Oblique Finale this year.
But I digress, all of this is to illustrate how I approach the creation of a challenge. I’m meticulous with how I handle artifacts, aiming to craft challenges that evolve naturally, are informative for both myself as the author and the players, and deliver a cohesive experience.
So, without further ado…
I always wanted to create a challenge that revolved around a messaging app, but WhatsApp and Viber are not only closed-source, they feel rudimentary, in the sense that a lot of information is already available so you just implement a decryption script and that’s it.
Signal being open-source, lets us examine the code. Because of the recent (not positive) headlines, I could be more optimistic that there will be limited research available so researching and playing around with the code will fall upon the player.
From the aforementioned link, we read that up until July of this year, the Signal desktop app (Linux, Windows, macOS), had been encrypting the SQLite database that holds the messages with a key that was stored in plain text under config.json in the User’s Home folder!
This, plus the fact they tried to “downplay” the severity of this, as you can imagine, sparked some controversy among their users. As a result, the Signal team merged the already-made pull request, moving to Electron’s safeStorage API and storing an encrypted key.
In a completely unrelated set of events, last April, I participated in CCSC (Cyprus CyberSecurity Challenge) which serves as our qualifier for the National Team for ECSC (European CyberSecurity Challenge). There I encountered a challenge called Orion Lsassault.
The “TL;DR” of this challenge was that the provided lsass.dmp minidump was taken from the (then) latest version of Windows 11, which mimikatz and pypykatz had not been upgraded yet to support it (The wdigest symbol was not resolvable). The solution was to open it using WinDbg so it would download the needed symbols and then use the mimilib.dll library to read the wdigest. Or you could brute force the address of the symbol and “crudely” add support for it.
This only added to my already existing fascination with Windows security internals and memory snapshots, and I wanted to look into them more (LSASS, DPAPI, mimikatz, etc.), so when the Signal change rolled around a couple of months later, it was a perfect match!
On a random weekday afternoon in July, after reading about the changes, I started playing around with the code, just trying to see what I could do, and lo and behold a decryption PoC was available!
So the idea was clear: provide the players with Signal’s data, find a way to meaningfully provide artifacts that will facilitate DPAPI decryption (full memory dump, or process dump), and finally create a pretext around them.
Now with the idea set, let’s take a look at how Signal creates the database and what the procedure is for storing the key. These are the steps I took to learn how Signal works when creating my PoC. We will check out the packing and delivery further down.
From the app/main.ts file:
async function initializeSQL(
userDataPath: string
): Promise<{ ok: true; error: undefined } | { ok: false; error: Error }> {
sqlInitTimeStart = Date.now();
let key: string;
try {
key = getSQLKey();
} catch (error) {
[...]
}
try {
// This should be the first awaited call in this function, otherwise
// `sql.sqlRead` will throw an uninitialized error instead of waiting for
// init to finish.
await sql.initialize({
appVersion: app.getVersion(),
configDir: userDataPath,
key,
logger: getLogger(),
});
[...]
}
[...]
return { ok: true, error: undefined };
}
As you can see, the initializeSQL() function is responsible for creating the key using the getSQLKey() function and then creating the database using sql.initialize(). Before getting any further, let’s check out the generateSQLKey() function:
function generateSQLKey(): string {
getLogger().info(
'key/initialize: Generating new encryption key, since we did not find it on disk'
);
// https://www.zetetic.net/sqlcipher/sqlcipher-api/#key
return randomBytes(32).toString('hex');
}
From the comment above the return line, we can see it’s using sqlcipher, which is a fork of SQLite that adds transparent AES encryption. Since encryption is built-in to the database, meaning the whole database is encrypted instead of encrypting the contents of the database, we don’t really need to look at how data is getting stored/read. If we treat the database as an abstract file, we need only the key to access it.
Now that we have a better understanding of what we are looking for, let’s check out getSQLKey():
function getSQLKey(): string {
[...]
let key: string;
if (typeof modernKeyValue === 'string') {
[...]
getLogger().info('getSQLKey: decrypting key');
const encrypted = Buffer.from(modernKeyValue, 'hex');
key = safeStorage.decryptString(encrypted);
[...]
} else if (typeof legacyKeyValue === 'string') {
[...]
} else {
getLogger().warn("getSQLKey: got key from config, but it wasn't a string");
key = generateSQLKey();
update = true;
}
if (!update) {
return key;
}
if (isEncryptionAvailable) {
getLogger().info('getSQLKey: updating encrypted key in the config');
const encrypted = safeStorage.encryptString(key).toString('hex');
userConfig.set('encryptedKey', encrypted);
userConfig.set('key', undefined);
[...]
} else {
getLogger().info('getSQLKey: updating plaintext key in the config');
userConfig.set('key', key);
}
return key;
There is some code in the beginning that figures out the storage backend, depending on the platform, but we are mainly interested in three lines of code:
key = generateSQLKey();
key = safeStorage.decryptString(encrypted);
const encrypted = safeStorage.encryptString(key).toString('hex');
We can see that, the key is being read from the current config (if any) and we find the first reference of the safeStorage API in use, to decrypt the encrypted key. If the key is not available (for whatever reason) then a new one is generated using generateSQLKey() and later encrypted. The resulting key is then saved under the key name encryptedKey in the config.json file.
Now our next step is to find out some more information about the safeStorage API, for that, we can head over to its documentation page. There, we can see that it uses the platform-specific crypto systems, like DPAPI for Windows and Keychain for MacOS, and all the relevant methods associated with it. We know that DPAPI will be used, but we don’t know yet how it’s being used to encrypt or even if it’s actually used on the SQL key itself (foreshadowing…)
From Electron’s browser API, safe-storage.ts:
const safeStorage = process._linkedBinding('electron_browser_safe_storage');
module.exports = safeStorage;
We find the relevant C++ module that implements the functions and under the Shell API, electron_api_safe_storage.cc we see the implementation of those functions:
[...]
namespace {
bool IsEncryptionAvailable() {
[...]
return OSCrypt::IsEncryptionAvailable();
[...]
}
void SetUsePasswordV10(bool use) {
use_password_v10 = use;
}
[...]
v8::Local<v8::Value> EncryptString(v8::Isolate* isolate,
const std::string& plaintext) {
[...]
std::string ciphertext;
bool encrypted = OSCrypt::EncryptString(plaintext, &ciphertext);
[...]
}
std::string DecryptString(v8::Isolate* isolate, v8::Local<v8::Value> buffer) {
[...]
std::string plaintext;
bool decrypted = OSCrypt::DecryptString(ciphertext, &plaintext);
[...]
return plaintext;
}
} // namespace
[...]
At the bottom, we can find the definition of the TypeScript methods and the binding name:
[...]
void Initialize(v8::Local<v8::Object> exports,
v8::Local<v8::Value> unused,
v8::Local<v8::Context> context,
void* priv) {
v8::Isolate* isolate = context->GetIsolate();
gin_helper::Dictionary dict(isolate, exports);
dict.SetMethod("decryptString", &DecryptString);
dict.SetMethod("encryptString", &EncryptString);
#if BUILDFLAG(IS_LINUX)
dict.SetMethod("getSelectedStorageBackend", &GetSelectedLinuxBackend);
#endif
dict.SetMethod("isEncryptionAvailable", &IsEncryptionAvailable);
dict.SetMethod("setUsePlainTextEncryption", &SetUsePasswordV10);
}
NODE_LINKED_BINDING_CONTEXT_AWARE(electron_browser_safe_storage, Initialize)
However, this is just an abstraction over the actual platform-specific implementations, we can see that because there are nested calls to the OSCrypt namespace. Because Electron bundles chromium underneath, we have to view the source of the OSCrypt module to understand it better, os_crypt_win.cc:
[...]
namespace OSCrypt {
[...]
bool EncryptString(const std::string& plaintext, std::string* ciphertext) {
return OSCryptImpl::GetInstance()->EncryptString(plaintext, ciphertext);
}
bool DecryptString(const std::string& ciphertext, std::string* plaintext) {
return OSCryptImpl::GetInstance()->DecryptString(ciphertext, plaintext);
}
bool IsEncryptionAvailable() {
return OSCryptImpl::GetInstance()->IsEncryptionAvailable();
}
[...]
}
[...]
Let’s look at DecryptString():
bool OSCryptImpl::DecryptString(const std::string& ciphertext,
std::string* plaintext) {
if (!base::StartsWith(ciphertext, kEncryptionVersionPrefix,
base::CompareCase::SENSITIVE))
return DecryptStringWithDPAPI(ciphertext, plaintext);
crypto::Aead aead(crypto::Aead::AES_256_GCM);
const auto key = GetRawEncryptionKey();
aead.Init(&key);
// Obtain the nonce.
const std::string nonce =
ciphertext.substr(sizeof(kEncryptionVersionPrefix) - 1, kNonceLength);
// Strip off the versioning prefix before decrypting.
const std::string raw_ciphertext =
ciphertext.substr(kNonceLength + (sizeof(kEncryptionVersionPrefix) - 1));
return aead.Open(raw_ciphertext, nonce, std::string(), plaintext);
}
At the beginning of the function, there is a check about whether the ciphertext starts with kEncryptionVersionPrefix (“v10”), if it does, then it goes on to call DecryptStringWithDPAPI(), and if it does not, it continues decrypting using AES GCM. Since our encrypted key arrives here in the ciphertext variable, we know that such a prefix does not exist, so decryption must happen using AES GCM.
As hinted before, it seems the SQL Key is not directly encrypted with DPAPI, it’s rather encrypted with a Key that’s responsible for encrypting every string that’s being passed into safeStorage. That key (let’s call it Electron Key) is protected with DPAPI, and it’s different for every Chromium-based app because it’s stored as part of each application’s data.
The function GetRawEncryptionKey() is used to retrieve the key:
std::string OSCryptImpl::GetRawEncryptionKey() {
if (use_mock_key_) {
if (mock_encryption_key_.empty())
mock_encryption_key_.assign(
crypto::HkdfSha256("peanuts", "salt", "info", kKeyLength));
DCHECK(!mock_encryption_key_.empty()) << "Failed to initialize mock key.";
return mock_encryption_key_;
}
DCHECK(!encryption_key_.empty()) << "No key.";
return encryption_key_;
}
It’s just a wrapper that returns encryption_key_ (given that the mock key is not used). Said variable is defined in os_crypt.h:
[...]
// Encryption Key. Set either by calling Init() or SetRawEncryptionKey().
std::string encryption_key_;
[...]
With a convenient comment above which tells us which functions are responsible for setting that variable. So let’s look at OSCryptImpl::Init():
bool OSCryptImpl::Init(PrefService* local_state) {
// Try to pull the key from the local state.
switch (InitWithExistingKey(local_state)) {
case OSCrypt::kSuccess:
return true;
case OSCrypt::kKeyDoesNotExist:
break;
case OSCrypt::kInvalidKeyFormat:
return false;
case OSCrypt::kDecryptionFailed:
break;
}
// If there is no key in the local state, or if DPAPI decryption fails,
// generate a new key.
std::string key(kKeyLength, '\0');
crypto::RandBytes(base::as_writable_byte_span(key));
if (!EncryptAndStoreKey(key, local_state)) {
return false;
}
// This new key is already encrypted with audit flag enabled.
local_state->SetBoolean(kOsCryptAuditEnabledPrefName, true);
encryption_key_.assign(key);
return true;
}
Say for example that this is the first time Signal is launched, the switch statement will probably result in OSCrypt::kKeyDoesNotExist. If that’s the case, then it goes on to generate a new Key and then goes on to call EncryptAndStoreKey():
// Takes `key` and encrypts it with DPAPI, then stores it in the `local_state`.
// Returns true if the key was successfully encrypted and stored.
bool EncryptAndStoreKey(const std::string& key, PrefService* local_state) {
std::string encrypted_key;
if (!EncryptStringWithDPAPI(key, &encrypted_key)) {
return false;
}
// Add header indicating this key is encrypted with DPAPI.
encrypted_key.insert(0, kDPAPIKeyPrefix);
std::string base64_key = base::Base64Encode(encrypted_key);
local_state->SetString(kOsCryptEncryptedKeyPrefName, base64_key);
return true;
}
We finally find the key is encrypted using DPAPI and saved to local_state (with a prefix) under the key os_crypt.encrypted_key. This will translate into a JSON structure of:
{
"os_crypt": {
"encrypted_key": base64
}
}
This key is then assigned to encryption_key_ and retrieved when a call to safeStorage is made when needed.
EncryptStringWithDPAPI() is just a wrapper around Window’s native CryptProtectData(), it sets up buffers and performs some local housekeeping.
So in conclusion:
Electron creates a key for all safeStorage calls.
It encrypts said key using the platform-specific crypto systems (DPAPI).
The Key is stored decrypted in RAM and encrypted on disk.
The SQL key is encrypted with that key.
We can decrypt Electron’s Key if and only if we have the User’s decrypted DPAPI master keys (because it’s stored in that User’s keychain).
Here is a simplified diagram showing the involvement of each subsystem working together:
Now our goal is crystal clear:
Decrypt SQL.
Decrypt SQL Key.
Decrypt Electron Key.
Recover DPAPI master Key.
Before solving the challenge, we first have to create it ( ͡° ͜ʖ ͡°).
Signal mandates that your phone be able to receive text messages, so a (burner) number was needed, actually… two of them.
I bought a SIM card from a local telecom company here in Greece and used a spare phone a friend had lent me (since my dual-SIM phone was maxed out) for one party (Ace). And for the other, I bought a cheap eSIM in Europe that supported a voice/SMS plan I found through eSIMDB and used my iPhone that supported eSIM for the other party (Declan - Boss)
At this stage, I had a clear goal: to provide the Users\ folder containing the Signal data. When it comes to forensic challenges, two of the most common types of artifacts are disk images and memory snapshots, each with its own advantages and limitations.
Using a disk image would require providing either the user's NT hash or their password to decrypt the DPAPI master key (as outlined here). Alternatively, I could include a process dump of the lsass.exe process so players could extract the keys from there. However, this method has been overused in similar challenges, so I wanted to explore other options.
On the other hand, providing a memory dump came with its own challenges. For instance:
File caching: Some files might not be cached in memory, which could lead to missing data.
Sensitive data in memory: Running Signal could result in some messages being stored in memory along with the encryption keys.
Both of them, outside my control… Another potential issue was tool compatibility. Initially, I thought there was no way to dump a process as a minidump using Volatility, which would prevent the use of popular tools like pypykatz or mimikatz. However, I discovered that pypykatz has a Volatility plugin capable of extracting DPAPI keys directly from a full memory dump.
With this solution in hand, I decided to proceed with the memory dump approach!
Now comes the question of “What are they gonna text about?” So since these are the bad guys in the storyline, I made them text about deploying a C2 Server (Empire), an attack they had planned, and then exchange login info. So that’s the “valuable” information the players need to find in the messages.
For setting up Empire:
I ran and configured the dockerized version (login info, put the flag into the Credentials, created a couple of agents, etc.).
While the container was running, I copied /empire out to my host system.
Finally, I created a new Docker Image using the base Image and completely replaced the /empire directory with the one I copied.
The resulting image can be distributed and replicated without relying on configuration files to be provided.
I decided to include an additional step to add an extra layer of complexity before players could interact with Signal’s data. Initially, I considered presenting the Users\ folder as a backup stored within a TrueCrypt volume, as Volatility has a plugin for it. However, I quickly ruled this out for two main reasons:
TrueCrypt has been showcased before in similar challenges.
It’s outdated, and more secure alternatives exist, making it an unrealistic choice for a modern scenario.
Big thanks to Simon for suggesting a clever approach: encrypting the zip’s password using the Local Security Authority (LSA), which integrated perfectly into the challenge.
Here’s how I implemented it:
I created a program that stored the password as a key in the LSA store using LsaStorePrivateData().
Another program retrieved the key using LsaRetrievePrivateData() and passed it to the 7z utility to create the encrypted backup.
To ensure the process remained secure, I deliberately included a “pause” statement (wait for keypress) in the backup program. This prevented:
The immediate retrieval of the zip password.
The 7z command line (which contained the password) from being visible.
This approach resulted in three distinct elements for the challenge, all developed independently:
The Users\ backup.
The memory snapshot.
Empire’s Starkiller.
Here’s how I prepared the memory snapshot:
Set up a fresh Windows 10 virtual machine.
Downloaded and installed Signal, ensuring I didn’t link a phone number to avoid caching messages in RAM.
Ran the program to store the LSA secret, deleted the program, and rebooted the system.
Executed the backup program and left it paused.
Grab the memory snapshot.
The user’s password was deliberately set to be uncrackable to force players to look elsewhere for the master key. More on that at “Recovering DPAPI Master Key”.
At this point, the critical data (DPAPI master keys and the LSA secrets) was in memory. I captured the memory snapshot while ensuring Signal-related data was absent (more foreshadowing…).
After capturing the memory snapshot, I completed the text exchange between the two actors and generated the backup file. I presented the backup as being created during the memory capture process when in reality, it was made afterwards. While I could have manipulated the timestamps of the backup; it was all about the presentation. This ensured that anything related to Signal remained strictly absent from the memory snapshot.
Hopefully, with all things taken care of, let's check out how to solve the challenge! As mentioned before, we are given a zip file containing:
backup.7z - the encrypted archive
win10_memdump.elf - the memory snapshot
Let's enumerate the processes in the memory dump:
$ vol -f win10_memdump.elf windows.pslist
Volatility 3 Framework 2.11.0
Progress: 100.00 PDB scanning finished
PID PPID ImageFileName Offset(V) Threads Handles SessionId Wow64 CreateTime ExitTime File output
[...]
7152 748 Signal.exe 0xb90b8a9da080 53 - 1 False 2024-11-13 00:54:38.000000 UTC N/A Disabled
6820 7152 Signal.exe 0xb90b8a9c7080 20 - 1 False 2024-11-13 00:54:39.000000 UTC N/A Disabled
6800 7152 Signal.exe 0xb90b8a2812c0 18 - 1 False 2024-11-13 00:54:39.000000 UTC N/A Disabled
7028 7152 Signal.exe 0xb90b8a6670c0 24 - 1 False 2024-11-13 00:54:40.000000 UTC N/A Disabled
[...]
3216 728 svchost.exe 0xb90b89be42c0 13 - 0 False 2024-11-13 00:55:36.000000 UTC N/A Disabled
7392 7056 backuper.exe 0xb90b8a69d2c0 4 - 1 False 2024-11-13 00:55:58.000000 UTC N/A Disabled
We will ignore Signal.exe for the moment (going after the LSA secrets first) and will focus on backuper.exe. We’d want to examine it further, so we can append --pid 7392 --dump to the above command, to specify one process and then dump the executable. Or since the process is running, and we speculate that no file deletion happened, we should also be able to find it in the cached files:
$ vol -f win10_memdump.elf windows.filescan | rg "backuper.exe"
0xb90b89c0b110.0\Users\frontier-user-01\Desktop\backuper.exe
0xb90b89c0b8e0 \Users\frontier-user-01\Desktop\backuper.exe
0xb90b8b20ab60 \Users\frontier-user-01\Desktop\backuper.exe
And then dump it like so:
$ vol -f win10_memdump.elf windows.dumpfiles --virtaddr 0xb90b89c0b110
Volatility 3 Framework 2.11.0
Progress: 100.00 PDB scanning finished
Cache FileObject FileName Result
DataSectionObject 0xb90b89c0b110 backuper.exe file.0xb90b89c0b110.0xb90b873d97e0.DataSectionObject.backuper.exe.dat
ImageSectionObject 0xb90b89c0b110 backuper.exe file.0xb90b89c0b110.0xb90b89be5730.ImageSectionObject.backuper.exe.img
With the executable extracted, it’s now time to perform some reversing:
We see that a new handle is being taken over the local Policy Object (SystemName = nullptr) using LsaOpenPolicy(). And a variable with the content “OfflineBackupKey” is being defined.
If the handle is acquired successfully, it then retrieves the data into the protected_data variable. Finally, it constructs the cmdline using sprintf() which is then passed into system():
The retrieved data will serve as the password for this archive! Notice the format specifier “%ws”, this is a Unicode-16LE (printable) string!
Note: I’m just noticing this now, there is a memset() that sets the contents of protected_data to zero. Since this is a pointer, the following call to LsaFreeMemory() will probably fail, but this remained untested since the program never got passed the pause() call. The memset() should probably have targeted the pointed data and not the pointer itself.
We know that there is a Key named “OfflineBackupKey” inside the LSA store and we need to retrieve it. We could try (unsuccessfully) brute-forcing using the process’ memory but why not use what we already know?
We can dump the LSA secrets using Volatility’s lsadump plugin as so:
$ vol -f win10_memdump.elf windows.lsadump
Volatility 3 Framework 2.11.0
Progress: 100.00 PDB scanning finished
Key Secret Hex
DPAPI_SYSTEM ,Ô¢¢7}ÔYVª!¾^T+£)àÏMºf]¦p#c=ÏÇ¿L3& 2c 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 01 00 00 00 d4 a2 a2 37 7d 10 04 d4 59 56 aa 21 1d be 5e 14 54 2b a3 29 e0 cf 4d ba 66 01 5d a6 70 23 63 9f 3d cf c7 bf 4c 9f 33 26 00 00 00 00
NL$KM @´ùhf" ÷Zúè;j¬ÂèÕ&¡ûmvaZÍÚâ`HõáLõÑáAúÓY)r
ãVÌ©2YjiAdv3oÞö%H£ÙùÞ[H 40 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 b4 f9 68 66 22 20 f7 5a fa 82 e8 82 3b 6a 9a 90 ac 0f c2 e8 d5 26 a1 19 fb 6d 76 8d 9a 61 5a cd da e2 60 48 f5 e1 4c f5 11 d1 e1 41 fa d3 59 29 72 0b e3 56 cc a9 32 59 13 6a 69 41 64 76 33 6f 15 de f6 02 25 10 48 12 a3 d9 f9 de 83 1e 5b 48
OfflineBackupKey (yYj8g5Pk6h!-K3pBMSxF 28 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 79 00 59 00 6a 00 38 00 67 00 35 00 50 00 6b 00 36 00 68 00 21 00 2d 00 4b 00 33 00 70 00 42 00 4d 00 53 0078 00 46 00 00 00 00 00 00 00 00 00
The weird output is because of the non-printable characters in some of the keys. In any case, we can find the OfflineBackupKey, however, we need to remember that the key is Unicode 16LE and also printable! Taking this into account, we need to skip the first 0x28 and the next 0x00 bytes because they are not part of the Unicode text!
That means the archive password is: yYj8g5Pk6h!-K3pBMSxF
As mentioned above our first order of business is to recover the user’s DPAPI Master Key, which we can do using pypykatz-volatility3:
$ vol -f win10_memdump.elf -p pypykatz-volatility3 pypykatz
Volatility 3 Framework 2.11.0
ERROR pypykatz : Failed to prcess TSPKG package! Reason: Page Fault at entry 0x0 in table page directory
credtype domainname username NThash LMHash SHAHash masterkey masterkey(sha1) key_guid password
msv DESKTOP-6MBJBAP frontier-user-01 1d3e3e030ba1a179e1281406efd980bf ded871d3a3992be2179840890d061c9f30a59a77
dpapi 791ca70e650987684b043745c6f4b1c0f97eb2369317302c6c60f9cda19e1b4864fbece48341141501606d8d359ff7f54ee71e4a2b821d3df69582927742809f 8d53efa8456b9ba43206f4c3a6dc1c957d26105a ab71b6fc-d0b8-4d7b-aa12-6ece19ff1917
msv DESKTOP-6MBJBAP frontier-user-01 1d3e3e030ba1a179e1281406efd980bf ded871d3a3992be2179840890d061c9f30a59a7
The recovered DPAPI key is: 791ca70e650987684b043745c6f4b1c0f97eb2369317302c6c60f9cda19e1b4864fbece48341141501606d8d359ff7f54ee71e4a2b821d3df69582927742809f
Another way to do this, is to load the memory dump into a modified build of MemProcFS, to bypass its built-in security measures. Or directly binary-patch a prebuilt.
From there, one can generate the minidump and load it directly into tools like pypykatz or mimikatz.
Next step is decrypting Electron’s key, the key that is used to encrypt all strings passed to the safeStorage API. We know the key is stored in local_state and that can be retrieved from the ‘Local State’ file either from the backup or the memory dump:
$ cat "Local State" | jq
{
"os_crypt": {
"audit_enabled": true,
"encrypted_key": "RFBBUEkBAAAA0Iyd3wEV0RGMegDAT8KX6wEAAAD8tnGruNB7TaoSbs4Z/xkXEAAAABIAAABDAGgAcgBvAG0AaQB1AG0AAAAQZgAAAAEAACAAAACKakPvCWDeRdef30ik+0RfHTUXhQrfAdfcEOuzfv8sDQAAAAAOgAAAAAIAACAAAAAad9BHSVFuYmI0D8QG9924xL4pzewU1LemGmaTlTzcOjAAAAAg0SNGW/NP4egaKEv0Tgl9JE3d0tFQpx6G6lMcoOlF3EyR/dr0hbbBbQksTEkECcxAAAAAHaurRLkbh4yTcD+/hxG67Vfa0zLEIJpQOAWw6BIDUw+jRHY3AuIU0wdyxy5lv6CZEYmIQqUbyJSXzPIPpqYn6w=="
}
}
If we decode the key:
$ cat Local\ State | jq -r .os_crypt.encrypted_key | base64 -d
DPAPIЌ����z�O���q���{M�n��Chromiumf �jC� `�Eן�H��D_5�
� w�GIQnbb4��ݸľ)��Է�f��<�:0 �#F[�O��(K�N }$M���P���S��E�L�����m ,LI �@��D���p?����W��2� �P8��S�Dv7��r�.e�����B�������'�⏎
We can see that it begins with “DPAPI” which is prepended in EncryptAndStoreKey() when creating and storing the key! We need to remove the prefix to bring it to the correct format:
The search command works by searching for the following bytes, which represent the header (Version + DPAPI provider GUID) of the DPAPI blob structure:
0x01, 0x00, 0x00, 0x00, 0xD0, 0x8C, 0x9D, 0xDF, 0x01, 0x15, 0xD1, 0x11, 0x8C, 0x7A, 0x00, 0xC0, 0x4F, 0xC2, 0x97, 0xEB
We can easily skip the prefix as such:
cat Local\ State | jq -r .os_crypt.encrypted_key | base64 -d | tail -c +6 > enc_blob
Now we can use dpapi.py from the Impacket Suite to decrypt the blob:
$ dpapi.py unprotect -file enc_blob -key 0x791ca70e650987684b043745c6f4b1c0f97eb2369317302c6c60f9cda19e1b4864fbece48341141501606d8d359ff7f54ee71e4a2b821d3df69582927742809f
Impacket v0.11.0 - Copyright 2023 Fortra
Successfully decrypted data
0000 75 82 F0 84 A7 D0 08 72 EE BE 91 9C 2C 02 DA 0A u......r....,...
0010 8F 4D 8E 67 E6 48 BB 55 80 5E 89 94 A8 A1 65 EF .M.g.H.U.^....e.
Resulting in the Electron Key: 7582F084A7D00872EEBE919C2C02DA0A8F4D8E67E648BB55805E8994A8A165EF
Now we can grab the encrypted SQL key from config.json and craft a small Python script to decrypt based on the AES properties found in DecryptString():
from Crypto.Cipher import AES
NONCE_LEN = 96//8
PFX_LEN = 3
# encrypted key from config.json
data = bytes.fromhex("763130cc1[...]a97cd")
# Decrypted Electron Masterkey
key = bytes.fromhex("7582F084A7D00872EEBE919C2C02DA0A8F4D8E67E648BB55805E8994A8A165EF")
nonce = data[PFX_LEN:PFX_LEN+NONCE_LEN]
ct = data[PFX_LEN+NONCE_LEN:]
cipher = AES.new(key, AES.MODE_GCM, nonce)
pt = cipher.decrypt(ct)
print(pt)
Finally, we arrive at the much sought-after SQL key:
65f77c5912a1456af299975228bb45857144ee8fb546683c9274e11a1617fa65
We then can open sql/db.sqlite using an SQLite Viewer that supports SQLCipher, making sure to change to a Raw Key and prepend "0x" in front of the key:
Then we will be able to browse the messages they exchanged:
In the conversation we can find the shared credentials to access Starkiller:
After logging in to Starkiller, we can find a couple of agents, and the flag in the Credentials Tab:
During the event, we observed a large amount of tickets being opened, presenting the same question:
“Do we need to brute-force the user’s NT Hash in order to find the user’s password and subsequently decrypt the master key?” (as showcased here).
From the article, if the password is known, we can decrypt the master key which resides at \Users\[username]\AppData\Roaming\Microsoft\Protect\[SID] as such:
$ dpapi.py masterkey -file ab71b6fc-d0b8-4d7b-aa12-6ece19ff1917 -password "horsey-batterybank-frog" -sid "S-1-5-21-1208348762-991206961-812773293-1001"
Impacket v0.11.0 - Copyright 2023 Fortra
[MASTERKEYFILE]
Version : 2 (2)
Guid : ab71b6fc-d0b8-4d7b-aa12-6ece19ff1917
Flags : 5 (5)
Policy : 0 (0)
MasterKeyLen: 000000b0 (176)
BackupKeyLen: 00000090 (144)
CredHistLen : 00000014 (20)
DomainKeyLen: 00000000 (0)
Decrypted key with User Key (SHA1)
Decrypted key: 0x791ca70e650987684b043745c6f4b1c0f97eb2369317302c6c60f9cda19e1b4864fbece48341141501606d8d359ff7f54ee71e4a2b821d3df69582927742809f
However the password was not known to the players, and as you can see it’s quite large to brute-force it. So after a quick failed attempt with rockyou, players were left feeling stranded, because their initial chain of thought, which was perfectly logical, had failed them. Let’s stand on this for a bit, because this is something I still struggle with, myself.
We are provided the encrypted DPAPI master key, and we can obtain the user’s NT Hash through the memory dump. We know the correlation between NT Hash, password, and the encrypted master key, and we already established that given the password we could decrypt it. But we missed a critical point…
None of the above is what we actually need! The pivotal data in this case is the decrypted master key, and also, we know of techniques (sekurlsa::dpapi, sharpDPAPI, pypykatz) that directly retrieve the plain key from memory (either live or minidump), something that we have!
So it’s just a matter of finding a way to extract it. In addition to this, going through the cached files in the snapshot, it can easily be deduced that the snapshot has been taken with the user frontier-user-01 being logged in, that means, much like using mimikatz, the master key in question is cached in ram!
With the abundance of information available to the players, it’s easy to get trapped in assumptions and chase pseudo-objectives that, in hindsight, are unnecessary because they can be bypassed. This is why it’s crucial to take a step back, reassess what we have, identify what we actually need, and evaluate the importance of objectives—especially when tools exist that can achieve those objectives directly.
In this case, the plaintext master key is the true linchpin, and everything else is secondary. Recognizing this pivotal role helps maintain focus and prioritize the real goal.
Another thing that was brought to our attention during the competition, was that while the Signal app is running, the encryption key exists in two forms: the non-printable raw-byte format and a hex-encoded string! This happens because the app passes the key through a variable of type string, ultimately leading to this piece of code:
function keyDatabase(db: WritableDB, key: string): void {
// https://www.zetetic.net/sqlcipher/sqlcipher-api/#key
db.pragma(`key = "x'${key}'"`);
}
Although I generally avoid modifying artifacts, I wanted Signal.exe to appear in windows.pslist, so I patched the raw-byte instances of the key out of the memory snapshot, thinking this would resolve the issue. However, I overlooked the fact that the key’s interpolated hex-encoded string would remain visible as a printable string in memory.
This led to an unintended solution where players could find the key by examining part of the source code to understand what to look for. On the plus side, this behavior is realistic—if the Signal app is running, the key should logically be present in memory.
That said, the proper solution would have been to ensure the Signal app wasn’t running at all when the snapshot was being taken. In hindsight, I chose a partial fix instead of a complete one. On the bright side, this discovery paves the way for developing a tool or plugin specifically designed to hunt for this interpolated string in memory snapshots, now that we understand how the key is represented.
With the move to safeStorage, it’s much more trickier for adversaries to acquire the necessary keys.
Can serve as a crude backup, since messages from linked devices are not transferred.
On Linux, at least in my limited testing, the key is still plaintext (by default at least).
Maybe some configuration is needed for keyring access (kwallet6/gnome-keyring).
Minidumps can be reconstructed from the raw Memory snapshot.
Sometimes, when compiling with Visual Studio as debug, you might end up with an executable with external dependencies (ucrtbased.dll - debug version of universal C runtime).
I wanted to build with debug info so limited reversing would be required.
DLL is provided by Windows SDK which I had no intention of installing on a user’s machine.
Solution was this age-old Stack Overflow answer.
This challenge and tooling inspired my Thesis which I am currently working on.
Closing, I would like to thank you for reading this post and I hope that you learned something from it. A huge thank you to the 8000+ students across 1000 universities that attended the CTF and a big congratulations is in order to the 19 teams that managed to solve it!