Hello Navi

Tech, Security & Personal Notes

Challenge

Forensic 3 — PapaSmurphey's Pizza

The local police just arrested a suspect believed to be the notorious PapaSmurphey, an online pizza trader. A flash drive was recovered from the scene and was copied; poorly. As best as you can, examine the contents for any incriminating evidence on the suspect. This challenge has several steps. The completion password will look like this: HTS{the_answer}

Solution

Step 1: 素材清单

附件 forensic_3.rar 解开后是 flashdrive/ 下 9 个文件。关键文件是体积异常的 shh.jpg:它只有 500×500 灰度图像,却占用约 10 MB;siggies.txt 则提供了文件签名 (magic bytes)参考。

1
2
3
4
5
6
7
8
49ZfDSNg.jpg                     90658   680x992 JPEG(其实是一张三格漫画)
bitcoin.pdf 184292 PDF 1.4,9 页
DYNAMITE - Winamp 5.0RC8crk.mp3 2230928 MPEG layer III 48kHz
Hack This Site!.url 55 Windows Internet Shortcut(内容就是 hackthissite.org)
legrandelibrary.xlsx 12871 xlsx,作者 Sam
shh.jpg 10376722 500x500 **灰度** JPEG,尺寸与体积严重不符
siggies.txt 54743 十六进制文件签名对照表
UXo9C84.jpg 57822 475x960 progressive JPEG

siggies.txt 是一张文件签名/魔数对照表(TGA、MOV、MOF……), 题面用它给出提示:类型按魔数判断,不以扩展名为准。

一张 500×500 的灰度 JPEG 不可能有 10 MB。binwalk 直接给出结构:

1
binwalk extracted/flashdrive/shh.jpg
1
2
0          0x0       JPEG image, total size: 32346 bytes
10371574 0x9E41F6 JPEG image, total size: 5148 bytes

也就是说:第一张 JPEG 在偏移 32346 处结束,文件从这个偏移开始的内容不是普通 JPEG 尾部,而是连续嵌入的数据。继续扫描 JPEG 起始标记 FF D8 FF,在偏移 10371574 处找到第二张 JPEG(205×80);因此中间区间 [32346, 10371574) 正好是隐藏归档。

这条边界很重要:如果只用 binwalk 或普通文件识别,可能只能看到两张 JPEG,或者把 RAR 当成未知数据;应按文件签名和 JPEG 的 FF D9/FF D8 FF 边界手工确认。

Step 2: Carve 隐藏文件并修复 RAR 签名

shh.jpg 的结构可以概括为:

