Skip to content

Latest commit

 

History

History
628 lines (540 loc) · 29.1 KB

ARCHITECTURE.md

File metadata and controls

628 lines (540 loc) · 29.1 KB

rootsh: technical architecture

This document describes how the Python implementation under python/ actually works: the process/data flow, the on-disk formats, and in particular the privilege-separation design that lets rootsh log a user's session even when it isn't running as root. It assumes you've read README.md for install/usage first. For what changed relative to the original 2004 C implementation, see README.md's "What changed vs the original" -- this document only occasionally references specific differences where they explain a design choice.

Contents

Design goals

  • Linux only. os.forkpty() wraps the platform's native pty support directly; none of the original's BSD forkpty() emulation or SysV STREAMS fallback code is needed. This is a deliberate scope cut, not an oversight.
  • stdlib only. pyproject.toml declares zero runtime dependencies. TOML parsing uses tomllib (3.11+), hashing uses hashlib, pty handling uses os/tty/termios/select/signal, fd-passing uses socket's send_fds/recv_fds (3.9+). The one piece of code that can't be pure Python (see Privilege architecture) is a ~180-line C file with no dependencies beyond libc.
  • Human-readable logs by default, not a raw terminal capture. Both the local file and syslog go through the same escape-stripping, line-splitting writer.
  • Tamper-evidence over tamper-proofing. Nothing running as the logged-in user's own uid can be made un-killable or un-editable by a sufficiently privileged attacker (see Threat model); the design goal is to make tampering detectable, and to get evidence off-box before it can happen.

Repository layout

python/
  install.sh                 installer wizard (build, configure, optionally
                              build+setcap the privileged helper)
  pyproject.toml
  etc/
    rootsh.toml               example config (install.sh writes its own)
    reviewers                 example reviewer list (ditto)
  helper/
    rootsh-logopen.c          privileged helper (see Privilege architecture)
  src/rootsh/
    cli.py                    `rootsh` entry point
    review_cli.py              `rootsh-review` entry point
    session.py                 PtySession: the forkpty()/select() relay loop
    transcript.py               TextTranscript: ANSI-stripping, line-splitting,
                                 timestamped writer
    integrity.py                 HashChain: tamper-evidence
    syslogsink.py                 syslog forwarding
    identity.py                    invoking user vs. effective identity
    reviewers.py                    /etc/rootsh/reviewers loader
    config.py                        TOML config loader
    privlog.py                        client for the privileged helper
  tests/                       pytest suite

Component overview

flowchart TB
    subgraph entry["Entry points"]
        cli["cli.py: main()\n(rootsh)"]
        review["review_cli.py: main()\n(rootsh-review)"]
    end

    subgraph core["Session recording"]
        session["session.py\nPtySession"]
        transcript["transcript.py\nTextTranscript"]
        integrity["integrity.py\nHashChain"]
    end

    subgraph sinks["Output sinks"]
        file["local .closed file\n+ .sha256 sidecar"]
        syslog["syslogsink.py\n-> syslog"]
    end

    subgraph support["Support"]
        identity["identity.py"]
        config["config.py"]
        reviewers["reviewers.py"]
        privlog["privlog.py\n<--> helper/rootsh-logopen.c"]
    end

    cli --> session
    review --> session
    session -->|pty output bytes| transcript
    transcript -->|cleaned line| integrity
    transcript -->|write bytes| file
    transcript -->|line text| syslog
    cli --> identity
    cli --> config
    cli --> reviewers
    cli -->|euid != 0| privlog
    review --> identity
    review --> config
    review --> reviewers
Loading

cli.py and review_cli.py are thin orchestrators: they resolve configuration and identity, wire up a TextTranscript with the right sinks, then hand control to a PtySession and block until it exits. Almost none of the actual pty/logging logic lives in the entry points themselves -- it's factored into the pieces below so both entry points (and, in principle, future ones) share it.

The pty relay loop

session.py's PtySession replaces the bulk of the original rootsh.c: forkpty(), manual raw-mode termios setup, and the select() loop relaying stdin↔pty and pty→stdout+log.

