The Empty Argument List That Became Root — Python Bug Hunt

Modelled on PwnKit (CVE-2021-4034, disclosed by Qualys in January 2022). polkit's pkexec assumed it was always started with at least one argument.

  • Language: Python
  • Layer: Backend
  • Difficulty: Medium
  • Concepts: Security, Bounds
  • Modelled on: polkit · CVE-2021-4034
  • Visible tests: a program name is resolved through the lookup; an empty argv is refused with ValueError
  • Reward: 50 XP for a complete fix

Briefing

Modelled on PwnKit (CVE-2021-4034, disclosed by Qualys in January 2022). polkit's pkexec assumed it was always started with at least one argument. Started with an empty argv, its option loop was skipped, it read argv[1] anyway — which on Linux is the first environment variable, laid out right after argv — and later wrote the resolved program path back into that slot. That out-of-bounds read and write let a local user smuggle an environment variable past pkexec's sanitising and gain root. The bug had been present since 2009; the fix makes pkexec refuse to run when argc is 0.

process.py models the kernel's layout as one array (argv, a terminator, then envp) with unchecked indexing, like C. pkexec.py resolves the program to run.

Fix resolve so it never reads or writes outside argv.

Bug report

BUG-PWNKIT · Priority: Critical (local root) · Reported by: security

resolve(frame, lookup) returns {"program": path, "args": [...]}:

  • argv[0] is pkexec itself; options start at argv[1] and are the arguments beginning with "--"; the first argument that is not an option is the program
  • a program without a leading "/" is resolved with lookup(name) and the resolved path is written back into its argv slot
  • "args" are the arguments after the program
  • if frame.argc < 1, raise ValueError before touching anything
  • if there is no program (only pkexec and options), raise ValueError
  • nothing outside argv (indexes 0 .. argc-1) may ever be read or written

Observed: with an empty argv, resolve reads the first environment variable as if it were the program and overwrites it with a "resolved" path.

Logs

pkexec: argc=0 argv[1]="LANG=C"
pkexec: resolved program "/usr/bin/LANG=C" written to argv[1]
audit: environment of setuid process modified after sanitising

The code as shipped

src/polkit/pkexec.py (editable)

# Resolves the program pkexec was asked to run.

def resolve(frame, lookup):
    n = 1
    while n < frame.argc:
        if frame.argv(n).startswith("--"):
            n += 1
            continue
        break
    path = frame.argv(n)
    if not path.startswith("/"):
        path = lookup(path)
        frame.set_argv(n, path)
    args = [frame.argv(i) for i in range(n + 1, frame.argc)]
    return {"program": path, "args": args}

Read-only context: src/polkit/process.py.

Open the hunt to edit the files, run the visible tests and submit against the hidden ones. More Python bug hunts.