1
2
[JPEG #1][Zar!\x1a\x07\x00 ... encrypted RAR ...][JPEG #2]
^ RAR 的第一个签名字节被改成了 Z

下面的 carving 脚本从实际 JPEG 标记计算第一张图片的结束位置和第二张图片的起始位置:

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
"""Carve and repair the embedded RAR stream from shh.jpg."""

from pathlib import Path

RAR4_SIGNATURE = b"Rar!\x1a\x07\x00"
BROKEN_SIGNATURE = b"Zar!\x1a\x07\x00"


def carve(source, rar_output, tail_jpeg_output):
data = Path(source).read_bytes()
first_eoi = data.find(b"\xff\xd9")
rar_start = first_eoi + 2
tail_start = data.find(b"\xff\xd8\xff", rar_start)
if first_eoi < 0 or tail_start < 0 or not rar_start < tail_start:
raise SystemExit("could not locate JPEG/RAR/JPEG boundaries")

carved = bytearray(data[rar_start:tail_start])
if bytes(carved[: len(BROKEN_SIGNATURE)]) != BROKEN_SIGNATURE:
raise SystemExit(
"unexpected embedded signature: %r" % bytes(carved[:8])
)
carved[: len(RAR4_SIGNATURE)] = RAR4_SIGNATURE
Path(rar_output).write_bytes(carved)
Path(tail_jpeg_output).write_bytes(data[tail_start:])
print("first JPEG end:", rar_start)
print("RAR start:", rar_start)
print("second JPEG start:", tail_start)
print("RAR bytes:", len(carved))
print("tail JPEG bytes:", len(data) - tail_start)


def main():
carve("shh.jpg", "fixed.rar", "tail_embedded.jpg")
print("RAR signature:", Path("fixed.rar").read_bytes()[:8])


if __name__ == "__main__":
main()

运行脚本的实际输出:

1
2
3
4
5
6
first JPEG end: 32346
RAR start: 32346
second JPEG start: 10371574
RAR bytes: 10339228
tail JPEG bytes: 5148
RAR signature: b'Rar!\\x1a\\x07\\x00'

此时 tail_embedded.jpg 是一个独立的 205×80 JPEG。打开它(也可以对它做 OCR 或直接放大) 能读出一个容易混淆 O/0 的 logo 文字,视觉上近似:

1
GLOzMe

Step 3: 使用图片中的密码解压

再让 7-Zip 认修复后的归档,并用从 tail_embedded.jpg 读出的候选变体测试。 通过校验的密码是 GL0zMe(第三个字符是数字 0):

1
2
7z t -pGL0zMe fixed.rar
7z x -y -pGL0zMe -so fixed.rar 'DNM Login.txt'
1
2
Everything is Ok
PapaSmurphey -- HTS{You_caught_me!}

Vulnerabilities

从闪存盘取证的场景里,拷贝的完整性本身就是证据的一部分: 本例的归档被改掉了一个签名字节、还被截断,靠魔数对照 + 字节级修复仍能还原出归档结构, 说明把文件混在图片里 + 改扩展名 + 改首字节这类手法只能阻碍直接双击查看。 需要保密的介质应整盘加密(如 BitLocker/LUKS),文件层面的伪装不构成保护。

Challenge

Forensic 2 — Cheater

From: howard.davis@divorceattorneys.rus My client is being accused of cheating on his wife. A photograph has been presented in court that shows him with another woman in their bedroom, in their home. The woman in question is a known friend to them both. My client has stated that he did take the photo. However, he claims it was a selfie and no other person was present in the home or in the picture. As the forensic specialist for our law firm, are you able to detect any digital manipulation within this image?

Image link / Password: (提交字段名 forensic2,POST 回关卡页自身)

Solution

ELA 的基本假设是:JPEG 是有损压缩,整幅图被重新压一次时未编辑过的区域残余很小且分布均匀; 而被拼接、复制或二次压缩过的区域,其残余会明显偏离邻域。

完整的全局 ELA 脚本如下。它只计算整幅图的重压缩差异;区域统计和镜像/放大结果来自已保存的分析产物,未把缺失的脚本补写成确定步骤。

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
#!/usr/bin/env python3
"""Compute a global JPEG Error Level Analysis (ELA) summary."""

import argparse
import io

import numpy as np
from PIL import Image


def ela_summary(path, quality=90):
original = Image.open(path).convert("RGB")
buffer = io.BytesIO()
original.save(buffer, format="JPEG", quality=quality)
recompressed = Image.open(buffer).convert("RGB")
diff = np.abs(
np.asarray(original).astype(int) - np.asarray(recompressed).astype(int)
).max(axis=2)
values = diff.astype(np.float32)
return values.mean(), np.percentile(values, 90), int(values.max())


def main():
parser = argparse.ArgumentParser()
parser.add_argument("image", nargs="?", default="selfie.jpg")
parser.add_argument("--quality", type=int, default=90)
args = parser.parse_args()
mean, p90, maximum = ela_summary(args.image, args.quality)
print("global mean %.2f p90 %.0f max %d" % (mean, p90, maximum))


if __name__ == "__main__":
main()
1
2
$ uv run python ela_summary.py selfie.jpg
global mean 1.07 p90 3 max 24

Vulnerabilities

把两张照片拼在一起这种伪造只对人眼有效:JPEG 的压缩历史会把编辑痕迹留在像素的误差水平里, 同一张图里不同来源的区域永远无法伪装成一次拍摄。要真正伪造需要重编码整幅图并统一噪声指纹, 而有经验的取证者仍能用噪声一致性、压缩历史和多质量因子 ELA 把这些差异暴露出来。

ELA4LIFE!!

Challenge

From: stacy.melroy@tritech.org

Thank you for agreeing to help me. We recently had a problem with a former employee and though I cannot prove it, I believe he is the one that erased the files off my thumbdrive. I did some research and I made an image of the drive for you but that is as far as I got. I can replace most of what was lost but there is one file in particular that holds a very important account password. If you could recover that file, I would be extremely grateful.

Also, this is my personal thumbdrive. Keep that in mind please. I'm not sure what you might recover, but I would rather it not get spread around.

委托人(stacy.melroy@tritech.org)说自己的 U 盘被一名已离职的员工清空,附件 image.tar.gz 是整盘镜像,要求把其中保存账号密码的那个文件恢复出来。附件自带 md5 校验值 4e7af965caed9b8d29c40f549fdb7d28

Solution

Step 1: 固定镜像与文件系统

1
2
3
4
5
$ md5sum image.tar.gz
4e7af965caed9b8d29c40f549fdb7d28 image.tar.gz

$ tar xzf image.tar.gz && file image.dd
image.dd: DOS/MBR boot sector, code offset 0x52+2, OEM-ID "NTFS ", sectors/cluster 8, Media descriptor 0xf8, sectors/track 32, heads 64, hidden sectors 1464320, dos < 4.0 BootSector (0x80), FAT (1Y bit by descriptor); NTFS, sectors/track 32, sectors 47103, $MFT start cluster 4, $MFTMirror start cluster 2943, bytes/RecordSegment 2^(-1*246), clusters/index block 1, serial number 038037c7c7f42f96e; contains bootstrap BOOTMGR

filefsstat 都直接把它识别成 NTFS 卷,没有分区表,mmls 无输出,也就是说 image.dd 本身就是卷设备,取证工具按裸卷解析即可:

1
2
3
4
5
6
7
8
$ fsstat image.dd
FILE SYSTEM INFORMATION
--------------------------------------------
File System Type: NTFS
Volume Serial Number: 38037C7C7F42F96E
OEM Name: NTFS
Volume Name: stacy
Version: Windows XP

卷标 stacy 和委托人对得上,确认这就是目标 U 盘。

Step 2: 列出被删除的 inode

fls -r -d 递归列出被标记为删除的目录项,-p 输出完整路径:

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
$ fls -r -d -p image.dd
-/d * 64-144-2: .Trash-1000/expunged/2026288587
r/r * 85-128-2: .Trash-1000/expunged/2026288587/Termination - Allen Smith.docx
r/- * 0: .Trash-1000/expunged/2026288587/Voicemail 1.wav
r/r * 81-128-2: .Trash-1000/expunged/2026288587/Voicemail 1.wav
r/r * 81-128-4: .Trash-1000/expunged/2026288587/Voicemail 1.wav:Zone.Identifier
r/- * 0: .Trash-1000/expunged/2026288587/XuN4Olv.jpg
r/- * 0: .Trash-1000/expunged/2026288587/Zr5FTg3.jpg
r/r * 76-128-2: .Trash-1000/expunged/2026288587/Zr5FTg3.jpg
-/d * 65-144-2: .Trash-1000/expunged/2026288587/private
-/r * 66-128-2: .Trash-1000/expunged/2026288587/private/Cxm5Xlgh.jpg
-/r * 66-128-4: .Trash-1000/expunged/2026288587/private/Cxm5Xlgh.jpg:Zone.Identifier
-/r * 86-128-2: .Trash-1000/expunged/2026288587/private/Your new password is.rar
-/r * 67-128-2: .Trash-1000/expunged/2026288587/5.jpg
-/r * 68-128-2: .Trash-1000/expunged/2026288587/hkjvXEH.jpg
-/r * 69-128-2: .Trash-1000/expunged/2026288587/bvCvfQz.jpg
-/r * 70-128-2: .Trash-1000/expunged/2026288587/BQrr07W.jpg
-/r * 71-128-2: .Trash-1000/expunged/2026288587/IMGP2027_8_9_tonemapped_2-X3.jpg
-/r * 72-128-2: .Trash-1000/expunged/2026288587/kygf687rf.jpg
-/r * 73-128-2: .Trash-1000/expunged/2026288587/11738542606_4157a9cb04_b.jpg
-/r * 74-128-2: .Trash-1000/expunged/2026288587/24155.pdf
-/r * 75-128-2: .Trash-1000/expunged/2026288587/yIeVjcQ.jpg
-/r * 77-128-2: .Trash-1000/expunged/2026288587/46L3tK0.jpg
-/r * 78-128-2: .Trash-1000/expunged/2026288587/XuN4Olv.jpg
-/r * 78-128-4: .Trash-1000/expunged/2026288587/XuN4Olv.jpg:Zone.Identifier
-/r * 79-128-2: .Trash-1000/expunged/2026288587/armls_mwr_2013-q1.pdf
-/r * 79-128-4: .Trash-1000/expunged/2026288587/armls_mwr_2013-q1.pdf:Zone.Identifier
-/r * 80-128-2: .Trash-1000/expunged/2026288587/TrueCrypt Setup 7.1a(1).rar
-/r * 82-128-2: .Trash-1000/expunged/2026288587/jgA1wpZ.jpg
-/r * 82-128-4: .Trash-1000/expunged/2026288587/jgA1wpZ.jpg:Zone.Identifier
-/r * 83-128-2: .Trash-1000/expunged/2026288587/logins.txt
-/r * 84-128-2: .Trash-1000/expunged/2026288587/License.txt
-/r * 91-128-2: .Trash-1000/info/Current.trashinfo
d/- * 0: Current
-/r * 16: $OrphanFiles/OrphanFile-16
-/r * 17: $OrphanFiles/OrphanFile-17
-/r * 18: $OrphanFiles/OrphanFile-18
-/r * 19: $OrphanFiles/OrphanFile-19
-/r * 20: $OrphanFiles/OrphanFile-20
-/r * 21: $OrphanFiles/OrphanFile-21
-/r * 22: $OrphanFiles/OrphanFile-22
-/r * 23: $OrphanFiles/OrphanFile-23

几个观察:

  • 所有被删内容都在 .Trash-1000(Linux 桌面环境的回收站目录)里,Current.trashinfo 记录了删除时间 2014-01-05T01:11:31,路径是 Current,这是整个目录被一次性移入回收站再清空的痕迹。
  • inode-attrtype-attrid 三元组里,r/r * 85-128-2 表示该名字在 $MFT 里仍有 FILE_NAME 属性残留(128 = 0x80,即默认 $DATA 流所在的属性记录),可以按 inode 直接 icatr/- * 0 那几条(Voicemail 1.wavXuN4Olv.jpgZr5FTg3.jpg)只剩索引项,inode 已被清零,同名文件在别处还有一条带真实 inode 的记录,对应关系靠文件名匹配。
  • 和题面相关的四个文件:Termination - Allen Smith.docx(85)、private/Your new password is.rar(86)、logins.txt(83)、Voicemail 1.wav(81)。

Step 3: 按 inode 恢复文件

1
2
3
4
5
6
7
8
9
10
11
$ mkdir -p recovered
$ icat image.dd 85 > recovered/Termination.docx
$ icat image.dd 86 > 'recovered/Your new password is.rar'
$ icat image.dd 81 > 'recovered/Voicemail 1.wav'
$ icat image.dd 83 > recovered/logins.txt

$ file recovered/*
recovered/Termination.docx: Microsoft Word 2007+
recovered/Your new password is.rar: RAR archive data, v4, os: Win32
recovered/Voicemail 1.wav: RIFF (little-endian) data, WAVE audio, Microsoft PCM, 16 bit, mono 44100 Hz
recovered/logins.txt: ASCII text, with CRLF line terminators

删除只清了 $MFT 里的分配位和索引项,数据运行(data runs)指向的簇没有被复用,所以 icat 出来仍然是完好文件。被删名字还带着 NTFS 备用数据流,Voicemail 1.wavZone.Identifier 里是 ZoneId=3,说明这批文件是从网上下载后拷进来的:

1
2
3
$ icat image.dd 81-128-4
[ZoneTransfer]
ZoneId=3

先看那个名字最直白的凭证文件:

1
2
$ cat recovered/logins.txt
stacy.melroy@tritech.org - LittleSister92

Step 4: 从辞退信找解压密码

Termination.docx 是一封辞退通知,落款人正是委托人 Stacy Melroy,正文里留着她的联系电话。docx 是 zip 容器,正文在 word/document.xml,剥掉 XML 标签就能看:

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
$ unzip -p recovered/Termination.docx word/document.xml | sed -e 's/<\/w:p>/\n/g' -e 's/<[^>]*>//g' | grep -n .
1:
2:Stacy Melroy
3:Marketing Day Shift Manager
4:TriTech Inc.
5:519-555-4783
7:January 1st, 2014
9:Allen Smith
10:Marketing Analyst
11:TriTech Inc.
13:
14:Allen Smith,
16:This is an official notification of termination.
17:Effective as of January 3rd, 2014, your position at TriTech Inc. as a Marketing Analyst will be terminated as of the end of your shift.
19:After numerous complaints of harassment from multiple female employees of TriTech Inc. we have no other alternative than to terminate your employment with us.
21:Verbal warning on November 1st, 2013
22:First official write up on November 22nd, 2013
23:Second official write up on December 2nd, 2013 and two days of unpaid vacation required.
24:Final write up on December 20th, 2013 and informed that continued employment will be evaluated by Human Resources.
26:At the end of your shift on January 3rd, 2014, you are to turn in your access badge and any other company issued equipment to the front security desk. Your exit interview will be conducted with HR at 3:00pm to discuss any company benefits that you chose to utilize; health and retirement.
28:Due to the nature of your termination we will not consider you for future employment with TriTech Inc.
34:Respectfully,
39:Stacy Melroy
40:Marketing Day Shift Manager
42:

519-555-4783 就是后来用来加密压缩包的号码。

Step 5: 电话号码解开 RAR

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ 7z x -p5195554783 -orecovered 'recovered/Your new password is.rar'
Extracting archive: recovered/Your new password is.rar
--
Path = recovered/Your new password is.rar
Type = Rar
Physical Size = 7393
Solid = -
Blocks = 1
Multivolume = -
Volumes = 1

Everything is Ok

Size: 9940
Compressed: 7393

$ unzip -p 'recovered/Your new password is.docx' word/document.xml | sed -e 's/<\/w:p>/\n/g' -e 's/<[^>]*>//g' | grep -n .
1:
2:Your new password is 'qPYgbs0w5&amp;?i{8a'.

压缩包内是一个同名的 docx,正文一句话就是任务密码(&amp; 是 XML 转义的 &)。

另外恢复出来的 Voicemail 1.wav 是一段 8.54 秒的 16 bit/44.1 kHz 单声道 PCM 录音(753710 字节),属于同一个事件背景,不参与上面的解压链路。

Script

把上面每一步串起来,从镜像直接跑到任务密码:

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
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
#!/usr/bin/env python3
"""HackThisSite Forensic 1 - recover the deleted password file from the NTFS image.

Chain:
fls -r -d lists deleted inodes -> icat extracts them -> the termination
letter leaks the phone number -> that number is the RAR password -> the
recovered .docx holds the mission password.

Requires: sleuthkit (fls, icat) and 7z on PATH.
"""
import html
import os
import re
import subprocess
import sys
import zipfile

IMAGE = "image.dd"
OUT = "recovered"
os.chdir(os.path.dirname(os.path.abspath(__file__)))


def run(*args, **kw):
return subprocess.run(args, capture_output=True, **kw)


def deleted_inodes(image):
"""Return {basename: inode} for every deleted inode in the image."""
out = run("fls", "-r", "-d", "-p", image).stdout.decode("utf-8", "replace")
found = {}
for line in out.splitlines():
m = re.match(r"^[-rd]/[rd-] \* (\d+)-\d+-\d+:\t(.*)$", line)
if not m:
continue
inode, path = m.group(1), m.group(2)
found[os.path.basename(path)] = int(inode)
return found


def icat(image, inode, dest):
"""Extract the default $DATA stream of an inode to dest."""
with open(dest, "wb") as fh:
subprocess.run(["icat", image, str(inode)], stdout=fh, check=True)
return os.path.getsize(dest)


def docx_text(path):
"""Return the plain text of a .docx (word/document.xml, tags stripped)."""
with zipfile.ZipFile(path) as z:
xml = z.read("word/document.xml").decode("utf-8", "replace")
xml = xml.replace("</w:p>", "\n")
text = re.sub(r"<[^>]+>", "", xml)
return html.unescape(text)


def main():
os.makedirs(OUT, exist_ok=True)
inodes = deleted_inodes(IMAGE)
print("[*] deleted inodes: %d" % len(inodes))
for name in ("Termination - Allen Smith.docx",
"Your new password is.rar",
"Voicemail 1.wav",
"logins.txt"):
if name in inodes:
print(" %-34s inode %d" % (name, inodes[name]))

# 1. termination letter -> manager phone number
letter = os.path.join(OUT, "Termination.docx")
icat(IMAGE, inodes["Termination - Allen Smith.docx"], letter)
text = docx_text(letter)
phone = re.search(r"(\d{3})[-.\s]?(\d{3})[-.\s]?(\d{4})", text)
if not phone:
sys.exit("no phone number in the termination letter")
rar_pw = "".join(phone.groups())
print("[*] phone number : %s" % phone.group(0))
print("[*] rar password : %s" % rar_pw)

# 2. deleted logins.txt (the other credential artifact on the stick)
logins = os.path.join(OUT, "logins.txt")
icat(IMAGE, inodes["logins.txt"], logins)
print("[*] logins.txt : %s" % open(logins, encoding="utf-8",
errors="replace").read().strip())

# 3. the deleted voicemail recording
wav = os.path.join(OUT, "Voicemail 1.wav")
size = icat(IMAGE, inodes["Voicemail 1.wav"], wav)
import wave
with wave.open(wav) as w:
dur = round(w.getnframes() / w.getframerate(), 2)
print("[*] voicemail : %d bytes, %s s PCM" % (size, dur))

# 4. unlock the RAR with the phone number and read the new password
rar = os.path.join(OUT, "Your new password is.rar")
icat(IMAGE, inodes["Your new password is.rar"], rar)
subprocess.run(["7z", "x", "-y", "-p" + rar_pw, "-o" + OUT, rar],
check=True, stdout=subprocess.DEVNULL)
inner = os.path.join(OUT, "Your new password is.docx")
match = re.search(r"'([^']+)'", docx_text(inner))
print("[*] mission password: %s" % match.group(1))


if __name__ == "__main__":
main()

运行结果:

1
2
3
4
5
6
7
8
9
10
11
$ cd <hts-workspace> && uv run python challenges/hts-forensic/1/solve.py
[*] deleted inodes: 29
Termination - Allen Smith.docx inode 85
Your new password is.rar inode 86
Voicemail 1.wav inode 81
logins.txt inode 83
[*] phone number : 519-555-4783
[*] rar password : 5195554783
[*] logins.txt : stacy.melroy@tritech.org - LittleSister92
[*] voicemail : 753710 bytes, 8.54 s PCM
[*] mission password: qPYgbs0w5&?i{8a

Vulnerabilities

NTFS 的删除只做两件事:把 $MFT 记录标成未分配、撤掉父目录索引项,文件内容所在的簇原地不动,直到被新数据覆盖。fls + icat 之所以能整盘还原,就是因为字节还在,元数据也还留着 inode、时间戳和备用数据流。这盘镜像里同时残留两处删除痕迹:一张写着登录邮箱和明文口令的 logins.txt,和一个只用一个电话号码当口令的压缩包,而那个号码印在另一封同样被删除的辞退信签名栏里。也就是说,把文件移入回收站再清空,对取证者而言等同于没有删除;真正需要保密的内容必须用整盘加密或可信的擦除工具处理,否则删除动作本身只是把线索排好队。口令复用把这两处痕迹连成一条链:同一个 5195554783 既出现在文档里又当压缩包密码,攻击者只要恢复出任何一个文件就能顺着链条把所有东西打开。

Challenge

Sam was trying to make a program to show how 1337 he is. But the output isn't always correct. Help him fix his program so he can impress his friends.

Sam 写了个程序想显得自己很 1337,但输出总是不对;帮他把程序修好。

关卡页给出完整的类源码:

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
package org.hackthissite.missions.extbasic;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ExtBasic14 {

private final ExecutorService executorService = Executors.newFixedThreadPool(100);
private static final int MAX = 1337;
private int timeToGetLeet = 0;

ExtBasic14() throws InterruptedException {
for (int i = 0; i < MAX; i++) {
executorService.execute(new Runnable() {
public void run() {
incrementLeetness();
}
});
}
executorService.shutdown();
while (!executorService.isTerminated()) {
Thread.sleep(500);
}
System.out.println(timeToGetLeet);
}

private void incrementLeetness() {
int obfusticatedIncremental = timeToGetLeet;
obfusticatedIncremental = obfusticatedIncremental + 1;
timeToGetLeet = obfusticatedIncremental;
}

/**
* @param args
*/
public static void main(String[] args) throws InterruptedException {
new ExtBasic14();
}

}

