Skip to content
academia.sh

Lesson 03 / 09

Capabilities

Superuser privilege is divided into separately grantable pieces; capability dropping closes only one of two facts and file permissions stay open, and in the seven-mechanism bundle the visible fact count drops to 11.

Contents

The previous two lessons narrowed what a process sees and how much it consumes. The third question belongs to another axis: which privileged operation a process can request from the kernel. A process that sees its own process table, with its quota trimmed, can still attempt to open a mount point, change network configuration, or touch another user’s file. Whether these requests are granted has nothing to do with visibility or consumption.

This lesson measures the mechanism that makes that decision. The catalog’s heading was “Capabilities”; because this curriculum settled the equivalent of capability as privilege, the lesson’s name is Privileges, and this single form is used throughout the course.

The Fragmentation of Superuser Privilege

The classic model is binary: a process either belongs to user zero and is exempt from every kernel check, or it does not and is exempt from none. A thousand privileged operations all hang on a single flag. A program that wants to listen on a network port also gets, under this model, the right to load kernel modules and change the ownership of any file.

A capability is this single flag divided into separately grantable pieces. Every privileged operation class is bound to a capability: binding to a low-numbered port, opening a raw socket, changing network configuration, changing file ownership, bypassing file permission checks, raising process priority, changing the system clock, loading a kernel module, and mounting are separate capabilities. A process can only request the operations its granted capabilities cover; for the rest, even belonging to user zero is not enough.

The fragmentation is not even, and it carries a detail this course cares about. Some capabilities are narrow in scope and open a single operation class; others are broad and, on their own, cover enough operation classes to crack open the door to other capabilities. The capability that bundles mounting, namespace management, and a great many administrative operations together on its own is the best known of these. The rule in practice is therefore short: a broad capability is not granted. When a program genuinely needs one, moving the needed operation into a separate helper is a narrower solution than granting that capability to the whole process.

Five Sets and the Transition Rule

A process’s capabilities are not a single list but five sets, and each set has a separate job. The effective set carries the capabilities currently used in checks. The permitted set is the pool the process can pull into the effective set. The inheritable set shows what can be carried across an exec. The bounding set is a ceiling: a capability not in it can never be regained by any route. The ambient set is the bridge that lets inheritable capabilities carry over to the new program.

A separate flag sits above these sets: when the no-new-privileges flag is turned on, the process and all its children cannot gain new capabilities through an exec. The flag is turned on once and cannot be turned off. Capability dropping being permanent depends on this flag; dropping done without the flag turned on can be reversed by capabilities written on a file.

# example dump — not executed; mask values are fiction
$ grep -E '^(Cap|NoNewPrivs)' /proc/self/status
CapInh: 0000000000000000
CapPrm: 000000000000cc00
CapEff: 000000000000cc00
CapBnd: 000000000000cc00
CapAmb: 0000000000000000
NoNewPrivs: 1

The masks in the dump are bit sets; every bit corresponds to a capability. What must be read is not the individual bits but the relationship between the sets: if the bounding set equals the permitted set, no dropping has been done; if the ambient set is empty, no capability is carried across an exec.

Capabilities sit not only on processes but also on files. An executable file can have a capability marked on it, and when that file runs, the process starts with a capability the caller did not have. This is a narrow alternative to the old ownership flag: the file grants not the whole privilege, only the marked capability.

# example dump — not executed
$ getcap /usr/local/bin/metrics-collector
/usr/local/bin/metrics-collector cap_net_bind_service=ep
$ setcap -r /usr/local/bin/metrics-collector
$ find /root-tree -perm /6000 -type f
/root-tree/usr/bin/old-helper

The last line is an audit step: it lists the files in the root tree that carry an ownership flag. If this list is not empty, a capability dropped from the process can come back through the file. The dumps have not been executed, and no numeric claim comes from them.

Where a Capability Applies and Where It Is Written

Where a capability applies cannot be said without accounting for namespaces. A process that opens a user namespace starts inside that namespace with a broad capability set; but that set’s scope is only that namespace and the objects beneath it. The same capability does nothing to an object outside the namespace. The meaning of the first lesson’s mapping table is completed here: the user zero visible inside is an ordinary mapped identity outside, and the boundary of its capabilities is the boundary of the mapping.

This has two practical consequences. First, “appearing privileged inside” and “being privileged on the machine” are two separate states, and mixing them up in diagnosis gives the wrong answer. Second, when access to a resource outside the namespace is required — a device node, a mount point, a kernel setting — the capability alone is not enough; that resource must be explicitly brought inside the namespace. As long as it is not brought in, the exposed surface does not grow.

Capabilities are written by the party that starts the process, and their durable place is a configuration file.

# example unit file fragment — not executed
[Service]
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE
NoNewPrivileges=yes
ProtectSystem=strict
ReadWritePaths=/data/metrics

The first line in this fragment reduces the bounding set to a single capability: no capability outside this list can ever be regained under this unit. The second line lets that capability carry across an exec. The third line turns on the flag. The last two lines touch the fact this lesson leaves open — they make the file system read-only and leave only the needed directory writable. The settings’ names vary by service manager; what does not vary is the order: the ceiling is lowered first, then the single needed capability is granted back.

