Skip to content

how to run setcap on python executable #576

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
jsiverskog opened this issue Apr 3, 2025 · 10 comments
Open

how to run setcap on python executable #576

jsiverskog opened this issue Apr 3, 2025 · 10 comments

Comments

@jsiverskog
Copy link

jsiverskog commented Apr 3, 2025

we're having some special requirements. we're using the pysoem library to communicate with ethercat devices. this means that the python executable needs cap_net_raw+ep capability, which can be achieved by running sudo setcap cap_net_raw+ep /path/to/python.

this works fine with the system python, but when setting it on the uv provided python executable:

sudo setcap cap_net_raw+ep ~/.local/share/uv/python/cpython-3.11.11-linux-x86_64-gnu/bin/python3.11

i get this when i then try to run python:

[...]/python3: error while loading shared libraries: $ORIGIN/../lib/libpython3.11.so.1.0: DST not allowed in SUID/SGID programs

if i run this:

patchelf --replace-needed "\$ORIGIN/../lib/libpython3.11.so.1.0" ~/.local/share/uv/python/cpython-3.11.11-linux-x86_64-gnu/lib/libpython3.11.so.1.0  ~/.local/share/uv/python/cpython-3.11.11-linux-x86_64-gnu/bin/python3.11

it works (after re-running setcap), so it seems to be related to the relative path. and sure, from a security point of view i can understand why that may be problematic.

i found this:

# slash, the explicit path is used.
patchelf --replace-needed ${LIBPYTHON_SHARED_LIBRARY_BASENAME} "\$ORIGIN/../lib/${LIBPYTHON_SHARED_LIBRARY_BASENAME}" \
${ROOT}/out/python/install/bin/python${PYTHON_MAJMIN_VERSION}
# libpython3.so isn't present in debug builds.
if [ -z "${CPYTHON_DEBUG}" ]; then
patchelf --replace-needed ${LIBPYTHON_SHARED_LIBRARY_BASENAME} "\$ORIGIN/../lib/${LIBPYTHON_SHARED_LIBRARY_BASENAME}" \
${ROOT}/out/python/install/lib/libpython3.so
fi
if [ -n "${PYTHON_BINARY_SUFFIX}" ]; then
patchelf --replace-needed ${LIBPYTHON_SHARED_LIBRARY_BASENAME} "\$ORIGIN/../lib/${LIBPYTHON_SHARED_LIBRARY_BASENAME}" \
${ROOT}/out/python/install/bin/python${PYTHON_MAJMIN_VERSION}${PYTHON_BINARY_SUFFIX}
fi

which is probably what causes this. but what is the right forward here? running pop!_os 22.04 lts (based on ubuntu 24.04) amd64

@indygreg
Copy link
Collaborator

indygreg commented Apr 3, 2025

That's an error message I haven't seen before and a failure mode I didn't realize was possible!

We want to use $ORIGIN in DT_NEEDED because it is the least bad approach for portability.

Your patchelf workaround seems reasonable. But undesirable. Definitely not turnkey.

This issue feels like yet more justification for statically linking libpython...

@indygreg
Copy link
Collaborator

indygreg commented Apr 3, 2025

Apparently the glibc dynamic loader straight up refuses dynamic token expansion in DT_NEEDED for setuid/setgid binaries.

That error message and logic originates from a commit in 1999!

@geofft
Copy link
Collaborator

geofft commented Apr 3, 2025