Solution

MAX = 1337,构造函数把 1337 个任务提交进一个 100 线程的固定池,每个任务只调用一次 incrementLeetness()shutdown 等池终止后打印 timeToGetLeet。程序想要的输出是 1337。

被点名的 incrementLeetness() 是一个三步的读-改-写:

1
2
3
4
5
private void incrementLeetness() {
int obfusticatedIncremental = timeToGetLeet;
obfusticatedIncremental = obfusticatedIncremental + 1;
timeToGetLeet = obfusticatedIncremental;
}

而且 timeToGetLeet 是普通实例字段,没有 volatile,整个方法也没有任何锁。

多线程交错执行这段代码时会出现经典的丢失更新:

1
2
3
4
5
6
线程 A: obfusticatedIncremental = timeToGetLeet    // 读到 0
线程 B: obfusticatedIncremental = timeToGetLeet // 也读到 0
线程 A: obfusticatedIncremental = 0 + 1
线程 A: timeToGetLeet = 1
线程 B: obfusticatedIncremental = 0 + 1
线程 B: timeToGetLeet = 1

A、B 各做了一次自增,计数却只从 0 走到 1:B 读到的旧值 0 在 A 写回之后仍然被写回,A 的那次增量被覆盖。100 个线程争抢同一个三段序列,丢失的更新累积起来,System.out.println 打出的值就稳定地低于 1337。