A program’s genuine capability needs are not found by guessing. The hardening habit works in reverse: it starts by emptying the bounding set, the program is run in a test environment, the denied operations are logged, and for every operation that lands in the log, a single capability is granted back. This route earns two things. The capabilities granted are the ones actually used, and the list turns into a written record of what the program does; when a version changes, the list growing also becomes a visible event. Least privilege was established in the Introduction to Cybersecurity course; its counterpart here is this list.

Privilege Axis: Two Facts, One Closure

  • IM17 — The privilege axis has 2 facts: capability-set and file-permissions.
  • IM18 — Capability dropping is the only mechanism that closes the capability axis; it closes 1 fact and leaves open the file-permissions fact.
  • IM19 — The distinction among the five sets is reduced in the model to a single fact; this is a deliberate limit of the model, and the sweep tests exactly this limit.
  • IM20 — File capabilities and file permission bits are gathered into the same fact in the model.
"""Shared definition, the part this lesson uses: 24 facts, 9 axes, and
capability dropping that closes the capability axis. What is measured: REMAINING EXPOSED SURFACE."""

FACTS = [
    ("other-processes-list", "process"), ("own-process-number", "process"),
    ("ancestor-process-chain", "process"),
    ("root-filesystem-tree", "mount"), ("other-mounts", "mount"),
    ("shared-temp-dir", "mount"),
    ("machine-interfaces", "network"), ("machine-routing-table", "network"),
    ("listening-ports", "network"),
    ("user-id-mapping", "user"), ("file-ownership", "user"),
    ("machine-name", "machine"),
    ("cpu-share", "resource"), ("memory-limit", "resource"),
    ("used-memory", "resource"), ("cpu-count", "resource"),
    ("kernel-version", "kernel"), ("kernel-settings", "kernel"),
    ("system-load", "kernel"), ("clock", "kernel"),
    ("capability-set", "privilege"), ("file-permissions", "privilege"),
    ("access-label", "label"), ("policy-rules", "label"),
]
MECHANISMS = {
    "pid-namespace":     {"axis": "process", "left_open": ["own-process-number"]},
    "mount-namespace":   {"axis": "mount", "left_open": ["root-filesystem-tree"]},
    "network-namespace": {"axis": "network", "left_open": []},
    "user-namespace":    {"axis": "user", "left_open": ["file-ownership"]},
    "uts-namespace":     {"axis": "machine", "left_open": []},
    "cgroup":            {"axis": "resource", "left_open": ["cpu-count"]},
    "capability-dropping": {"axis": "privilege", "left_open": ["file-permissions"]},
}
SIX = ["pid-namespace", "mount-namespace", "network-namespace",
       "user-namespace", "uts-namespace", "cgroup"]


def visible(mechanisms, facts=None):
    facts = FACTS if facts is None else facts
    closed = set()
    for m in mechanisms:
        v = MECHANISMS[m]
        closed |= {o for o, e in facts if e == v["axis"]}
        closed -= set(v["left_open"])
    return [o for o, _ in facts if o not in closed]


def open_axes(mechanisms, facts=None):
    facts = FACTS if facts is None else facts
    g = set(visible(mechanisms, facts))
    return sorted({e for o, e in facts if o in g})


open_facts = set(visible(["capability-dropping"]))
print("ORACLE  fact:", len(FACTS), "| axis:", len({e for _, e in FACTS}))
print("capability axis has", sum(1 for _, e in FACTS if e == "privilege"), "facts")
print()
print("fact                 capability dropping")
for o, e in FACTS:
    if e == "privilege":
        print(f"  {o:20s} {'LEAVES OPEN' if o in open_facts else 'closes'}")
print()
g = visible(["capability-dropping"])
print("capability dropping alone: closed", len(FACTS) - len(g),
      "| left open 1 | visible", len(g),
      "| open axis", len(open_axes(["capability-dropping"])))
ORACLE  fact: 24 | axis: 9
capability axis has 2 facts

fact                 capability dropping
  capability-set       closes
  file-permissions     LEAVES OPEN

capability dropping alone: closed 1 | left open 1 | visible 23 | open axis 9

Three numbers stand side by side. Oracle: 24 facts, 9 axes; 2 facts on the capability axis. Isolation: capability dropping closes 1 fact. Remaining exposed surface: 23 facts and 9 axes. This is among the least-closing of the mechanisms measured alone, and the number is not misleading: capability dropping narrows what a process can request, not what it sees.

This is a limit of the measurement and must be written down openly. This course counts visibility; a mechanism’s operational value is not measured by its closed-fact count alone. Against its single fact in the table, capability dropping closes most of the privileged operation classes a process could request from the kernel. The two measures answer separate questions, and neither substitutes for the other.

The Fact Left Open: File Permissions

The fact that does not close is in an unexpected place. A capability dropped from a process does not change what is written on the file. If a file in the root tree carries an ownership flag or a marked capability, when that file runs, the new process’s starting set is computed from the file. The dropping was done on the process, not on the file system, and the two sides do not know about each other.

