Your exploit worked, the shell is lying: systemd confinement gotchas

  • linux
  • systemd
  • privilege-escalation
  • post-exploitation

Proof of concept on GitHub

You land code execution through a service, run your payload, and it looks like it failed: the file you dropped in /tmp is not there, or su rejects a password you know is right. The reflex is to re-engineer the exploit. Usually the exploit is fine. What changed is that the code ran inside a systemd unit's confinement, and the confinement, not the exploit, ate the result. Retiring a working technique on this evidence is how you lose a box you already owned. It is also, read from the other side, a lesson in which systemd settings actually blunt an attacker.

PrivateTmp: the file is real, you just cannot see it

Your exec primitive clearly succeeded (the EVAL returned, the cron fired, no error), yet the artifact you told it to write to /tmp does not exist. Almost every packaged daemon (redis-server, apache2, nginx, php-fpm, tomcat, mysql) ships PrivateTmp=yes, which gives the unit its own mount namespace for /tmp and /var/tmp. The file was written. It lives in the service's private namespace, not yours.

grep -r PrivateTmp /lib/systemd/system/ /etc/systemd/system/ 2>/dev/null
ls -l /tmp/systemd-private-*/tmp/ 2>/dev/null   # readable as root: the private tmp dirs

The fix is the destination, not the payload. Write somewhere the namespace does not cover:

  • /dev/shm or /run/shm: shared, world-writable, never under PrivateTmp. The best canary to prove exec really worked.
  • /root/.ssh/authorized_keys if the service runs as root: drop a key and come back over SSH. This converts a blind exec into an interactive shell and sidesteps PrivateTmp and the SUID problem below in one move.
  • Any directory the service account owns (/var/backups, /opt, its home).

Record the exec as confirmed and the delivery as the thing that failed. That is a delivery problem, not a primitive problem.

NoNewPrivileges: su and SUID fail with the right password

From a service shell, su - user reports an authentication failure with a password you have good reason to trust, or sudo complains that it is on a nosuid filesystem. This is not a bad credential. It is NoNewPrivileges=true in the unit (or a nosuid mount): the kernel flag is inherited by every child and permanently strips setuid escalation, so su, sudo, passwd, and any SUID binary you drop are inert in this process tree.

grep NoNewPrivs /proc/self/status                 # 1 = you are under it
grep -iE 'NoNewPrivileges' /lib/systemd/system/*.service /etc/systemd/system/*.service 2>/dev/null
mount | grep -E 'nosuid' | grep -E '/tmp|/home|/dev/shm'
ls -l /bin/su /usr/bin/sudo                        # setuid bit still set means it is the flag, not the file

The way out is a new process tree, not a better exploit. Reuse the same credential against a different unit: sshd is its own unit and is not under the service's flag.

sshpass -p '<pass>' ssh -o StrictHostKeyChecking=no <user>@<target>

A SUID-bash payload written from a service shell is useless to that shell but perfectly usable once you re-enter over SSH. Do not retire the credential on a failed su; mark it untested and retry through another unit.

The operator rule

The thread through both is the same: an exploit confirmed by one signal (the exec returned) and judged failed by another (the artifact is missing, su failed) is not a failed exploit. It is confinement standing between the two signals. Prove the exec with a path that is not namespaced (/dev/shm), then choose a delivery that the confinement does not block.

The same shape covers ProtectHome= and ProtectSystem= (writes land in an overlay you cannot see) and any container or nsenter context.

Defense: this confinement is the control

Flip it around and the attacker's headache is the defender's cheapest win. The settings that frustrated the escalation above are exactly what you want on every exposed service unit, because they shrink what a service compromise can become:

  • PrivateTmp=yes stops cross-service /tmp tricks and isolates dropped artifacts.
  • NoNewPrivileges=yes neuters SUID escalation from the service tree entirely.
  • ProtectHome=yes, ProtectSystem=strict, and a nosuid,nodev,noexec mount on writable temp dirs remove whole classes of post-exploitation.
  • Run the service as a dedicated non-root account so the /root/.ssh key-drop is not even on the table.

Most distro unit files already set some of these; auditing them (systemd-analyze security <unit>) and raising the weakest exposed services is a short, high-value hardening pass.

Detection

Because the clean conversions above are narrow, they make good signals: a service account writing to /dev/shm or to /root/.ssh/authorized_keys, a brand new SSH session for a service account that normally never logs in interactively, and SUID files appearing in service-writable directories. Audit execve and file creation for the service's uid and these stand out.

Use

Authorized and educational use only: your own systems, a lab you control, or an engagement with written permission. This explains why a working primitive can look broken so testers do not discard it, and gives defenders the systemd settings that make the attacker's life hard. For the broader local-enumeration picture see living off the land on Linux; the namespace theme continues in Linux container escapes.

Proof of concept on GitHub

Back to all techniques