用 OpenJDK 17.0.20.1:原始类原样编为 broken,只把方法声明改成 private synchronized void 编为 fixed,各跑 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
$ javac -d broken/out broken/org/hackthissite/missions/extbasic/ExtBasic14.java
$ javac -d fixed/out fixed/org/hackthissite/missions/extbasic/ExtBasic14.java
$ for i in $(seq 1 10); do java -cp broken/out org.hackthissite.missions.extbasic.ExtBasic14; done
1333
1336
1332
1335
1337
1335
1335
1335
1337
1335
$ for i in $(seq 1 10); do java -cp fixed/out org.hackthissite.missions.extbasic.ExtBasic14; done
1337
1337
1337
1337
1337
1337
1337
1337
1337
1337

broken 十次落在 1332–1337 之间(只有两次偶然凑满),fixed 十次全是 1337。两份源码的唯一差异是那个 synchronized

1
2
3
4
5
private synchronized void incrementLeetness() {
int obfusticatedIncremental = timeToGetLeet;
obfusticatedIncremental = obfusticatedIncremental + 1;
timeToGetLeet = obfusticatedIncremental;
}

synchronized 加在实例方法上,等价于整段方法体在 this 的监视器锁内执行:同一时刻只有一个线程能进入读-改-写序列,丢失的更新随之消失。

