这个比赛名字怎么不统一?之前叫Automotive,是和Vicone合办的,现在叫Vehicle 之类,全称:Global Vehicle Cybersecurity Competition (VCC) 2025
这个赛事是8月22-25和8月29日-9月1日
2024的还没学完,做这个属实不太好,目前 ICV技能树一个都没点完。
今年分了红蓝队两种类型,做了那个签到才能解锁后续题目,这几天是边学边做,但几个地方仍然没有头绪
这里主要参考的yichen大佬,具体知识点我再研究研究
后记:靶场关了,少部分题目迁移到了靶场,也无法复现了,贴一下各家wp:
yichen的wp:https://mp.weixin.qq.com/s/iy8MbbxXgSyhL5ut60KxgQ;
第一的wp,他做出了全部题目,不过wp貌似没有全放上来:https://icanhack.nl/blog/blockharbor-ctf-2025-writeup/,
某选手的password_change_policy wp:https://dparnishchev.github.io/posts/vcc_2025_password_change_policy/
某选手韩语wp:https://taesam.tistory.com/51
Competition Rules(签到)
To begin this competition and unlock the challenges you must thoroughly read through the rules to find this flag.
By submitting this flag you acknowledge agreement to the rules of this competition.
要开始本次比赛并解锁挑战,您必须仔细阅读规则以找到此标志。
提交此标志即表示您已同意本比赛的规则。
flag在规则的9.2 Sharing Knowledge
While collaboration between players during the competition is prohibited, sharing knowledge and experiences after the event is encouraged. The only exception is the solution to the first challenge: “i-accept-the-rules” which should not be shared at any time.
Blue Team Challenges
TARA Challenge
这个是白送的,给了一个表格,我还以为要做威胁分析打分,谁知道提交就行,测验到了一定分数就给flag,测验全问AI的
BHVicOne:rXaARH0%dL
Red Team Challenges
AutoGraph [RAMN]
Participants are provided with signed firmware files and a diagnostic log file from a “caringcaribou” UDS RDBI dump, related to a secret over-the-air (OTA) firmware portal in development for RAMN’s ECU B and C.
This challenge invites you to delve into intricate vehicle communication data and sophisticated security mechanisms. Can you leverage this information to unlock the ability to sign your own firmware files? Success in this endeavor promises to enable you in learning more about firmware authentication.
Prompt: I downloaded a firmware update for RAMN’s ECU B and C from a secret OTA portal in development. Can you help me sign my own firmware files? I have attached a caringcaribou UDS RDBI dump log file, if that is any help. (Note: flag is secret key in decimal format – not hexadecimal. It is NOT the password for the .zip file).
参赛者将获得已签名的固件文件以及来自“caringcaribou” UDS RDBI 转储的诊断日志文件,这些文件与 RAMN 的 ECU B 和 C 正在开发中的秘密空中固件(OTA)门户相关。
本挑战要求你深入研究复杂的车辆通信数据和先进的安全机制。你能否利用这些信息解锁签名自有固件文件的能力?成功完成此任务将使你进一步了解固件认证。
提示:我从一个正在开发的秘密 OTA 门户下载了 RAMN 的 ECU B 和 C 的固件更新。你能帮我签名自己的固件文件吗?我已附上一个 caringcaribou UDS RDBI 转储日志文件,或许能有所帮助。(注意:flag 为十进制格式的密钥——不是十六进制。它不是.zip 文件的密码。)
附件是challenge4_RDID_dump.log和challenge_signed_firmware_files.zip
我最初没看出来日志是什么,yichen的wp说是 caringcaribou 暴破DID,然后去看了这个工具,以及问了AI,不得不说,再入门一个新行业的时候,各种原理、概念不懂,通过AI学习真的是事半功倍。
为什么是DID暴破流量?
- 看请求ID:
7E3
是诊断请求(Tester → ECU),7EB
是 ECU 回复(ECU → Tester)。这正是典型的 诊断会话 ID 对。统计结果也印证了:7E3
里有 8033 条 SF(len>0)(请求),7EB
里有 8028 条 SF(len>0) + 一些 FF/CF(应答)。 这就是完整的请求-应答对。 - 看请求数据域格式(除掉 PCI):
0x22 DID_H DID_L ...
= ReadDataByIdentifier。在日志里,7E3 的有效载荷几乎都是22 xx xx
,只是 DID 每次都在变。这就是“逐个尝试不同 DID”的特征。 - 看 ECU 的回应,大多数 7EB 应答是
7F 22 31
→ 否定应答(服务 0x22,NRC=0x31:Request Out Of Range)。说明这个 DID 不支持。少数会返回 0x62 DID_H DID_L … → 肯定应答,带数据。这种模式正好符合“暴破 DID”:测试仪在循环遍历 DID,ECU不支持的都返回 0x31,支持的才回 0x62。
在VSEC 2024那篇文章说过DID是存储一些信息的段,通过SID=22的协议去读,所以这里暴破DID就是在遍历所有数据。这里要判断PCI,PCI就是数据域的第一个字节,下面PCI高代表PCI的高四位。
- 单帧:PCI高=0,第一个字节的低四位是数据长度,第二个字节是SID+0x40(0x7F代表否定),第三、四个字节是DID。后面长度对应的字节就是数据
- 首帧:PCI高=1,第一个字节的低四位和第二个字节共同组成总长度,第三个字节是SID+0x40,第四、五个字节是请求帧的DID。后面是数据
- 连续帧:PCI高=2,第一个字节的低四位是表示第几个帧,后面都是数据
这里要提取数据,不仅要通过PCI截取不同长度的数据,还要判断长度是否合规,比如有效单帧的帧长度是大于等于3的,第二字节是0x62。如果数据长度是0也要排除掉。
根据前面的分析,其实能确定CAN ID是7EB的是回复了,不过脚本不需要用到CAN ID,因为是通过长度和PCI判断的。
1import re
2
3file="challenge4_RDID_dump.log"
4
5
6# 解析每行的CAN ID和数据域
7def parse_candump_log(line):
8 m=re.match(r".*can0\s+(\w+)#([0-9A-Fa-f]+)", line)
9 if not m:
10 return None, None
11 can_id = int(m.group(1), 16)
12 data = bytes.fromhex(m.group(2))
13 return can_id, data
14
15# 把每个DID的数据都以DID为键名存到字典里
16def extract_data(file):
17 # 初始化存放结果和连续帧的字典
18 result={}
19 multi_frame={}
20 with open(file) as f:
21 for line in f:
22 # 提取每行的CAN ID,数据域
23 can_id,data=parse_candump_log(line)
24 if data is None or len(data)==0:
25 continue
26 # 提取第一个字节的高4位为PCI
27 pci=data[0] >> 4
28 if pci == 0:
29 # 提取应用数据长度
30 length=data[0] & 0x0F
31 # 这里验证的是ISO-TP层长度,如果实际总长小于写的长度,说明CAN帧残缺
32 if len(data)<1+length:
33 continue
34 # 把ISO-TP层的载荷提取出来,UDS报文就是这个载荷
35 payload=data[1:length+1]
36 # 有效UDS回复帧,必定包含SID、DID,至少三个字节,少于3个字节,说明ISO-TP层写的长度短了,此帧有误,不管它
37 # 要求必须是0x22的肯定响应
38 if len(payload)>=3 and payload[0]==0x62:
39 did=payload[1:3]
40 result[did]=payload[3:]
41
42 elif pci == 1:
43 # 首帧至少要有 PCI + 长度 + 0x62 + DID,5个字节
44 # 而且首帧肯定带满数据,长度一定是8,如果总长小于8,说明此帧残缺
45 if len(data) < 8:
46 continue
47 #传输的数据总长度
48 total_len=((data[0]&0x0F)<<8)|data[1]
49 # 首帧的UDS响应
50 payload=data[2:]
51 if len(payload)>=3 and payload[0]==0x62:
52 did = payload[1:3].hex().upper()
53 multi_frame[did]={
54 "total_len":total_len,
55 "data":payload[3:]
56 }
57
58
59 elif pci == 2:
60 #连续帧必须有PCI和1个数据,所以最少2个字节
61 if len(data) < 2:
62 continue
63 payload = data[1:]
64 for did, mf in multi_frame.items():
65 # 减3是因为首帧提取的总长度包括了SID,DID的三个字节
66 if len(mf["data"]) < mf["total_len"] - 3:
67 mf["data"] += payload
68 if len(mf["data"]) >= mf["total_len"] - 3:
69 result[did] = mf["data"]
70
71 return result
72
73for did,value in extract_data(file).items():
74 try:
75 text = value.decode("ascii")
76 print(f"DID 0x{did}: {text}")
77 except:
78 print(f"DID 0x{did}: {value.hex()}")
我写得比AI好一点,AI居然还求通过连续帧的第一个字节低4位,求连续帧数据长度
运行脚本
可以看到有一个公钥:
1-----BEGIN PUBLIC KEY-----
2MFYwEAYHKoZIzj0CAQYFK4EEAAoDQgAEzWvXZ0MMevWSHMMCzdJKOq2uXr1kg45NS1z0w8ZWc0Lr
3n2qYn0QsXdR3aTXK9kDmPx2fH3wxt9OkhrwHnl+Hsg==
4-----END PUBLIC KEY-----
还有一个SECP256k1
zip密码可以暴破出来:password12345678
里面有俩hex文件,俩hex.sig文件,hex文件问了AI才知道是ARM架构的芯片固件,不过这里没什么用处
Qwen-Coder很强:https://chat.qwen.ai/s/b575892d-d560-46e4-a329-df2c27734fae?fev=0.0.191
两个sig文件前面一样,我windows装了xxd,所以能直接看到
1➜ xxd -p .\ECUB.hex.sig
2cb8f2f4901e5dc0610a562309a0ba238289edd5a6a819a115ca3b2ac802e
3758990b2130178e5aa4501e0da0fa9fa02a82c94f9f137664e43562d040d
468b21262
5
6➜ xxd -p .\ECUC.hex.sig
7cb8f2f4901e5dc0610a562309a0ba238289edd5a6a819a115ca3b2ac802e
87589ab711a35e232b03c5baa9ad2d1f3f74d348381ff31203ab3ce66f598
90de2f945
Powershell里用Format-Hex .\ECUC.hex.sig
也能看
根据前面的SECP256k1
就可以知道这是ECDSA算法了,学习这个请看:https://zhuanlan.zhihu.com/p/97953640ECDSA,ECDSA算法的签名,前一半是R,后一半是S,160-bit q 的情况,每部分是20字节,这题是SECP256k1
,每个部分32字节。
前32个字节(R)一样就是k一样,可以通过 重复k攻击(nonce重用攻击) 恢复私钥,这个攻击是不需要公钥的,这一题给公钥是为了让我们验证用的。比如用私钥签发一个文件,用公钥验证一下。
需要两个参数
- 椭圆曲线参数 (secp256k1)
- 两个消息的哈希值 (z1 和 z2)
这里Qwen3-Coder再次出手,加上我的改造:
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3"""
4ECDSA私钥恢复工具
5通过两个使用相同nonce的签名来恢复私钥
6无需公钥
7"""
8
9import hashlib
10import binascii
11
12def main():
13 # 文件名配置
14 sig_file1 = "challenge_signed_firmware_files\ECUB.hex.sig"
15 sig_file2 = "challenge_signed_firmware_files\ECUC.hex.sig"
16 msg_file1 = "challenge_signed_firmware_files\ECUB.hex"
17 msg_file2 = "challenge_signed_firmware_files\ECUC.hex"
18
19 # secp256k1曲线参数
20 n = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141 # 阶数
21
22 print("=== ECDSA私钥恢复工具 ===")
23 print(f"签名文件1: {sig_file1}")
24 print(f"签名文件2: {sig_file2}")
25 print(f"消息文件1: {msg_file1}")
26 print(f"消息文件2: {msg_file2}")
27 print(f"曲线阶数 n = 0x{n:064x}")
28 print()
29
30 try:
31 # 读取签名文件
32 print("=== 读取签名数据 ===")
33 r1, s1 = read_sig_file(sig_file1)
34 r2, s2 = read_sig_file(sig_file2)
35
36 # 计算消息哈希
37 print("\n=== 计算消息哈希 ===")
38 z1 = get_file_hash(msg_file1)
39 z2 = get_file_hash(msg_file2)
40
41 # 恢复私钥
42 k, private_key = recover_private_key(r1, s1, r2, s2, z1, z2, n)
43
44 # 输出结果
45 print("\n=== 最终结果 ===")
46 print(f"使用的Nonce (十六进制): 0x{k:064x}")
47 print(f"使用的Nonce (十进制): {k}")
48 print(f"恢复的私钥 (十六进制): 0x{private_key:064x}")
49 print(f"恢复的私钥 (十进制): {private_key}")
50
51 except FileNotFoundError as e:
52 print(f"错误: 找不到文件 - {e}")
53 print("请确保以下文件存在:")
54 print(f" - {sig_file1}")
55 print(f" - {sig_file2}")
56 print(f" - {msg_file1}")
57 print(f" - {msg_file2}")
58 except Exception as e:
59 print(f"错误: {e}")
60
61def read_sig_file(filename):
62 """读取sig文件并解析ECDSA签名"""
63 try:
64 with open(filename, 'rb') as f:
65 data = f.read()
66 hex_data = data.hex()
67 print(f"文件 {filename} 内容: {hex_data}")
68 except FileNotFoundError:
69 # 如果文件不存在,直接使用文件名作为hex字符串
70 with open(filename.replace('.sig', ''), 'rb') as f:
71 data = f.read()
72 hex_data = data.hex()
73
74 # 解析签名数据
75 if len(hex_data) >= 128: # 至少64字节
76 # 假设前64字节是r和s
77 r_hex = hex_data[:64]
78 s_hex = hex_data[64:128]
79 r = int(r_hex, 16)
80 s = int(s_hex, 16)
81 print(f" r = 0x{r:064x}")
82 print(f" s = 0x{s:064x}")
83 return r, s
84 else:
85 raise ValueError(f"签名文件 {filename} 数据不足")
86
87def get_file_hash(filename):
88 """计算文件的SHA-256哈希值"""
89 with open(filename, 'rb') as f:
90 content = f.read()
91 hash_value = hashlib.sha256(content).digest()
92 hash_int = int.from_bytes(hash_value, 'big')
93 print(f"文件 {filename} SHA256: {hash_int:064x}")
94 return hash_int
95
96def recover_private_key(r1, s1, r2, s2, z1, z2, n):
97 """通过两个签名恢复私钥"""
98 print("\n=== 私钥恢复计算 ===")
99
100 # 检查是否使用了相同的r值(nonce重用)
101 if r1 != r2:
102 print("警告: 两个签名的r值不同,可能不适用nonce重用攻击")
103 print(f"r1 = 0x{r1:064x}")
104 print(f"r2 = 0x{r2:064x}")
105
106 r = r1 # 使用第一个签名的r值
107
108 # 计算 nonce k = (z1 - z2) / (s1 - s2) mod n
109 s_diff = (s1 - s2) % n
110 z_diff = (z1 - z2) % n
111
112 # 处理负数
113 if s_diff == 0:
114 raise ValueError("s1 == s2,无法计算,请检查数据")
115
116 if s_diff < 0:
117 s_diff = (s_diff + n) % n
118 if z_diff < 0:
119 z_diff = (z_diff + n) % n
120
121 # 计算k
122 k = (z_diff * pow(s_diff, -1, n)) % n
123 print(f"Nonce (k) = 0x{k:064x}")
124
125 # 计算私钥 d = (s1 * k - z1) / r mod n
126 temp = (s1 * k - z1) % n
127 if temp < 0:
128 temp = (temp + n) % n
129
130 d = (temp * pow(r, -1, n)) % n
131 print(f"Private key (d) = 0x{d:064x}")
132
133 # 验证计算
134 d_verify = ((s2 * k - z2) * pow(r, -1, n)) % n
135 print(f"Verification key = 0x{d_verify:064x}")
136 print(f"Keys match: {d == d_verify}")
137
138 return k, d
139
140
141if __name__ == "__main__":
142 main()
答案是:58198666691408413489157977283298548714183388226985257069167369868995344613920
Password Change Policy [RAMN]
This challenge dives into Universal Diagnostic Services (UDS) and firmware reverse engineering. You’ll need to reconstruct a complete firmware image from a raw CAN log file.
The main goal is to identify and understand a new Security Access algorithm embedded within the firmware. This algorithm is common to other automotive security access algorithms, requiring meticulous binary analysis to extract. Success hinges on your UDS knowledge and reverse engineering skills.
Prompt: I updated my RAMN with a new firmware for ECU C, but it seems like the Security Access algorithm has been updated and I can’t unlock it anymore.
ECU C just gave me the seed: 9A5ABF0C1CAAFDEB72761E909501D6E9.
What is the answer to that seed? (Note: flag is 32-character hexadecimal string, all caps).
本挑战涉及通用诊断服务(UDS)和固件逆向工程。你需要从原始的 CAN 日志文件中重建完整的固件镜像。
主要目标是识别并理解嵌入在固件中的新型安全访问算法。这种算法与其他汽车安全访问算法类似,需要进行细致的二进制分析才能提取出来。成功与否取决于你对 UDS 的理解以及你的逆向工程技能。
提示:我用 ECU C 的新固件更新了我的 RAMN,但似乎安全访问算法已经更新了,我现在无法解锁它了。
ECU C 刚刚给了我一个种子:9A5ABF0C1CAAFDEB72761E909501D6E9。
这个种子的答案是什么?(注意:flag 是一个 32 位字符的十六进制字符串,全部大写)。
给了提示,就是提取固件分析,和安全访问算法
不给提示也能知道大致内容(先射箭再画靶子)
0x36服务是数据传输服务,靠他来传输数据,但是需要请求一下,而请求的SID分两种,请求下载0x34,请求上传0x35
但这里面没有#34或#35的字样,也就是只给了传输帧
固件都很大,都是首帧加连续帧的组合,连续帧没什么好说的,第一个字节代表第几个片段,后面都是数据
首帧不一样,一般是前两个字节是PCI和数据总长度,第三个字节是回应的SID,也就是0x36,第四个字节代表块序号,因为连续帧最多传输,0xFFF个字节,这远远不够一个固件的大小。首帧加相应连续帧构成一个帧块。所以第四个字节表示这是第几个个帧块,传输完一个块就加0x01,然后把对应块按序号连接起来构成最终固件。后面四个字节是有效数据
还有一点,CAN ID是7E2,通常是诊断请求的发送方,如果正则搜索7E2#1...36..
就能看到有多少个帧块,还能看到最后的数字在逐个增加,那就是帧块序号
脚本让AI给就行,跟之前的大同小异。
得到了一个bin文件,将其拖入IDA
在函数sub_8006828
中发现主要代码块,我不知道yichen怎么翻到这个函数的,伪代码如下:
1int __fastcall sub_8006828(int a1, _BYTE *a2, unsigned __int16 a3, int a4, _WORD *a5)
2{
3 int result; // r0
4 int v6; // r3
5
6 dword_20032B04 = a4;
7 dword_20032B08 = (int)a5;
8 *a5 = 0;
9 dword_20032B10 = a1;
10 if ( !a3 )
11 {
12 *a2 = 0;
13 return sub_80042BC(a2, 19);
14 }
15 if ( (unsigned __int8)*a2 <= 0xFu )
16 return sub_8003A28(a2, a3, a4, a5);
17 v6 = (unsigned __int8)*a2;
18 if ( v6 == 135 )
19 return sub_8006150(a2, a3);
20 if ( (unsigned __int8)*a2 > 0x87u )
21 return sub_80042BC(a2, 17);
22 if ( v6 == 133 )
23 return sub_8006078(a2, a3);
24 if ( (unsigned __int8)*a2 > 0x85u )
25 return sub_80042BC(a2, 17);
26 if ( (unsigned __int8)*a2 > 0x27u )
27 {
28 if ( v6 == 62 )
29 return sub_8005FF4(a2, a3);
30 return sub_80042BC(a2, 17);
31 }
32 if ( (unsigned __int8)*a2 < 0x10u )
33 return sub_80042BC(a2, 17);
34 switch ( *a2 )
35 {
36 case 0x10:
37 result = sub_8004428(a2, a3);
38 break;
39 case 0x11:
40 result = sub_8004544(a2, a3);
41 break;
42 case 0x14:
43 result = sub_80045B4(a2, a3);
44 break;
45 case 0x27:
46 result = sub_8004DB8(a2, a3);
47 break;
48 default:
49 return sub_80042BC(a2, 17);
50 }
51 return result;
52}
题目提到了安全访问算法,而这个安全访问是0x27服务,我建议阅读:https://blog.csdn.net/huihuige092/article/details/126465150
首先去看case 0x27
:
1int __fastcall sub_8004DB8(int a1, unsigned __int16 a2)
2{
3 int v3; // r3
4
5 if ( a2 <= 1u )
6 return sub_80042BC(a1, 19);
7 v3 = *(_BYTE *)(a1 + 1) & 0x7F;
8 if ( v3 == 1 )
9 return sub_8004BF4(a1, a2);
10 if ( v3 != 2 )
11 return sub_80042BC(a1, 18);
12 if ( a2 != 18 )
13 return sub_80042BC(a1, 19);
14 return sub_8004CD4(a1, a2);
15}
函数都点一点,sub_8004CD4
有一个 16 次的 for 循环,正好对应了种子的字节数:
1int __fastcall sub_8004CD4(int result)
2{
3 _BYTE v1[4]; // [sp+Ch] [bp+Ch] BYREF
4 int i; // [sp+10h] [bp+10h]
5 char v3; // [sp+17h] [bp+17h]
6
7 v1[0] = 103;
8 v1[1] = *(_BYTE *)(result + 1) & 0x7F;
9 if ( (unsigned int)dword_20032B3C > 4 )
10 return sub_80042BC(result, 54);
11 if ( (unsigned int)(dword_20032B10 - dword_20032B38) <= 9 )
12 return sub_80042BC(result, 55);
13 if ( byte_20032B40 != 1 )
14 return sub_80042BC(result, 36);
15 v3 = 1;
16 for ( i = 0; i <= 15; ++i )
17 {
18 if ( *(unsigned __int8 *)(i + 2 + result) != (unsigned __int8)byte_20032B0C[i + 28] )
19 v3 = 0;
20 }
21 if ( v3 )
22 {
23 byte_20032B40 = 2;
24 if ( *(char *)(result + 1) >= 0 )
25 return sub_8004268(v1, 2);
26 }
27 else
28 {
29 byte_20032B40 = 0;
30 ++dword_20032B3C;
31 dword_20032B38 = dword_20032B10;
32 return sub_80042BC(result, 53);
33 }
34 return result;
35}
后面不想写了,去看yichen吧