Database audit fix

How to Run Database Services as Unprivileged Users

A database engine running as root turns any SQL-level file-write primitive, such as SELECT INTO OUTFILE or COPY TO PROGRAM, into full host compromise. Run each engine under its own account: mysql, postgres, mongodb or redis.

Hiren KalariyaLast reviewed: Sep 9, 2026Check service-user
High
severity
No
needs root or sudo
3
results it can return
1 of 7
checks in this audit

Every threshold on this page is transcribed from the database-security-hardening audit script that CtrlOps runs, and a build check fails if the two ever disagree. See all 7 Configuration & Hardening checks, or how the audit runs.

High severityNo sudo needed

What this check reads

The check walks a fixed list of five process names - mysqld, mariadbd, postgres, mongod and redis-server - and runs pgrep -x for each one. Where a name matches, it takes the first PID only and reads that process's owner with ps -o user= -p PID, building a list of name=user pairs. Any engine whose owner is the literal string root is collected separately, and one entry on that list is enough to fail the check. If no name matched at all there is no owner to compare and the check skips.

When it applies

Runs on any host and needs no root - pgrep/ps are enough. It looks at running processes only, and only at five exact process names (mysqld, mariadbd, postgres, mongod, redis-server); a stopped engine, an engine whose binary has another name (postmaster on older RHEL), or one running inside a container yields SKIP or is simply absent from the report. Only the first PID of each name is sampled, so a second instance running as a different user is not seen.

What each result means

Result thresholds this check applies
ResultWhenWhat it means
SKIPNo database process runningNothing was verified: none of the five process names was running, so no process owner was ever read.
FAILOne or more engines run as rootAt least one running engine is owned by root, so a file-write primitive inside the database writes anywhere on the host. The message names each engine.
PASSAll run as dedicated users (`mysql`, `postgres`, `mongodb`, `redis`)Every engine process the check sampled is owned by a non-root account. Only the first PID per name was sampled, so a second instance running as root would not appear here.

Why it matters

All four vendors require or strongly recommend a dedicated unprivileged account. PostgreSQL refuses to start as root. MySQL runs as root only with --user=root which the manual warns against. A SQL-level file-write primitive (SELECT ... INTO OUTFILE, COPY TO PROGRAM, Redis CONFIG SET dir) from a root-owned engine writes anywhere on the host. NIST SP 800-123 (least privilege for server processes); OWASP Database Security Cheat Sheet "run the database as a low-privileged user".

Why it fails, and when it is wrong

  • pgrep -x postgres matches the postmaster and its children; on some builds the binary is postmaster (older RHEL) and nothing matches, giving SKIP.
  • ps -o user= truncates names longer than 8 characters to a UID number on some procps builds; the comparison to root still works.
  • MySQL started manually by an admin (mysqld & as root) rather than via systemd is the common cause. Docker containers for these engines are not seen.
  • MongoDB started via a custom systemd unit without User=mongodb.

How to fix it

Read the process owner

Run ps -eo user,comm and look at the user column for mysqld, mariadbd, postgres, mongod and redis-server. Anything showing root is the finding, and the engine name tells you which packaged account it should be running under instead.

Set the service account

Name the packaged account in a systemd override for the unit, with User= and Group=:

  • MySQL/MariaDB: ensure [mysqld] user=mysql in my.cnf and User=mysql in the unit.
  • MongoDB: User=mongodb / Group=mongodb in mongod.service.
  • Redis: User=redis in the unit (the distro packages do this).

Fix the data directory ownership

The new account has to own the data it reads and writes, otherwise the service will not start once you switch it:

sudo chown -R mysql:mysql /var/lib/mysql
sudo chown -R mongodb:mongodb /var/lib/mongodb /var/log/mongodb

Restart and confirm

Reload the unit files and restart the service, then re-run the ps command from the first step and confirm the owner changed:

sudo systemctl daemon-reload
sudo systemctl restart mysql

Verify the fix

# Reproduce exactly what the check reads:
for p in mysqld mariadbd postgres mongod redis-server; do
  pid=$(pgrep -x "$p" | head -1) && printf '%s=%s\n' "$p" "$(ps -o user= -p "$pid" | tr -d ' ')"
done
# expected: mysql / postgres / mongodb / redis - never root

# Confirm the unit enforces it, so it survives a restart:
systemctl show mysql -p User -p Group

Debugging

Either the engine is genuinely stopped (systemctl status mysql postgresql mongod redis-server) or its process name is not one of the five. Check with ps -eo comm,user | grep -Ei 'sql|mongo|redis'. On older RHEL the PostgreSQL parent is postmaster, which this check does not match.

The running process was started some other way (a manual mysqld &, an old process from before the unit was fixed). Confirm with ps -o pid,user,args -p $(pgrep -x mysqld | head -1), then restart the service properly.

Only the first PID per name is sampled. List them all: pgrep -ax mysqld, ps -eo user,args -C mysqld.

A database in a container is invisible here even though the container process may run as root on the host. Use the Docker Container User check instead.

Changing User= without chown -R mysql:mysql /var/lib/mysql leaves the service unable to start. Change ownership first, then the unit.

Sources

How the script reads this

Next

Re-run the Configuration & Hardening audit after applying the fix and confirm this check moves to PASS. CtrlOps runs all 7 checks over your existing SSH connection and scores the result, so the change is visible without reading another config file.

All 7 Configuration & Hardening fixes
Audit your fleet

Find every one of these on every server, in one click

CtrlOps runs this audit over your existing SSH connection - no agents, no scripts to manage. $7/user/month after a 1 month free trial - no credit card required.

✓ Start instantly·✓ No credit card·✓ No sneaky autorenewals