Key points

  • 递增(x = x + 1)不是原子操作,多线程下必须用锁或原子类型保护;synchronized 方法等价于用 this 做互斥。
  • 只加 volatile 不够:它保证可见性与有序性,但不会让读-改-写变成原子操作,两个线程仍能交错读走同一个旧值。
  • AtomicInteger.incrementAndGet() 用 CAS 免锁达到同样的原子性;本例只需修 incrementLeetness,加 synchronized 是最小改动。
  • 竞争窗口越小越难复现:broken 十次里有两次恰好是 1337,判断修好没有要多跑几次,不能只看单次输出。

Challenge

The script's filename is vrfy.php. Make the script reply 1. Use the relative path. You don't know any users or emails.

关卡给出校验脚本 vrfy.php,要求给出一段相对路径的 URL(可带 query),让脚本输出 1;没有任何已知的用户或邮箱。

关卡页把脚本正文放在 <code> 块里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
if (isset($_GET['name']) && isset($_GET['email'])) {
$user = mysql_real_escape_string($_GET['name']);
$email = mysql_real_escape_string($_GET['email']);
$result= mysql_fetch_assoc(mysql_query("SELECT `email` FROM `members` WHERE name = '$user'"));
$reply = false;
if ($email == $result['email'])
{
$reply = true;
}
} else {
$reply = false;
}
echo ($reply) ? 1 : 0;
?>

Solution

name 先过 mysql_real_escape_string() 作为 WHERE 条件里的单引号字符串字面量。这个函数会转义 \ ' " \n \r \0 和 Ctrl-Z,所以引号无法闭合,name 侧没有 SQL 注入。同时 email 也被转义后才参与比较,但它不进入 SQL:它只和查询结果做相等判断。

也就是说注入方向被堵死,能动的只有查询结果长什么样和比较表达式怎么算。

SELECT \email` FROM `members` WHERE name = '$user'没有LIMIT,但脚本用mysql_fetch_assoc()只取**第一行**。关键在一行都取不到时它的返回值:布尔false`。

此时 $resultfalse,脚本却直接读 $result['email']。布尔值上的下标访问在 PHP 5 里静默求值为 null(PHP 8 会补一条 Trying to access array offset on value of type bool 警告,结果仍是 null)。于是只要让 name 匹配不到任何成员,比较的右端就固定是 null

if ($email == $result['email']) 用的是松散比较 ==null 与字符串比较时按空串处理,于是:

1
2
3
4
var_dump(""    == null);   // bool(true)
var_dump("0" == null); // bool(false)
var_dump("x" == null); // bool(false)
var_dump("0e0" == null); // bool(false)

所以 $email 必须是空字符串,配合一个匹配不到的 name$reply 就变成 true,脚本回 1email 换成 0x0e0 都不行:isset($_GET['email']) 对空串仍为 trueemail= 不会被前一个 isset 分支挡掉。

PHP 8 已经移除 mysql_* 扩展,于是把三个 DB 调用按关卡语义打桩:mysql_query() 返回空结果集(表中不存在所发送的 name),mysql_fetch_assoc() 对空集返回 false,与线上行为一致。

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
<?php
// Level logic replayed against a members table we know nothing about.
// PHP 8 dropped ext/mysql, so the three calls are stubbed to reproduce exactly
// what the level depends on: a SELECT matching no row makes mysql_fetch_assoc()
// return FALSE, and FALSE['email'] then evaluates to NULL.

function mysql_real_escape_string(string $in): string
{
return strtr($in, [
"\\" => "\\\\", "'" => "\\'", '"' => '\\"',
"\n" => "\\n", "\r" => "\\r", "\0" => "\\0", "\x1a" => "\\Z",
]);
}