The same distinction holds for file permission bits. When the capability that bypasses permission checks is dropped, the process can no longer access every file; but the permissions written on the files stay in place, and a world-writable directory stays writable for a process with dropped capabilities too. Capability dropping is a request filter, not a file system operation.

The narrowing path therefore runs through the file system and has four steps. First, the root tree is mounted read-only; writable space is handed to a separate, narrow mount. Second, the nosuid, nodev, and noexec options are placed on writable mounts — the first cuts off gaining a capability through a file, the second producing a device node, the third running a program from that space. Third, while the root tree is being prepared, files carrying an ownership flag are searched for and the flag is cleared on the ones that do not need it; this is the job of the last line in the lesson’s example dump. Fourth, the no-new-privileges flag is turned on and the bounding set is dropped; done together, a dropped capability never comes back through any exec.

What the four have in common is this: none of them is done on the process, all are done in the process’s world. The model represents this distinction with a single open fact, and what closing that fact requires is not a second capability setting but narrowing the root tree itself.

The Growing Bundle and Fact Set Sweep

  • IM21 — Bundle order is binding: five namespaces, the cgroup, then capability dropping.
  • IM22 — The added fact ambient-capabilities belongs to the capability axis and is not among what capability dropping leaves open.
  • IM23 — The removed file-permissions is the capability axis’s single fact left open.
# --- This block builds on the previous block: FACTS, MECHANISMS, SIX,
# visible and open_axes come from there.
SEVEN = SIX + ["capability-dropping"]
print("the bundle growing through the course")
print("stage                      mechanism  visible  open axis")
for name, d in (("six mechanisms", SIX), ("+capability dropping", SEVEN)):
    print(f"  {name:24s} {len(d):7d}  {len(visible(d)):7d}  {len(open_axes(d)):10d}")
print()
ADDED = FACTS + [("ambient-capabilities", "privilege")]
MISSING = [(o, e) for o, e in FACTS if o != "file-permissions"]
print("fact set sweep (seven mechanisms applied)")
print("set                        fact  axis  visible  open axis  privilege open")
for name, facts in (("base", FACTS), ("+ambient-capabilities", ADDED),
                     ("-file-permissions", MISSING)):
    g = set(visible(SEVEN, facts))
    e = open_axes(SEVEN, facts)
    po = sum(1 for o, ax in facts if ax == "privilege" and o in g)
    print(f"  {name:24s} {len(facts):4d}  {len({x for _, x in facts}):5d}"
          f"  {len(g):7d}  {len(e):10d}  {po:10d}")
the bundle growing through the course
stage                      mechanism  visible  open axis
  six mechanisms                 6       12           7
  +capability dropping           7       11           7

fact set sweep (seven mechanisms applied)
set                        fact  axis  visible  open axis  privilege open
  base                       24      9       11           7           1
  +ambient-capabilities      25      9       11           7           1
  -file-permissions          23      9       10           6           0

The seventh mechanism drops the visible fact count from 12 to 11: one fact. The open axis count still stands at 7, and this gives the same result for a third time. Adding a mechanism narrows the surface, not the axis; something stays open on every axis.

The sweep also shows the model’s limit. When a fact independent of the eighth mechanism is added to the privilege axis, capability dropping closes it too, and the surface stays at 11: modeling the five sets separately would not have changed the result. By contrast, when the single fact left open is removed, the axis closes, the surface drops to 10, and the open axis count to 6. The result points the same direction as the previous two lessons: what determines the surface is not the fact count on the axis, but the existence of the fact left open. Every number in the model is a full count over the fiction, not a measurement taken on a machine.

Summary

  • A capability is superuser privilege divided into separately grantable pieces; capabilities are not equal in scope, and a broad capability is not granted.
  • A process has five capability sets — effective, permitted, inheritable, bounding, and ambient — and above them sits the no-new-privileges flag, which is turned on once and cannot be turned off.
  • The privilege axis has 2 facts; capability dropping closes 1 of them, leaves open the file-permissions fact, and on its own leaves 23 of the 24 facts visible.
  • The reason the fact stays open is structural: dropping is done on the process, what is written on the files does not change. The narrowing path is in the file system — a read-only root, the nosuid, nodev, and noexec options, clearing ownership flags, and dropping the bounding set.
  • In the seven-mechanism bundle, the visible fact count drops from 12 to 11, and the open axis count stays at 7; in the sweep, removing the single fact left open drops the surface to 10 and the axis to 6.

Next Step

Capability dropping narrowed what a process can request from the kernel, but it never asked whom the request is directed at. Permission checking still rests on decisions made by the file’s owner: if a directory is marked world-writable, a process with dropped capabilities writes there too. The next lesson takes up the layer that takes this decision out of the owner’s hands and binds it to a policy. Subject and object are given an access label, every access between them is made explicitly allowed by a policy rule, and access is denied if there is no rule. What is measured is two-sided: the fact the policy closes, and the policy’s own exposed surface, that is, the number of accesses allowed when not needed.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close