This lab teaches the setuid-to-capability conversion without
damaging the host you run it on. You will first measure how
ping is privileged today, then perform the conversion on a
copy of the binary, then restore the vendor state exactly.
Objective
By the end of this lab, you can:
- Record the exact privilege state of a binary before you touch it.
- Name the three mechanisms that let an unprivileged user send ICMP.
- Grant the single capability a program needs, and justify refusing the rest.
- Inventory every setuid and every capability-bearing binary on a host.
- Restore a binary to its packaged state and prove it with the package manager.
Architecture
One Linux host. No network topology is required; every step is local.
Requirements
- A Linux host you can take a root shell on, and which you are allowed to modify. Do not run this on a production host.
libcap2-bin(Debian/Ubuntu) orlibcap(RHEL/SUSE) forgetcapandsetcap.stracefor Task 2. If it is unavailable, read Task 2 and skip the command.- A filesystem mounted without
nosuidand withoutnodev, and one that supports extended attributes.setcapstores capabilities in thesecurity.capabilityxattr; it fails on tmpfs-with-nosuid, NFS, and most overlay upper layers.
Scenario
You have been asked to remove a setuid-root binary from a fleet image and replace it with a capability. Before you write the change, you need to prove three things: what the binary’s privilege actually is today, what the minimum capability is, and that you can put the host back exactly as you found it.
Tasks
Task 1: Record the before-state
Everything you do later is judged against this output. Save it.
mkdir -p ~/cap-lab
{
echo "=== ls -l ==="
ls -l "$(command -v ping)"
echo "=== getcap ==="
getcap "$(command -v ping)"
echo "=== ping_group_range ==="
sysctl net.ipv4.ping_group_range
echo "=== package ==="
(dpkg -S "$(command -v ping)" 2>/dev/null || rpm -qf "$(command -v ping)" 2>/dev/null)
} | tee ~/cap-lab/before.txt
$ ls -l /usr/bin/ping; getcap /usr/bin/ping; sysctl net.ipv4.ping_group_range-rwxr-xr-x 1 root root 159552 Oct 31 2025 /usr/bin/ping
/usr/bin/ping cap_net_raw=ep
net.ipv4.ping_group_range = 0 2147483647Illustrative output
Note what is not here. ping6 is a symlink to ping on
Debian and Ubuntu, so chmod and setcap against it act on
ping itself - a classic way to change a file you did not think
you were touching. traceroute6 is usually not installed at all.
Confirm with ls -l before you plan any change against a name.
Task 2: Prove which mechanism is actually in use
A capability that is present is not the same as a capability that is used. Watch the socket call:
strace -f -e trace=socket ping -c 1 127.0.0.1 2>&1 | grep socket
Read the result like this:
socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP)- the unprivileged ICMP socket.ping_group_rangepermitted it. No file privilege was used, and the capability could be removed without breaking anything for this user.socket(AF_INET, SOCK_RAW, IPPROTO_ICMP)- a raw socket. That requiresCAP_NET_RAW, so the file capability (or setuid) did the work.
Modern iputils tries SOCK_DGRAM first and falls back to
SOCK_RAW. This is why “ping works” is not evidence about which
privilege mechanism is in play.
Task 3: Build a lab binary with no privilege at all
Do not experiment on /usr/bin/ping. Copy it. cp does not
carry the security.capability xattr unless you ask it to, so
the copy starts with nothing:
sudo install -m 0755 -o root -g root \
"$(command -v ping)" /usr/local/bin/ping-lab
getcap /usr/local/bin/ping-lab # expect: no output
ls -l /usr/local/bin/ping-lab # expect: -rwxr-xr-x, no 's'
Now force the raw-socket path so the missing capability is
visible. -M do sets the “do not fragment” bit, which iputils
can only do on a raw socket:
sudo -u nobody /usr/local/bin/ping-lab -c 1 -M do 127.0.0.1
Expect a failure such as ping: socket: Operation not permitted
or Lacking privilege for raw socket. If it succeeds, your
kernel served the request from the datagram path; set
net.ipv4.ping_group_range to 1 0 on a lab host only to
close that path, and repeat.
Task 4: Grant the minimum capability
ping needs one capability: CAP_NET_RAW, to open a raw socket.
That is all.
sudo setcap cap_net_raw+ep /usr/local/bin/ping-lab
getcap /usr/local/bin/ping-lab
# /usr/local/bin/ping-lab cap_net_raw=ep
sudo -u nobody /usr/local/bin/ping-lab -c 1 -M do 127.0.0.1
# now succeeds
The +ep suffix matters. e (effective) means the kernel raises
the capability in the effective set at exec, so the program does
not need to be capability-aware. p (permitted) means the
capability is available to the process at all. cap_net_raw+p
alone would require the binary to call capset() itself.
Task 5: Understand the contrast with setuid, without creating one
You will not create a setuid-root binary in this lab. Creating one is the risk the whole exercise exists to remove. Reason about it instead:
| setuid root | cap_net_raw+ep | |
|---|---|---|
| Effective UID during execution | 0 | unchanged |
Can read /etc/shadow | yes | no |
| Can write anywhere on the filesystem | yes | no |
| Can open a raw socket | yes | yes |
| Blast radius of a memory-safety bug | full root | one capability |
Both let ping do its job. Only one of them also lets a
compromised ping add a root SSH key.
Task 6: Inventory the host
# Every setuid binary on the root filesystem
find / -xdev -type f -perm -u+s 2>/dev/null
# Every capability-bearing file - one process, not one per file
getcap -r / 2>/dev/null
Use getcap -r /, not find / -exec getcap {} \;. The find
form forks a getcap process for every file on the filesystem -
hundreds of thousands of execs, minutes of wall time, and
noticeable load. getcap -r walks the tree once inside a single
process.
Compare the output with ~/cap-lab/before.txt. The only new line
should be /usr/local/bin/ping-lab.
Task 7: Document the change
CAPABILITY CONVERSION - LAB RECORD
==================================
Host: LAB-HOST
Date: 2026-08-11
Before (recorded, not assumed):
- /usr/bin/ping: mode 0755, no setuid, cap_net_raw=ep
- net.ipv4.ping_group_range: 0 2147483647
- Package: iputils-ping
Change (on the lab copy only):
- /usr/local/bin/ping-lab: mode 0755, cap_net_raw=ep
- cap_net_admin considered and REJECTED: not required; grants
interface, routing and firewall control.
Verification:
- ping-lab -M do works for an unprivileged user
- No new setuid binary on the host
- getcap -r / differs from the before-state by one line
Restore:
- /usr/local/bin/ping-lab removed
- /usr/bin/ping matches the packaged state (dpkg --verify clean)
Validation
getcap /usr/local/bin/ping-labprintscap_net_raw=epand nothing else.sudo -u nobody /usr/local/bin/ping-lab -c 1 -M do 127.0.0.1succeeds.find / -xdev -type f -perm -u+slists no ping variant - not before the lab, and not after it.getcap -r / 2>/dev/nulldiffers from the Task 1 record by exactly one line.- The
ls -landgetcapoutput for/usr/bin/pingis byte-identical to~/cap-lab/before.txt.
Expected Outcome
A host that is in exactly its pre-lab security state, plus one
extra file (/usr/local/bin/ping-lab) that demonstrates the
minimum-capability pattern, plus a written before-state you could
hand to a change reviewer.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
setcap: Operation not supported | Filesystem has no xattr support, or is mounted nosuid/nodev | Put the lab binary on a normal ext4/xfs mount; check findmnt -no OPTIONS /usr/local |
ping-lab works even with no capability | The kernel served SOCK_DGRAM via ping_group_range | Use -M do to force the raw path, as in Task 3 |
getcap prints nothing for a file you just set | You ran setcap on a symlink, or a later cp/install replaced the file and dropped the xattr | ls -l the path; re-run setcap on the real file |
sudo -u nobody fails with a shell or $HOME error | nobody has /usr/sbin/nologin | Use an ordinary unprivileged account instead |
getcap -r / is slow or noisy | It is crossing into /proc, /sys or network mounts | Keep the 2>/dev/null, or narrow the walk to getcap -r /usr /bin /sbin |
Cleanup
Restore the state you recorded in Task 1. Restoring means returning to the measured before-state - not to a remembered one.
# Remove the lab binary
sudo rm -f /usr/local/bin/ping-lab
# Confirm the vendor binary is untouched
ls -l /usr/bin/ping # expect -rwxr-xr-x, no 's'
getcap /usr/bin/ping # expect /usr/bin/ping cap_net_raw=ep
# Compare against the record you took in Task 1
grep -E 'ping$|cap_net_raw' ~/cap-lab/before.txt
# Only if the capability was lost at some point, restore it explicitly
sudo setcap cap_net_raw=ep /usr/bin/ping
sudo chmod 0755 /usr/bin/ping
# Authoritative check against the package
sudo dpkg --verify iputils-ping # Debian/Ubuntu: no output = clean
# rpm -V iputils # RHEL/Fedora
What You Learned
- Record before you change. The distribution’s actual state is data, not folklore. Task 1 is the deliverable that makes the cleanup possible.
- Three mechanisms grant ICMP: setuid,
cap_net_raw+ep, andnet.ipv4.ping_group_range. “ping works” tells you nothing about which one is active -stracedoes. - Grant one capability, then try to remove it.
pingneedsCAP_NET_RAWand nothing more;CAP_NET_ADMINwould have been a silent privilege escalation shipped as hardening. - Practise on a copy.
/usr/local/bin/ping-labgives you the full lesson with none of the blast radius. - Audit cheaply.
getcap -r /is one process; thefind -execform is one process per file.