function mysql_query(string $sql)
{
// "SELECT `email` FROM `members` WHERE name = '$user'" — the members table
// contains none of the names we send, so every lookup returns no rows.
return [];
}

function mysql_fetch_assoc($result)
{
return empty($result) ? false : array_shift($result);
}

function vrfy(array $get): void
{
if (isset($get['name']) && isset($get['email'])) {
$user = mysql_real_escape_string($get['name']);
$email = mysql_real_escape_string($get['email']);
$result = mysql_fetch_assoc(mysql_query("SELECT `email` FROM `members` WHERE name = '$user'"));
$reply = false;
if ($email == $result['email']) {
$reply = true;
}
} else {
$reply = false;
}
echo $reply ? 1 : 0;
}

parse_str($argv[1] ?? '', $get);
vrfy($get);
echo "\n";

工作区里用已安装的 PHP 8.5 跑了几组 query string:

1
2
3
4
5
6
7
8
9
10
$ php replica_vrfy.php 'name=&email=' 2>/dev/null
1
$ php replica_vrfy.php 'name=nobody&email=' 2>/dev/null
1
$ php replica_vrfy.php 'name=nobody&email=0' 2>/dev/null
0
$ php replica_vrfy.php 'name=nobody&email=x' 2>/dev/null
0
$ php replica_vrfy.php 'name=nobody' 2>/dev/null
0

emailname 匹配不到 → 1;非空 email0;只给 name 不给 emailisset 为假)→ 0。与上面的推导一致。(2>/dev/null 只是滤掉 PHP 8 那条布尔下标警告;线上 PHP 5 不产生警告,返回同样是 null。)

Key points

  • namemysql_real_escape_string() 后拼进单引号字符串,引号闭合不了,SQL 注入不通;email 根本不进 SQL,只参与相等判断。
  • mysql_fetch_assoc()零行时返回 false 而非空数组,脚本却直接读 $result['email'],布尔下标求值为 null
  • 松比较 =="" == null 为真、任何非空字符串(含 "0")为假,所以 email 必须留空;isset($_GET['email']) 对空串为真,email= 能通过前置检查。
  • name 只要匹配不到成员即可(本关没有任何已知用户),无需猜邮箱。
  • playit 提交纪律:formkey 每次加载都变,必须取页面 → 立刻提交;缺少 Referer: <关卡页> 会被 Invalid Referer 拒掉且不计分。
  • 修正方式:用 === 做严格比较,并在使用结果前判断 mysql_fetch_assoc() 是否为 false(更彻底地用 PDO/mysqli 预处理替换已废弃的 mysql_*)。
vrfy.php?name=&email=

Challenge

This site is run by a serious web admin. But the web developer doesn't know that much. URL: moo.com (any script you want); Exploit this code:

站点由一个认真的 web 管理员在维护,但写代码的开发者水平不高。题目给定 moo.com 上的任意脚本(any script you want),要求利用这段 PHP。

关卡页给出的脚本正文:

1
2
3
4
5
<?php
$password = 'IWantToCow';
foreach ($_GET as $key => $value) { $$key = $value; }
if ($userpass == $password) { ok(); } else { echo "<form><input type='text' name='usertext' /><input type='submit'><form>"; }
?>

Solution

$$key = $value 是 PHP 的变量变量写法:把 $key 的值当作变量名,再给它赋值。foreach ($_GET as $key => $value) 遍历 URL query string 的每个参数,于是

  • ?userpass=IWantToCow 会执行 $userpass = 'IWantToCow'
  • ?password=IWantToCow 会执行 $password = 'IWantToCow'
  • 任何其他名字同理,脚本里的变量表完全由请求者给出的 query string 决定。

脚本原本先把 $password 设成 'IWantToCow',但紧接着的循环会把它连同 $userpass 一起覆盖。最终判据是:

1
if ($userpass == $password) { ok(); }

判据只比较 $userpass$password 两个变量,而两者都在循环的可写范围内。既然 query string 能同时给它们赋值,直接让二者取同一个值就恒为真

Challenge

关卡给出一段 Windows 批处理(batch)写的认证脚本,要求输入一个能让脚本认证通过的密码。脚本用 SET /P 读取输入,然后逐字符与字母表比对,命中时把一个累乘变量乘以对应的素数:

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
@ECHO OFF
SETLOCAL ENABLEDELAYEDEXPANSION
SET PRIME=2 3 5 7 11 13 17 19 23 29 31 37 41 43 47 53 59 61 67 71 73 79 83 89 97 101
SET CHARS=a b c d e f g h i j k l m n o p q r s t u v w x y z
SET PASSWORDVALUE=1
SET INPUT=
SET /P INPUT=Insert password:
IF "%INPUT%"=="" "%~0"
ECHO Authenticating...
:OVERLOOP
SET CURRENTPOSITION=0
:SUBLOOP
IF /I "!INPUT:~%CHARACTERPOSITION%,1!"=="!CHARS:~%CURRENTPOSITION%,1!" SET /A PASSWORDVALUE*=!PRIME:~%CURRENTPOSITION%,3!
SET /A CURRENTPOSITION+=3
IF NOT %CURRENTPOSITION%==78 GOTO :SUBLOOP
SET /A CHARACTERPOSITION+=1
IF NOT "!INPUT:~%CHARACTERPOSITION%,1!"=="" GOTO :OVERLOOP
:END
ENDLOCAL&IF NOT %PASSWORDVALUE%==1065435274 GOTO :ACCESSDENIED
ECHO You have been authenticated. Welcome aboard!
GOTO :SILENTPAUSE
:ACCESSDENIED
ECHO Access denied!
:SILENTPAUSE
PAUSE > NUL

Solution

Step 1: 脚本语义

PRIMECHARS 都是等宽分隔的字符串:PRIME 里每个素数被右填充到 3 个字符宽,CHARS 里每个字母后面也有两个空格。内层循环的 CURRENTPOSITION0,3,6,…,75 共 26 个位置,!PRIME:~%CURRENTPOSITION%,3! 正好截出对应的一个素数,!CHARS:~%CURRENTPOSITION%,1! 截出 az

外层对输入里每个字符位置 CHARACTERPOSITION 走一遍这 26 个槽位。IF /I 是大小写不敏感的,所以一个大写字母也会命中。命中的后果只有一个:SET /A PASSWORDVALUE*=!PRIME:~…,3!,也就是把累乘值乘上该字母对应的那个素数。因此

  • 输入里每个字符最多贡献一次乘法,乘的是 {2,3,5,…,101} 中的某一个素数;
  • 非字母字符不贡献任何乘法;
  • 重复同一个字母就是重复乘同一个素数。