pid, master_fd = os.forkpty()
if pid == 0:
    os.execvpe(executable, argv, env)   # child: becomes the shell (or su, or a pager)
# parent: relay loop

The relay loop (_pump) select()s on three descriptors: stdin (0), the pty master, and a self-pipe wake-up descriptor. Two things worth calling out:

  • Signal handling via self-pipe. The original used pselect() with an empty signal mask, relying on a signal interrupting a blocking syscall with EINTR (checked explicitly, since it ran without SA_RESTART). Python 3.5+ (PEP 475) automatically retries syscalls on EINTR, which defeats that trick outright. The replacement is the standard one: signal.set_wakeup_fd() writes a byte to a pipe whenever a signal is delivered, and that pipe is one of the three descriptors select() blocks on -- so SIGWINCH (terminal resize) and SIGCHLD (child exited) reliably wake the loop up.
  • Draining on child exit. On SIGCHLD, the loop doesn't return immediately -- it first drains whatever output is already buffered in the pty (_flush_trailing, a non-blocking read loop) so the last burst of output before exit isn't lost, matching the original's reasoning for reacting to SIGCHLD immediately rather than waiting for orphaned grandchildren to release the pty.

PtySession is reused for three different children across the codebase: the wrapped shell (cli.py), su (for -u), and a pager like less (rootsh-review) -- the relay/logging logic doesn't care what's on the other end of the pty.

Transcript processing

transcript.py's TextTranscript is the one place both output destinations (local file, syslog) go through, replacing the original's duplicated logic (the local file got the raw byte stream; only the syslog path, write2syslog.c, stripped escape sequences).

Processing order, matching the original's approach in write2syslog.c:

  1. Split on line boundaries first (\r\n and bare \r -- used by prompt/progress-bar redraws -- both normalized to \n).
  2. Strip ANSI/VT100 escape sequences per line (_ANSI_RE: CSI, OSC, DCS, SOS, PM, APC, and generic 2-byte sequences).
  3. Strip remaining C0 control bytes except tab.

This is not a terminal emulator -- see Known limitations.

Partial lines (no trailing \n yet, e.g. mid-command-line output) are buffered (_pending) across feed() calls, so an escape sequence or multi-byte UTF-8 character split across two read()s is handled correctly. _pending is bounded at 8192 bytes, past which it's forced out as its own line, so malformed or binary input can't grow memory without limit.

Each emitted line (_emit) does three things: writes the raw bytes (with an optional [ISO-8601] timestamp prefix) to the file object, calls on_record with those exact bytes (feeds the hash chain -- see below), and calls each line_sinks callback with the decoded text (feeds syslog).

Session log lifecycle and format

A session's local log file is named <user>.<YYYYMMDDHHMMSS>.<pid> under [logging] dir (default /var/log/rootsh), e.g.:

test10.20260923171123.251536

