GHSA-38CW-85XC-XR9X

Identity Crisis: Dumping Veramo's Digital Wallets via SQL Injection

Amit Schendel
Amit Schendel
Senior Security Researcher

Jan 17, 2026·5 min read

Executive Summary (TL;DR)

The Veramo framework, designed for Self-Sovereign Identity (SSI), contained a massive hole in its data access layer. By manipulating the `order` parameter in API requests, attackers could force the application to execute arbitrary SQL. This bypasses the ORM's protections, allowing full database dumps. If you run Veramo < 6.0.2, your DIDs and private keys are compromised.

A critical SQL injection vulnerability in the Veramo framework's data storage layer allows authenticated attackers to manipulate query ordering parameters, enabling the exfiltration of sensitive data—including private keys and verifiable credentials—from the underlying database.

The Hook: Trustless Systems require Trusted Code

Veramo is a framework built for the "Trustless" web—Self-Sovereign Identity (SSI), Verifiable Credentials (VCs), and Decentralized Identifiers (DIDs). The entire premise of this ecosystem is cryptographic integrity. You hold your keys; you own your identity. It's a beautiful concept, really.

But here is the irony: in a system designed to eliminate the need for blind trust, the code managing the database was blindly trusting user input. We aren't talking about a complex cryptographic failure or a side-channel attack on the curve signatures. We are talking about the cockroach of the internet: SQL Injection.

Specifically, the vulnerability lies deep within @veramo/data-store. This package is the vault. It stores the identifiers, the claims, and, most critically, the private keys used to sign everything. By sending a malformed sort order to the API, an attacker can trick the database into handing over the keys to the kingdom. It is the digital equivalent of installing a retina scanner on your front door but leaving the key under the mat.

The Flaw: When Type Safety is a Lie

Modern developers rely heavily on ORMs (Object-Relational Mappers) like TypeORM to abstract away the grit of raw SQL. The assumption is usually, "I'm using a query builder, so I'm safe from SQL injection." That assumption is the mother of all security incidents.

The flaw resides in a utility function called decorateQB within data-store-orm.ts. This helper function is designed to take a generic API request containing pagination (skip, take) and sorting (order) instructions and apply them to a database query. It sounds harmless enough.

The problem is that the function blindly accepted the column name from the user's input and fed it directly into the query builder. While the developer did attempt to use the driver's escape function for the column selection, they passed the raw, user-controlled string as the alias for the selected column.

In TypeORM (and many SQL dialects), aliases are often treated with less scrutiny than table identifiers, or simply wrapped in quotes. If you can break out of those quotes, you can rewrite the query. The application essentially said: "Select this column and call it [User Input]." The attacker replied: "Call it 'MyColumn' FROM table UNION ALL SELECT private_keys..."

The Code: The Smoking Gun

Let's look at the crime scene. This code snippet from packages/data-store/src/data-store-orm.ts shows exactly how the injection occurs.

// VULNERABLE CODE
function decorateQB(
  qb: SelectQueryBuilder<any>,
  tableName: string,
  input: FindArgs<any>,
): SelectQueryBuilder<any> {
  // ... skip and take logic ...
 
  if (input?.order) {
    for (const item of input.order) {
      // CRITICAL FLAW:
      // item.column comes directly from the JSON body.
      // It is used as the second argument to addSelect(), which is the ALIAS.
      qb = qb.addSelect(
        qb.connection.driver.escape(tableName) + '.' + qb.connection.driver.escape(item.column),
        item.column, // <--- HERE IS THE INJECTION
      )
      qb = qb.orderBy(qb.connection.driver.escape(item.column), item.direction)
    }
  }
  return qb
}

You might ask, "But isn't this TypeScript? Shouldn't FindArgs prevent random strings?" No. TypeScript is a compile-time construct. At runtime, the compiled JavaScript blindly accepts whatever JSON payload the API endpoint receives. The type definitions claimed item.column would be a valid column name, but the runtime reality was that it could be a malicious SQL fragment.

The Exploit: Crafting the Breakout

Exploiting this requires a bit of finesse. We need to inject into the order array. The target is an endpoint like dataStoreORMGetVerifiableCredentialsByClaims. We want to break out of the alias context and append a UNION statement to fetch data from other tables.

The payload looks something like this:

{
  "where": [{
      "column": "some_valid_column",
      "value": ["1"]
  }],
  "order": [
    {
      "direction": "ASC",
      "column": "issuanceDate\" AS \"issuanceDate\" FROM \"claim\" ... UNION ALL SELECT ... FROM private-key-- -"
    }
  ]
}

When the server processes this, it tries to construct a query that looks roughly like:

SELECT ... as "issuanceDate" AS "issuanceDate" FROM "claim" ... UNION ALL SELECT ... FROM private-key-- -"

By injecting the quote ", we close the alias string that the ORM opened. Then we start writing our own SQL. The UNION ALL allows us to append results from the private-key table to the legitimate results of the credential search. The trailing -- - comments out whatever valid SQL the ORM tried to append afterwards (like the original ORDER BY clause).

The result? The API response, which should contain a list of verifiable credentials, now contains your server's private keys, formatted innocently as part of the JSON response.

The Fix: Whitelisting (The Only Sane Choice)

The fix implemented by the Veramo team in version 6.0.2 is the textbook definition of "Defense in Depth." Instead of trying to write a better regex to sanitize the input or relying on a different ORM method, they implemented a strict allow-list (whitelist).

They defined exactly which columns are legally sortable for each table:

export const ALLOWED_COLUMNS = {
  message: ['from', 'to', 'id', 'createdAt', ...],
  claim: ['context', 'credentialType', 'type', ...],
  credential: ['context', 'type', 'id', 'issuer', ...],
  identifier: ['did', 'alias', 'provider'],
  // ...
} as const;

Then, inside decorateQB, they validate the input against this list before doing anything else:

const allowedColumns = getAllowedColumnsForTable(tableName);
for (const item of input.order) {
  if (!allowedColumns.includes(item.column)) {
    throw new Error(`Invalid column name: ${item.column}`);
  }
  // ... proceed to build query
}

This completely neutralizes the attack. Even if the attacker sends a wicked SQL payload, it won't match strings like 'createdAt' or 'id', and the server will throw an error before touching the database. Simple, boring, and secure.

Fix Analysis (1)

Technical Appendix

CVSS Score
6.8/ 10
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N

Affected Systems

Veramo Framework@veramo/data-store@veramo/data-store-json

Affected Versions Detail

Product
Affected Versions
Fixed Version
@veramo/data-store
Veramo
< 6.0.26.0.2
@veramo/data-store-json
Veramo
< 6.0.26.0.2
AttributeDetail
CWE IDCWE-89 (SQL Injection)
CVSS Score6.8 (Medium)
Attack VectorNetwork (Authenticated)
ImpactHigh (Confidentiality & Integrity)
Vulnerable ComponentdecorateQB()
Fix TypeInput Validation (Whitelist)
CWE-89
Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection')

Vulnerability Timeline

Patch Released in v6.0.2
2026-01-16
Advisory Published
2026-01-16

Subscribe to updates

Get the latest CVE analysis reports delivered to your inbox.