关卡的门槛是

1
IF NOT %PASSWORDVALUE%==1065435274 GOTO :ACCESSDENIED

把目标值分解:

1
1065435274 = 2 × 6827 × 78031

682778031 都是素数,且都大于 101,不在脚本能乘到的素数表里。换句话说,任何由列表内素数组成的乘积都不可能精确等于 1065435274。如果只盯着凑乘积这一个方向,该关无法通过。

Step 3: 32 位溢出

SET /A 的整数运算是 32 位有符号 的,累乘过程中超过 2**31 会回环绕。于是真正需要满足的条件是同余:

1
∏(选中的素数) ≡ 1065435274 (mod 2**32)

因为乘法在模 2**32 下可结合,最终值只取决于所选素数构成的多重集合(与输入顺序无关)。

再看目标值的 2-adic 赋值:1065435274 = 2 × 532717637532717637 是奇数。2**32 以内的偶数按 2 的幂次分层,目标只含一个因子 2,所以素数 2(字母 a)必须恰好出现一次,其余部分是一个奇数乘积,需要满足

1
X ≡ 532717637 (mod 2**32)

只剩 25 个奇素数(3101)可选,每个的多重度未知。用中间相遇(meet-in-the-middle):把奇素数分成两半分别枚举各自的多重集合乘积,在一半的哈希表里查另一半需要的补数(对奇数取模逆),取总乘法次数最少的组合。每个素数的重数上限取 2 就足够:25 个奇素数、每个重数取 0..2,组合数在 3**25 量级,远多于模数 2**32,解很多而长度很小。

完整求解器:

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
from itertools import product

MOD = 1 << 32
TARGET = 1065435274
PRIMES = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37,
41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97, 101]
LETTERS = "abcdefghijklmnopqrstuvwxyz"

def search(limit=2):
"""Find prime multiplicities (0..limit per odd prime) with product == TARGET mod 2**32."""
odd = PRIMES[1:]
left, right = odd[:12], odd[12:]
table = {}
ranges = [range(limit + 1)] * len(right)
for counts in product(*ranges):
r = 1
for q, k in zip(right, counts):
r = r * pow(q, k, MOD) % MOD
table.setdefault(r, counts)
best = None
for counts in product(*([range(limit + 1)] * len(left))):
l = 1
for q, k in zip(left, counts):
l = l * pow(q, k, MOD) % MOD
need = (TARGET // 2) * pow(l, -1, MOD) % MOD
rc = table.get(need)
if rc is not None and (best is None or sum(counts) + sum(rc) < best[0]):
best = (sum(counts) + sum(rc), counts, rc)
return best

def build(best):
_, lc, rc = best
counts = {2: 1}
for q, k in dict(zip(PRIMES[1:13], lc)).items():
counts[q] = counts.get(q, 0) + k
for q, k in dict(zip(PRIMES[13:], rc)).items():
counts[q] = counts.get(q, 0) + k
pw = "".join(LETTERS[q_index] * k
for q_index, k in sorted((PRIMES.index(q), k) for q, k in counts.items()))
return counts, pw

def replay(pw):
"""Model the batch loop: each letter multiplies its prime; result is signed 32-bit."""
value = 1
for ch in pw.lower():
idx = LETTERS.find(ch)
if idx >= 0:
value = value * PRIMES[idx] % MOD
return value - MOD if value >= (1 << 31) else value

if __name__ == "__main__":
counts, pw = build(search())
print("counts:", counts)
print("password:", pw)
assert replay(pw) == TARGET, "replay mismatch"
assert pw == "aghilmort"

Step 5: Result

搜索得到 9 个字符的最短解:

1
2
密码:aghilmort
对应素数:a=2, g=17, h=19, i=23, l=37, m=41, o=47, p=61, t=71

乘积在 32 位下回绕:

1
2
2×17×19×23×37×41×47×61×71 = 4588090507402
4588090507402 mod 2**32 = 1065435274

Challenge

The following is a batch script authentication system. Your goal here is to get the batch script to authenticate you by inputting a password into the field. For this extbasic, your goal is to circumvent authentication altogether. Decrypting the password is for extbasic11.

关卡给出一个 Windows batch 认证脚本,要求输入内容使脚本认证通过。本关的目标是绕过认证。

关卡页把脚本正文放在 <code> 块里:

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
@ECHO OFF
SETLOCAL ENABLEDELAYEDEXPANSION
SET PRIME=2 3 5 7 11 13 17 19 23 29 31 37 41 43 47 53 59 61 67 71 73 79 83 89 97 101
SET CHARS=a b c d e f g h i j k l m n o p q r s t u v w x y z
SET PASSWORDVALUE=1
SET INPUT=
SET /P INPUT=Insert password:
IF "%INPUT%"=="" "%~0"
ECHO Authenticating...
:OVERLOOP
SET CURRENTPOSITION=0
:SUBLOOP
IF /I "!INPUT:~%CHARACTERPOSITION%,1!"=="!CHARS:~%CURRENTPOSITION%,1!" SET /A PASSWORDVALUE*=!PRIME:~%CURRENTPOSITION%,3!
SET /A CURRENTPOSITION+=3
IF NOT %CURRENTPOSITION%==78 GOTO :SUBLOOP
SET /A CHARACTERPOSITION+=1
IF NOT "!INPUT:~%CHARACTERPOSITION%,1!"=="" GOTO :OVERLOOP
:END
ENDLOCAL&IF NOT %PASSWORDVALUE%==1065435274 GOTO :ACCESSDENIED
ECHO You have been authenticated. Welcome aboard!
GOTO :SILENTPAUSE
:ACCESSDENIED
ECHO Access denied!
:SILENTPAUSE
PAUSE > NUL

Solution

PRIMECHARS 都按 3 个字符一项排列。内层循环依次取 036 直到 75,将输入字符映射到 2101 的质数;命中后执行:

1
SET /A PASSWORDVALUE*=对应质数

最终判定要求 PASSWORDVALUE 等于 1065435274。该值的分解为:

1
1065435274 = 2 × 6827 × 78031

682778031 不在脚本的质数表中,因此按原有乘法路径无法得到目标值。可利用点在输入处理,而非乘积本身。

认证前的判断直接把原始输入拼进双引号:

1
IF "%INPUT%"=="" "%~0"

输入未经过转义,可以闭合字符串并追加命令。使用以下 payload:

1
"=="" set passwordvalue=1065435274 && goto :end abc

代入后得到:

1
IF ""=="" set passwordvalue=1065435274 && goto :end abc"=="" "%~0"

""=="" 成立,set passwordvalue=1065435274 将累积值直接设为目标值,goto :end 跳过整个乘法循环。set 的变量名不区分大小写,所以这里的 passwordvalue 与脚本中的 PASSWORDVALUE 相同。

到达 :END 后,判定行中的 %PASSWORDVALUE% 会在整行执行前展开,早于 ENDLOCAL

1
ENDLOCAL&IF NOT %PASSWORDVALUE%==1065435274 GOTO :ACCESSDENIED

此时展开结果为 1065435274==1065435274IF NOT 条件为假,执行流进入认证成功分支。

Challenge

Captain Kirk has coded this Perl script for all his fellow-captains to automate their logging. This way they don't have to record their logs on tape, but they can type them in and archive them. But this log only seems to log one log?! It automatically deletes all previous logs! Fix the script for him, so they can keep their logs again! Captain Kirk 给同僚写了一个自动记日志的 Perl 脚本,但每次只留下一条日志, 之前的全被删掉;把它修好,让日志能留存下来。

关卡页给出完整脚本:

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
#!/usr/bin/perl
# Captain Kirk has coded this Perl script for all his fellow-captains
# to automate their logging.
# This way they don't have to record their logs on tape, but they can type them in
# and archive them. But this log only seems to log one log?!
# It automatically deletes all previous logs! Fix the script for him,
# so they can keep their logs again!
print '> Hello Captain ' . $ENV{'USER'} . '.' . "\n";
open(STARTREKLOG, '>/var/log/startrek');
print '> Please enter your log data here, end with a "." on a single line.' . "\n";
my $LogText;
print '> ';
while (<STDIN>) {
unless ($_ ne '.' . "\n") {
last;
}
$LogText .= $_;
print '> ';
}
print '> Log is being saved to /var/log/startrek' . "\n";
$DateTime = localtime();
print STARTREKLOG ' -- START OF LOG -- ' . "\n";
print STARTREKLOG 'Date/Time: ' . $DateTime . "\n";
print STARTREKLOG 'Log : ' . $LogText;
print STARTREKLOG ' -- END OF LOG -- ' . "\n";
die('> Log saved! Now exiting.' . "\n");

Solution

脚本把交互内容累加进 $LogText,最后用四条 print STARTREKLOG 落盘:

1
2
3
4
print STARTREKLOG ' -- START OF LOG -- ' . "\n";
print STARTREKLOG 'Date/Time: ' . $DateTime . "\n";
print STARTREKLOG 'Log : ' . $LogText;
print STARTREKLOG ' -- END OF LOG -- ' . "\n";

写入语句本身没有问题,本次要记的内容也完整。决定上一次的日志还在不在的是更早的那一行:

1
open(STARTREKLOG, '>/var/log/startrek');

Perl 的两参数 open 把模式写在文件名前面:< 读、> 写、>> 追加。>截断写:打开时先把文件长度清零,文件指针回到开头,于是每次运行都从空文件开始,上一次的内容在这次运行的第一条 print 之前就没了。题面说的 It automatically deletes all previous logs 正是这个行为。

open(STARTREKLOG, >>/var/log/startrek);

Challenge

Bill Gates wrote a Perl script that grants access to the company records; it has a security flaw that lets everyone in. Fix the flaw.

Bill Gates 写了个 Perl 脚本,用来校验访问者有没有权限读取公司记录;这段脚本存在安全缺陷,任何人都能拿到记录。要求把缺陷修掉。

1
2
3
4
5
6
7
8
#!/usr/bin/perl
chomp ( my $User = `/usr/bin/whoami` ) ;
print "Checking your access level...\n" ;
if ( $User == 'BillGates' ) {
print "Authorized! Here are the company records:\n" . `cat /home/BillGates/CompanyRecords.db` ;
die ( "Closing...\n" ) ;
}
die ( "You're not authorized!\n" ) ;

Solution

脚本的逻辑很短:用反引号执行 /usr/bin/whoami 拿到当前用户名存进 $User,然后只有一个 if 决定是否读取 /home/BillGates/CompanyRecords.db。要修好缺陷,改动点必然落在这个比较上;其它行(chompprintdie)都只是输出,不参与授权判断。

Perl 有两套比较运算符,不能混用:

  • ==!=<>数值比较,会先把两侧操作数转成数字;
  • eqneltgt字符串比较,逐字符对比。

脚本用的是 $User == 'BillGates'。两侧都是字符串,却在走数值比较。用 Perl 直接复现这个语义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/usr/bin/perl
# Local reproduction of the ExtBasic 8 flaw (run against our own Perl, no
# network). A non-privileged user name stands in for the whoami output.
use strict;
use warnings;

my $User = "some_user"; # what `/usr/bin/whoami` would return

my $target = "BillGates";

print "user : $User\n";
print "target : $target\n";
print "num(user) : ", $User + 0, "\n";
print "num(target): ", $target + 0, "\n";
printf "flawed \$User == \$target -> %s\n", ($User == $target) ? "TRUE" : "FALSE";
printf "fixed \$User eq \$target -> %s\n", ($User eq $target) ? "TRUE" : "FALSE";
1
2
3
4
5
6
7
8
9
$ perl -w perl_demo.pl
Argument "some_user" isn't numeric in addition (+) at perl_demo.pl line 13.
Argument "BillGates" isn't numeric in addition (+) at perl_demo.pl line 14.
user : some_user
target : BillGates
num(user) : 0
num(target): 0
flawed $User == $target -> TRUE
fixed $User eq $target -> FALSE

Perl 把非数字开头的字符串转成数字时取前导数字部分,没有前导数字就是 0(同时抛 isn't numeric 警告)。whoami 返回的是用户名,BillGates 也是裸字符串,两者的数值转换结果都是 0

授权条件退化成 0 == 0,恒为真。任何用户名(rootnobody、普通用户都一样)都会走进 if 分支,打印记录再 die("Closing...")。真正的字符串判定应该用 eq'some_user' eq 'BillGates' 为假,只有用户名恰好是 BillGates 时才通过。

if ($User eq BillGates)