While the session is active the file has that exact name. At session end (cli.py's _finalize_log_file) it's renamed to test10.20260923171123.251536.closed, and a sidecar test10.20260923171123.251536.closed.sha256 is written next to it containing the final hash-chain digest. The .closed suffix is a structural signal, not decoration: rootsh-review and any archival tooling can distinguish "finished, safe to read/archive" sessions from one still being actively written to.

Example transcript content (with logging.timestamps = true):

[2026-09-23T17:11:23.104+00:00] *** rootsh session opened for test10 as test10 on /dev/pts/3 ***
[2026-09-23T17:11:24.512+00:00] test10@host:~$ id
[2026-09-23T17:11:24.520+00:00] uid=1001(test10) gid=1001(test10) groups=1001(test10)
[2026-09-23T17:11:30.001+00:00] test10@host:~$ exit
[2026-09-23T17:11:30.004+00:00] *** rootsh session ended by user (exit status 0) ***

Structural markers (session opened/ended) are written via TextTranscript.write_marker(), which bypasses line buffering and flushes immediately -- they're not shell output, they're rootsh's own bookkeeping, wrapped in *** ... *** to stand out when grepping a transcript.

Integrity: the hash chain

The original detected tampering by comparing the logfile's inode and device number at close time against what was recorded at open time (rootsh.c: beginlogging/endlogging). That only detects the file being replaced (unlinked and recreated); an in-place edit of the same inode goes completely undetected.

integrity.py's HashChain instead makes every record's digest depend on the digest of everything before it:

digest = sha256(bytes.fromhex(previous_digest) + record_bytes).hexdigest()

starting from a genesis value of 64 zero characters. Altering or removing any earlier line changes every digest computed after it. verify_file() recomputes the chain over a .closed transcript from scratch and returns the final digest, for comparison against what's stored in the .sha256 sidecar -- a mismatch means the file was edited after the fact.

Beyond the sidecar (which itself lives on the same, potentially compromised, host), [integrity] checkpoint_every optionally forwards the running digest to syslog every N transcript lines. This is the off-box evidence: if syslog is configured to ship to a remote collector (see the original README's suggestion, unchanged in intent here), a checkpoint recorded before an attacker gains control is outside their reach even if they later edit or delete the local file and its sidecar entirely.

Syslog forwarding

syslogsink.py's SyslogSink is a thin wrapper around the stdlib syslog module: resolves configured facility/priority names (local5/notice by default) to their syslog module constants, and optionally prefixes each line with a rolling 3-digit counter ([syslog] line_numbering) to help spot dropped messages, mod 1000. This replaces job (2) of the original's write2syslog.c; job (1) of that file (escape-stripping/line- splitting) is now shared with the local-file path via transcript.py, instead of being reimplemented here.

Identity resolution

identity.py exists because of a subtlety sudo introduces: sudo rootsh switches the process's real and effective uid both to the target identity (root, by default) before rootsh ever runs, so a geteuid()-based lookup can't distinguish alice's sudo rootsh from bob's -- both come back as root. $SUDO_USER is what sudo sets to record the original caller.

Two functions, deliberately kept separate:

  • effective_identity(): pwd.getpwuid(os.geteuid()).pw_name -- who the process (and therefore the child shell) is actually running as right now. Genuinely root for a plain sudo rootsh.
  • invoking_user(): prefers $SUDO_USER, falls back to effective_identity(). Used for attribution (log filenames, "session opened for" messages) and authorization (the reviewers-file check) -- anywhere the question is "who is the human at the keyboard," not "what uid is the shell running under."

is_standalone() is simply $SUDO_USER is None -- whether this invocation went through sudo at all, which governs whether -f/-d are honored (see CLI entry point).

Note this only disambiguates sudo's privilege model. The login-shell deployment (see Privilege architecture) never goes through sudo at all -- there, real uid == effective uid == the actual logged-in user from the moment the process starts, so invoking_user() naturally resolves correctly via the effective_identity() fallback with no $SUDO_USER involved.

Configuration

config.py loads a TOML file (default /etc/rootsh/rootsh.toml, or $ROOTSH_CONFIG, or --config) into a Config dataclass tree. A missing file at the default/env location is not an error (built-in defaults apply); an explicitly-passed --config path that doesn't exist is. An unknown key anywhere in the file is a hard error (_apply_section raises ValueError), to catch typos rather than silently ignoring them.

Section Key Default Meaning
[logging] enabled true write local transcript files
dir /var/log/rootsh where session logs live
timestamps true prefix each line with an ISO-8601 timestamp
privileged_helper "" path to rootsh-logopen; see Privilege architecture
[syslog] enabled true forward lines to syslog
facility local5 syslog facility name
priority notice syslog priority name
line_numbering false prefix syslog lines with a rolling counter
log_username true include the username in the syslog ident
[shell] default_shell /bin/sh shell to wrap; also the login-shell substitution target
su_command /usr/bin/su used for -u
[review] reviewers_file /etc/rootsh/reviewers who may run rootsh-review
log_dir /var/log/rootsh/review where review-session transcripts go
[integrity] checkpoint_every 20 syslog checkpoint interval in transcript lines (0 disables)

CLI entry point and shell substitution

cli.py's main() flow:

  1. _guard_against_recursive_self_invocation() -- a depth counter via $_ROOTSH_DEPTH, independent of (2) below; refuses to start past a shallow depth rather than fork-bombing the machine if rootsh ever ends up exec'ing itself.
  2. _resolve_shell() -- determines what shell to actually wrap. Normally $SHELL or the passwd entry's shell. If that resolves to rootsh itself -- which happens exactly when rootsh is the user's configured login shell, and login(1)/sshd just exec'd it directly -- substitute [shell] default_shell instead. This substitution is the login-shell deployment mechanism: it's what makes login/sshd exec'ing rootsh transparently become "run the user's real shell, but logged," rather than infinite recursion.
  3. build_command() -- constructs the child's (executable, argv). Mirrors the original execShell()'s branches (runAsUser × useLoginShell × shellCommands), collapsed since argv0 is built correctly here ("-" + basename) rather than the original's "-bin/bash" bug (see README.md).
  4. Resolve the log path, open the log file (direct open() or via the privileged helper -- see next section), wire up TextTranscript/HashChain/SyslogSink.
  5. Run the PtySession, block until the shell exits.
  6. Write the closing marker, finalize the log file (rename to .closed, write the .sha256 sidecar -- again, direct or via the helper), close the syslog sink.

-f/-d (custom logfile name/directory) are only honored when is_standalone() is true. Under sudo, they're silently ignored: an escalated user must not be able to redirect their own audit trail somewhere they control. This is a security property carried over directly from the original.

The reviewer workflow

rootsh-review (review_cli.py) has no equivalent in the original C version -- the 2013 README describes its maintainer solving the same problem by switching to sudo's own I/O logging plus a sudoers NOLOG_OUTPUT:NOLOG_INPUT: /usr/bin/sudoreplay rule. This is rootsh's own version of that second half:

  1. Only usernames listed in [review] reviewers_file may run it.
  2. _resolve_session_log() resolves the requested log name strictly inside [logging] dir, refusing .. components or anything that would escape it -- reviewers get read-only access to session logs, not an arbitrary-file reader.
  3. It opens the target log in a pager ($PAGER, else less, else more, else cat) through a PtySession exactly like a normal session -- and fully re-records that review session (the reviewer's own scrolling, searching, whatever they do) to its own tagged log (review.<reviewer>.<timestamp>.<pid>) under [review] log_dir, with the same .closed/.sha256 lifecycle as any other session. Who reviewed what, when, and what they did while reviewing is itself an auditable record, not a blind spot.

cli.py's _delegates_to_review() recognizes when it's specifically launching rootsh-review as the command (rootsh -u reviewer -c 'rootsh-review ...') for an authorized reviewer, and skips its own content logging for that one invocation -- otherwise the same content would be captured twice (once by rootsh wrapping the outer su, once by rootsh-review itself). This only covers that direct-launch case, not someone typing rootsh-review by hand inside an already-logged interactive shell, matching the scope of the sudoers precedent it's modeled on.

Privilege architecture

This is the part of the design that isn't obvious from reading any single module in isolation, so it gets its own section.

Two deployment modes, two privilege pictures

sudo rootsh (or standalone, unprivileged use with --no-logfile pointed at a directory the user owns): sudo sets the process's real and effective* uid to the target identity (root, by default) before rootsh ever runs. os.geteuid() == 0. Opening a file under /var/log/rootsh (root-owned, mode 0700) is trivial -- open() just works.

rootsh set as a user's login shell (--assign-login-shell, see README.md): login(1)/sshd run as root, but by the time they exec() the configured shell, they've already switched the process's real and effective uid to the target user -- that's the normal, unavoidable behavior of every login path on the system, not something rootsh controls. So when rootsh starts, os.geteuid() is the ordinary logged-in user's uid, not root. It never touches root privilege at all in this path.

That second case is where the actual bug this document exists partly to explain was: open(log_path, "ab") on a root-owned, mode-0700 directory, called by a process running at a normal user's own uid, fails with PermissionError. Every login-shell-wrapped login crashed immediately.

Why this can't just be more Python

The natural instinct is: give the Python process itself a Linux capability (CAP_DAC_OVERRIDE -- bypass DAC file permission checks -- is the minimal one; the login path already put the process at the correct uid, so CAP_SETUID is neither needed nor wanted) via setcap on the interpreter, and skip writing any C.

Two things rule that out:

  1. Linux only honors file capabilities (like setuid) on the ELF binary directly named in execve(). They can't be attached to a text file with a #! shebang line -- the kernel's binfmt_script handler re-invokes execve() on the interpreter, and it's the interpreter's own file capabilities that apply, not the script's (which the kernel doesn't even look at for this purpose). So in principle, setcap cap_dac_override=+ep on the venv's own private Python interpreter would be honored.
  2. But /etc/passwd's shell field must be a bare executable path with no arguments. login(1)/sshd execve() it directly as argv[0] = "-" + basename, nothing else. There's no way to say "run this specific interpreter with this specific script" from that one field. And even if there were a trick around that: a general-purpose interpreter carrying a DAC-bypass capability is catastrophic on its own terms -- its entire purpose is to run arbitrary code supplied by whoever invokes it, so granting it that capability effectively grants DAC-bypass to anyone who can execute it with -c and arbitrary Python, not just to rootsh's own logic. A setuid/capability-bearing interpreter is a well-known anti-pattern for exactly this reason.

So the privileged part has to be a small, fixed-behavior compiled program with no general code-execution surface -- not more Python. That's helper/rootsh-logopen.c.

rootsh-logopen: protocol and design

The helper is invoked by the already-running, unprivileged Python process (privlog.py) -- not by login/sshd, and not listed anywhere in /etc/passwd. It supports exactly two subcommands:

rootsh-logopen open <name> <sockfd>
rootsh-logopen finalize <name>

open opens (creating if needed) LOG_DIR/<name> for append, and sends the resulting file descriptor back to the caller over a UNIX domain socket via SCM_RIGHTS ancillary data -- the standard mechanism for passing an open file descriptor between unrelated processes. The caller never needs to open the path itself; it just gets to use an fd that was opened with more privilege than it has.

sequenceDiagram
    participant P as rootsh (unprivileged,<br/>euid = real user)
    participant H as rootsh-logopen<br/>(cap_dac_override)
    participant FS as /var/log/rootsh<br/>(root:root, 0700)

    P->>P: socket.socketpair()
    P->>H: exec("open", name, sockfd)<br/>pass_fds=[socket]
    H->>H: getuid() == prefix of <name>?
    H->>FS: openat(dirfd, name, O_CREAT|O_APPEND)
    FS-->>H: fd
    H->>P: sendmsg(SCM_RIGHTS, fd)
    H->>H: exit(0)
    P->>P: os.fdopen(fd, "ab")
    Note over P: writes to fd need no further<br/>permission check -- access was<br/>already granted at open() time
Loading

finalize renames LOG_DIR/<name> to LOG_DIR/<name>.closed and writes LOG_DIR/<name>.closed.sha256 from stdin (the final hash-chain digest) -- the same two directory-write operations cli.py does directly when euid == 0.

Security properties

  • CAP_DAC_OVERRIDE only -- set via setcap, not setuid, not full root. It can bypass file permission checks; it can't change who the process is, escalate to arbitrary other privileges, or survive being handed off anywhere except the one already-open fd it produces.
  • LOG_DIR is fixed at compile time (-DROOTSH_LOG_DIR, baked in by install.sh from [logging] dir) -- never taken from argv or the environment. A caller can never point the helper at an arbitrary directory.
  • <name> must be a bare filename: no /, not . or .. (valid_bare_name). Combined with the fixed LOG_DIR, there's no path-traversal escape.
  • <name> must start with "<calling process's own username>." (name_matches_caller), checked via getuid() -- never anything the caller supplies. This is the property that makes it safe for the helper to be installed world-executable (mode 0755, same threat model as su(1)/sudo(1): safe because the logic is fixed and narrow, not because invocation is restricted): one local user can never open, append to, or finalize (rename/overwrite the sidecar of) another user's session log through it.
  • Minimal lifetime. Each invocation does one thing and exits immediately; there's no persistent privileged daemon, no listening socket, nothing to attack between sessions.

A plain sudo rootsh never touches any of this -- cli.py checks os.geteuid() == 0 first and opens/finalizes directly in that case, both because it's unnecessary overhead otherwise and because the helper's getuid()-based ownership check would actively break under sudo (real uid is root there too, not the original invoker -- see Identity resolution).

The installer

install.sh is a single bash script, interactive by default with a --flag equivalent for every prompt (so it also runs unattended, e.g. --yes). Key responsibilities beyond what's covered in README.md:

  • Creates a Python virtualenv and installs the package into it, then writes small wrapper scripts (not bare symlinks) at rootsh/rootsh-review that pin $ROOTSH_CONFIG to wherever it actually wrote the config -- so a non-default --config-dir isn't silently ignored.
  • System mode only: builds helper/rootsh-logopen.c with a C compiler (cc/gcc/clang, whichever is found), setcap cap_dac_override=+eps it, and writes its path into the generated config as [logging] privileged_helper. If no compiler or setcap is available, it warns clearly and continues without the helper -- sudo rootsh still works fully; only the login-shell deployment is affected, and re-running the installer later (once a compiler is available) picks it up.
  • If an existing config is kept rather than overwritten, and the helper was just built, it attempts to safely patch just the privileged_helper key into the existing file (only when doing so is unambiguous: no such key present anywhere yet, exactly one [logging] table to anchor on) rather than silently leaving a freshly-built helper unreferenced.
  • --assign-login-shell=user1,user2,... is deliberately not automated by any other flag combination -- given the blast radius (a mistake here can leave a user, including over SSH, with no working shell at all), it's opt-in per invocation. For each user it:
    1. Pre-flight checks (su user -s /bin/sh -c '... rootsh -c true', run as root so it genuinely drops to that user's real+effective uid) that an unprivileged session can actually open its log file before touching anything.
    2. usermod -s to the rootsh path.
    3. Post-flight checks (su - user -c true, the real login path end to end).
    4. Automatically reverts the shell change and stops (without touching any further users in the list) if either check fails.

Testing

python3 -m venv .venv
.venv/bin/pip install -e . pytest
.venv/bin/pytest -q

The suite covers transcript.py (ANSI stripping, line splitting, buffering edge cases), integrity.py (hash chain properties), config.py (TOML loading, defaults, unknown-key rejection), identity.py and reviewers.py, real forkpty()-based PtySession smoke tests (test_session.py), the login-shell substitution logic (test_login_shell.py), and -- where a C compiler is available (the suite skips these tests otherwise) -- test_privlog.py, which compiles rootsh-logopen fresh into a temp directory and exercises the real open/finalize protocol end-to-end, including both security checks (cross-user name rejected, path traversal rejected).

Threat model and known limitations

  • No purely local design defeats a determined root user. Someone who is root inside the recorded shell (i.e. exactly the person this tool is meant to be watching, under sudo rootsh) can kill -9 the recording process or edit files on disk directly -- nothing running with no more privilege than the thing it's observing can prevent that. The syslog checkpoint forwarding (Integrity) exists specifically to get evidence off-box before that can happen; it's only as strong as wherever syslog is actually configured to ship those messages.
  • Not a full terminal emulator. Escape-sequence stripping is regex-based, not a VT100 state machine. Full-screen redraws (vim, top, ...) won't produce a sensible line-oriented log -- no design in this space does, without emulating an actual terminal.
  • The rootsh→rootsh-review delegation only covers directly launching rootsh-review as the command, not someone typing it by hand inside an otherwise-logged interactive shell (matches the scope of the sudoers precedent it's modeled on -- see The reviewer workflow).
  • shell.default_shell has no per-user override. Every login-shell-wrapped user gets the same substituted shell.
  • The privileged helper's compiled-in LOG_DIR must stay in sync with [logging] dir. Changing the config's log directory by hand after install requires rebuilding and re-setcap-ing the helper (or just re-running install.sh) -- otherwise sessions opened via the helper and sessions opened directly (under sudo) would end up writing to two different directories.