Yeah, this is defensible on glibc's part because historically you can hardlink to a binary you don't own, so some other user on the machine could make a hardlink in a directory they control, meaning that $ORIGIN/../lib/ would have their own libraries which could run with the added privileges. (On modern Linux systems this is disabled with /proc/sys/fs/restricted_hardlinks, but I am not sure if that's enough to justify glibc turning off this protection.)

Agree re statically linking libpython into the python3 binary - I'll ping here once I have something for you to try.


However, since you're granting caps to a Python interpreter which can then run arbitrary code I wonder if you'd be happier with a system design where the user has ambient cap_net_raw access for all their processes. There's not a realistic security boundary in restricting the elevated access to the specific Python binary, because that Python binary can do anything; you may as well allow all processes run by that user to have raw sockets access. (In my opinion, file caps make more sense for things like ping that have a specific and limited set of functionality.)

It appears that you can install libpam-cap, add

auth optional pam_cap.so keepcaps defer

to the end of your PAM stack (e.g. /etc/pam.d/common-auth on Debian/Ubuntu, on some distros it might need to be in a file in /etc/pam.d/ named after the specific login program), and add

^cap_net_raw yourusername otherusername

(or @yourgroupname if you have a UNIX group, or * for everyone) at the beginning of /etc/security/capability.conf, and then users will ambiently have cap_net_raw access in all their programs, e.g., tcpdump will now work without requiring sudo. This will take effect the next time they log in / authenticate. Then you don't need to mess with file capabilities, which among other things means that you don't need to re-apply the file capabilities when uv downloads a new version of Python.

See full docs here. (I have not tried this in production because it was very new functionality the last time I wanted it, so please test it out and make sure it works the way you expect, but it's definitely what I would have reached for if it were available!)

Also - note that by default on many UNIX systems ~/.local/share/ is readable by other users, so if you take the file capabilities approach, you're allowing everyone on the system to use this Python interpreter for raw socket access. If that's a concern for you, you may want to either change ~/.local/share/uv/ (or some parent directory, possibly just ~) to be mode 700, or take the ambient capabilities approach.

@jsiverskog
Copy link
Author

jsiverskog commented Apr 9, 2025

@geofft : wow, thanks for a very thorough reply.

it seems like the libcam-cap route is the best way forward for us.

on the latest raspberry pi os (based on debian bookworm), with libpam-cap 2.66. as an experiment i made a minimal c program which opens a raw socket. the c program works if i run setcap cap_net_raw+ep ./program but not directly through /etc/pam.d/common-auth and /etc/security/capability.conf.

capsh --print gives:

Current: cap_net_raw=i
Bounding set =cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_linux_immutable,cap_net_bind_service,cap_net_broadcast,cap_net_admin,cap_net_raw,cap_ipc_lock,cap_ipc_owner,cap_sys_module,cap_sys_rawio,cap_sys_chroot,cap_sys_ptrace,cap_sys_pacct,cap_sys_admin,cap_sys_boot,cap_sys_nice,cap_sys_resource,cap_sys_time,cap_sys_tty_config,cap_mknod,cap_lease,cap_audit_write,cap_audit_control,cap_setfcap,cap_mac_override,cap_mac_admin,cap_syslog,cap_wake_alarm,cap_block_suspend,cap_audit_read,cap_perfmon,cap_bpf,cap_checkpoint_restore
Ambient set =
Current IAB: cap_net_raw
Securebits: 00/0x0/1'b0 (no-new-privs=0)
 secure-noroot: no (unlocked)
 secure-no-suid-fixup: no (unlocked)
 secure-keep-caps: no (unlocked)
 secure-no-ambient-raise: no (unlocked)
[...]

combined with setcap cap_net_raw+ie ./program it works. but i'd prefer to avoid having to run setcap on the executable at all. it does not matter if i use cap_net_raw [user] instead of ^cap_net_raw [user].

is it even possible?

@Zoltag
Copy link

Zoltag commented May 14, 2025

We ran into the same issue, using cap_net_bind_service to bind Python to port 80 for a FastAPI service. Reading through all of this, we decided our efforts are better spent enhancing our infrastructure to stop using low level ports

geofft added a commit to geofft/python-build-standalone that referenced this issue May 22, 2025
Even though the Python interpreter no longer needs libpython3.x.so, it
turns out some extension modules (incorrectly) do, and miraculously,
allowing them to find libpython3.x.so doesn't actually break things, for
reasons detailed in the comment. So set an rpath that allows
libpython3.x.so to be loaded if needed by some other library, even
though we won't use that ourselves.

Note that this change does not risk users who want to make bin/python3
setuid or setcap (e.g. astral-sh#576); while the rpath is presumably ignored for
privileged binaries, there is no error message, and the binary launches
fine, and _because_ we do not need the rpath in order for the
interpreter to work, everything (except these misbuilt extension
modules) works.
geofft added a commit to geofft/python-build-standalone that referenced this issue May 22, 2025
Even though the Python interpreter no longer needs libpython3.x.so, it
turns out some extension modules (incorrectly) do, and miraculously,
allowing them to find libpython3.x.so doesn't actually break things, for
reasons detailed in the comment. So set an rpath that allows
libpython3.x.so to be loaded if needed by some other library, even
though we won't use that ourselves. We are already doing this on musl;
do it on glibc too.

Note that this change does not risk users who want to make bin/python3
setuid or setcap (e.g. astral-sh#576); while the rpath is presumably ignored for
privileged binaries, there is no error message, and the binary launches
fine, and _because_ we do not need the rpath in order for the
interpreter to work, everything (except these misbuilt extension
modules) works.
geofft added a commit that referenced this issue May 22, 2025
Even though the Python interpreter no longer needs libpython3.x.so, it
turns out some extension modules (incorrectly) do, and miraculously,
allowing them to find libpython3.x.so doesn't actually break things, for
reasons detailed in the comment. So set an rpath that allows
libpython3.x.so to be loaded if needed by some other library, even
though we won't use that ourselves. We are already doing this on musl;
do it on glibc too.

Note that this change does not risk users who want to make bin/python3
setuid or setcap (e.g. #576); while the rpath is presumably ignored for
privileged binaries, there is no error message, and the binary launches
fine, and _because_ we do not need the rpath in order for the
interpreter to work, everything (except these misbuilt extension
modules) works.
@geofft
Copy link
Collaborator

geofft commented May 22, 2025

Earlier this week we shipped a version of python-build-standalone that statically links libpython (#592), so it does not use an $ORIGIN-relative dependency reference and should work fine with setcap without modifications. After a compatibility tweak we're now pretty confident in that approach. Can you give it a shot and see if it works for you?

To upgrade do

uv self update
uv python install --reinstall

(note that this will clobber your existing patchelf'd binary)


If I get some downtime I'll see if I can actually set up the libpam-cap example on a Pi image, I would have expected it to work but as mentioned I haven't done it in prod.

Also, @Zoltag, while I 100% agree that changing your infrastructure to use high ports is a better solution for many reasons, another thing you can consider is to lower /proc/sys/net/ipv4/ip_unprivileged_port_start to 80 or lower. I have actually done this in prod at a large company, and we chose to set this to 26, which still protects SSH on 22 and SMTP on 25. I think it's technically possible to set it to zero, too. This is a sysctl, so you can set it via sudo sysctl -w net.ipv4.ip_unprivileged_port_start=26, and automatically set it on reboot by putting net.ipv4.ip_unprivileged_port_start=26 somewhere in /etc/sysctl.d/ or /etc/sysctl.conf.

@geofft geofft closed this as completed May 22, 2025
@Zoltag
Copy link

Zoltag commented May 23, 2025

Hi, @geofft

Thanks for your feedback. I have completed the initial update to migrate the first system to higher level port numbers, but took the time this morning to test your update. Running uv self update put me on version 0.7.7 and using this version to build our docker images with setcap has not resolved the issue. In fact, it has even broken simple use of the container / uv environment compared to uv version 0.7.3, as you can see from these screenshots:

Image

Image

@geofft
Copy link
Collaborator

geofft commented May 23, 2025

Hm, the error message you're getting in that first screenshot means the version of Python you're getting is (probably) the one from uv 0.7.5 or below. Can you double-check whether you need a uv python install --reinstall before setcap, especially if this is a Docker container on top of an existing container that had uv?

@Zoltag
Copy link

Zoltag commented May 23, 2025

We start with a basic Ubuntu 24.04 image, which I use root to build a base image from. The base image has uv installed and builds the environment. From the base image, I build the images needed for the various containers related to the system and also set an unprivileged user to execute. I am very much a beginner when it comes to Linux, but it appears to me that setcap is not user specific, so setting the unprivileged user should not affect it (I appreciate that I may well be wrong).

The uv, user and port part of the base image was setup like this:
Image

I then updated it to pin the Python version:
Image

And this results in the following (note the base image, running as root, works, while the unprivileged user in the web image does not):
Image

Updating again, to include the reinstall parameter:
Image

Gives the same result:
Image

EDIT
This is how we build the environment:
Image

@geofft geofft reopened this May 28, 2025
@geofft
Copy link
Collaborator

geofft commented May 28, 2025

Reopening because uv 0.7.8 reverted the static linking changes that would have made this work, but also I am going to take a look at why this doesn't seem to be working for you even with 0.7.7.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants