Skip to content

hardening

hardening

Process self-hardening — shrink what a leaked address space can reveal.

A process that holds secret material (a vault DB session key, an SSH private key) wants four cheap kernel-level guarantees the moment it starts, before it opens anything sensitive:

  • No ptrace / no debugger attach — prctl(PR_SET_DUMPABLE, 0) clears the dumpable flag, so another process in the same user (a compromised sibling) cannot ptrace the address space and cannot read /proc/<pid>/mem. It also stops the kernel writing a core dump for this process.
  • No core dumps — setrlimit(RLIMIT_CORE, 0) belt-and-braces the dumpable clear: even a SIGSEGV can't spill the heap (keys included) to a file on disk.
  • No swap-out — mlockall(MCL_CURRENT | MCL_FUTURE) pins the pages into RAM so secret bytes never land in the swap file where they outlive the process. This one is best-effort: it needs CAP_IPC_LOCK or a generous RLIMIT_MEMLOCK and legitimately fails in a locked-down rootless container — a failure is reported, never raised.
  • No privilege gain on exec — prctl(PR_SET_NO_NEW_PRIVS, 1) bars this process and every descendant from ever gaining privilege through a setuid/setgid bit or file capabilities on exec. A child that is compromised while shelling out (the gate service runs git) cannot re-exec a setuid helper to climb out. It also unlocks installing an unprivileged seccomp filter, which the kernel permits only once no-new-privs is set. Unlike the dumpable clear, it never impedes a debugger attaching, so it holds even in debug mode.

harden_self applies all four to the current process and returns a HardeningReport of what took — the floor every isolated child process (terok-sandbox's split supervisor children) applies at start-up.

confine_filesystem is the companion filesystem floor. It uses Landlock to pin the process to its lane: read and execute the shared runtime, read and write its own data, touch nothing else. A bug in a spawned binary then cannot read a secret outside the lane and cannot write a payload outside the lane. The kernel gates unprivileged Landlock on the no_new_privs that harden_self sets, so confine_filesystem runs second.

__all__ = ['HardeningReport', 'LandlockReport', 'confine_filesystem', 'harden_self'] module-attribute

HardeningReport(no_dump, no_core, memory_locked, no_new_privs) dataclass

What harden_self managed to apply.

Each field is True only when the corresponding guarantee is in force for the current process. no_dump, no_core, and no_new_privs are expected to succeed; memory_locked routinely comes back False in a rootless container without CAP_IPC_LOCK and that is not an error — the caller decides whether to log it.

no_dump instance-attribute

no_core instance-attribute

memory_locked instance-attribute

no_new_privs instance-attribute

fully_hardened property

True when all four guarantees are in force.

LandlockReport(confined, reason, partially_confined=False) dataclass

Whether confine_filesystem took hold.

confined is True only when the complete requested policy covers every thread in the process. An ABI 2 kernel enforces every right it supports but cannot deny truncation; it reports partially_confined=True. When both fields are False, the process is unchanged. reason always fits a diagnostic log line.

confined instance-attribute

reason instance-attribute

partially_confined = False class-attribute instance-attribute

harden_self(*, allow_debugger=False)

Apply the process-hardening floor to the current process.

Idempotent and side-effecting: clears the dumpable flag, zeroes the core-dump limit, and locks memory — each independently, so a failure of one (typically mlockall for lack of privilege) still lets the others take. Never raises; the returned HardeningReport says what held.

Call this as early as possible in a process that will hold secret material — before opening the credential store or binding a socket — so the sensitive bytes are only ever mapped under the guarantees.

allow_debugger leaves the dumpable flag set so a debugger, py-spy, or strace can attach — the escape hatch for running a task in debug mode. It trades away only the no-ptrace guarantee (no_dump reports False); the core-dump, swap-out, and no-new-privileges guarantees still apply (the last never impedes a debugger attaching).

Source code in src/terok_util/hardening.py
def harden_self(*, allow_debugger: bool = False) -> HardeningReport:
    """Apply the process-hardening floor to the current process.

    Idempotent and side-effecting: clears the dumpable flag, zeroes the
    core-dump limit, and locks memory — each independently, so a failure
    of one (typically ``mlockall`` for lack of privilege) still lets the
    others take.  Never raises; the returned
    [`HardeningReport`][terok_util.hardening.HardeningReport] says what
    held.

    Call this as early as possible in a process that will hold secret
    material — before opening the credential store or binding a socket —
    so the sensitive bytes are only ever mapped under the guarantees.

    *allow_debugger* leaves the dumpable flag set so a debugger, ``py-spy``,
    or ``strace`` can attach — the escape hatch for running a task in debug
    mode.  It trades away only the no-ptrace guarantee (``no_dump`` reports
    ``False``); the core-dump, swap-out, and no-new-privileges guarantees
    still apply (the last never impedes a debugger *attaching*).
    """
    libc = _libc()
    return HardeningReport(
        no_dump=False if allow_debugger else _clear_dumpable(libc),
        no_core=_zero_core_limit(),
        memory_locked=_lock_memory(libc),
        no_new_privs=_set_no_new_privs(libc),
    )

confine_filesystem(read_exec, read_write)

Pin the whole process and its descendants to the given filesystem lane.

After this call the process reads and executes only under read_exec. It creates, modifies, and removes only under read_write. A directory grant covers the directory's whole hierarchy. A non-directory grant covers that exact object — this permits a writable /dev/null without a writable /dev. Landlock denies every other path, even for reading. The kernel gates unprivileged Landlock on no_new_privs, so call harden_self first.

Call this before starting threads on Landlock ABI 1–7. Those kernels restrict only the calling thread, so the call leaves an already-multithreaded process unchanged and reports it as unconfined. ABI 8 applies the ruleset to all threads atomically.

Best-effort and irreversible: the call never raises. A kernel or build without Landlock changes nothing and returns confined=False. ABI 1 cannot allow cross-directory rename, so it also changes nothing rather than break read-write lane semantics. ABI 2 receives its supported subset and reports partially_confined=True because it cannot deny truncation. The call skips a path that does not exist: a parent grant covers its later creation. When it cannot grant an existing path, it installs no ruleset. Pathname unix sockets are intentionally outside this filesystem policy.

Source code in src/terok_util/hardening.py
def confine_filesystem(read_exec: Iterable[Path], read_write: Iterable[Path]) -> LandlockReport:
    """Pin the whole process and its descendants to the given filesystem lane.

    After this call the process reads and executes only under *read_exec*.
    It creates, modifies, and removes only under *read_write*.  A directory
    grant covers the directory's whole hierarchy.  A non-directory grant
    covers that exact object — this permits a writable ``/dev/null`` without
    a writable ``/dev``.  Landlock denies every other path, even for reading.
    The kernel gates unprivileged Landlock on ``no_new_privs``, so call
    [`harden_self`][terok_util.hardening.harden_self] first.

    Call this before starting threads on Landlock ABI 1–7.  Those kernels
    restrict only the calling thread, so the call leaves an
    already-multithreaded process unchanged and reports it as unconfined.
    ABI 8 applies the ruleset to all threads atomically.

    Best-effort and irreversible: the call never raises.  A kernel or build
    without Landlock changes nothing and returns ``confined=False``.  ABI 1
    cannot allow cross-directory rename, so it also changes nothing rather
    than break read-write lane semantics.  ABI 2 receives its supported
    subset and reports ``partially_confined=True`` because it cannot deny
    truncation.  The call skips a path that does not exist: a parent grant
    covers its later creation.  When it cannot grant an existing path, it
    installs no ruleset.  Pathname unix sockets are intentionally outside
    this filesystem policy.
    """
    libc = _libc()
    if libc is None:
        return LandlockReport(False, "landlock unavailable (kernel < 5.13 or no syscall)")

    abi = _landlock_abi(libc)
    if abi < 1:
        return LandlockReport(False, "landlock unavailable (kernel < 5.13 or no syscall)")
    if abi < _LANDLOCK_ABI_REFER:
        return LandlockReport(
            False,
            "Landlock ABI 1 cannot allow cross-directory rename/link; filesystem unconfined",
        )

    if scope_failure := _thread_scope_failure(abi):
        return LandlockReport(False, scope_failure)

    read_access, write_access = _access_masks(abi)
    ruleset = _create_ruleset(libc, write_access)
    if ruleset < 0:
        return LandlockReport(False, f"create_ruleset failed (errno {ctypes.get_errno()})")

    try:
        for paths, access in ((read_exec, read_access), (read_write, write_access)):
            for path in paths:
                if failure := _grant_beneath(libc, ruleset, path, access):
                    return LandlockReport(False, failure)

        flags = _RESTRICT_SELF_TSYNC if abi >= _LANDLOCK_ABI_TSYNC else 0
        if libc.syscall(_NR_RESTRICT_SELF, ruleset, flags) != 0:
            return LandlockReport(False, f"restrict_self failed (errno {ctypes.get_errno()})")
    finally:
        with suppress(OSError):
            os.close(ruleset)

    if abi < _LANDLOCK_ABI_TRUNCATE:
        return LandlockReport(
            False,
            f"filesystem partially confined (Landlock ABI {abi} cannot deny truncation)",
            partially_confined=True,
        )
    return LandlockReport(True, f"filesystem confined (Landlock ABI {abi})")