UDS诊断初探 & polarisctf2026中ez_uds_plus的wp
同学来问我问题的时候提到了ez_uds这道题,我本身没研究过这方面的固件安全,对uds诊断也没啥了解,正好趁这个机会学习一下,也是<del>成功地</del>在比赛结束后的一个小时做了出来。
ez_uds的wp
题目
题目如下:
简单的UDS诊断服务,你也是第一次学吧,那就把算法送给你吧:
def generate_seed():
return random.randint(0, 0xFFFFFFFF)
def calculate_key(seed):
key = seed ^ 0xA5A5A5A5
key = ((key << 3) | (key >> 29)) & 0xFFFFFFFF
key = (key + 0x12345678) & 0xFFFFFFFF
return key
分析
看着一脸懵,直接起容器看一下呢:
提示了:要输入十六进制字节,其中27 01是求取种子,27 02 + 4byte是提交key。
那么我们就发27 01请求一下,得到如:67 01 21 5E CD B1,怎么有六字节?不符合上面seed的0~0xFFFFFFFF范围啊。
查了一下,发现67表示这是安全访问服务(0x27)的肯定响应,标准格式是67 + 子功能(你发送的子功能) + 数据。
所以,后面的四字节才是真正的seed,将其放入calculate_key函数,得到一个数字再转为十六进制:如0x3a0f971c,就可以提交了:27 02 3a 0f 97 1c
第一题直接返回flag了:
ez_uds_plus的wp
题目
开始研究进阶版,题目如下:
小星同学是一名车联网的安全测试员,在做UDS诊断时发现某个ECU的22服务存在漏洞,你可以协助他一起得到你想要的结果吗。
相信已经学会使用UDS诊断的命令了吧,那么接下来该你展现真正的技术了。(level1的算法与EZ_UDS一致)
还有一个附件:
EZ_UDS_PLUS_Attachment.zip
(461.41 KB, 下载次数: 3)
,其中有两个文件:ble.pcapng和can.asc。
基础分析,进入level1
ble.pcapng文件可以猜测是蓝牙ble相关的流量,用wireshark简单查看里面的条目也可以基本验证;can.asc不知道是什么,简单查询可以知道是CAN 总线通信日志文件,具体如何使用一下也看不懂,只知道是交互文件,有许多输入输出的16进制交互。
还是先直接打开容器看看:
有点小错位,问题不大,可以看到除了27服务,多出了许多其他的服务,那我们依旧尝试进行27 01操作,发现返回7F 27 7E。
又是经过查询,知道7F表示否决响应,完整格式是7F + 原请求服务ID + 否定原因码(NRC),这里表示否决27服务,因为当前会话不允许该服务。
那么,我们可以看到,10表示的是SessionControl,于是我们需要通过10服务切换当前session。经过查询可以知道,默认会话是10 01,编程会话是10 02,扩展会话是10 03。尝试10 02,发现返回依旧;再尝试10 02,发现还是返回7F 27 7E!这是什么奇怪的情况?
再经过一番资料的查询,发现原来UDS诊断在会话过程中,只要5秒内没有发送诊断相关信息,就会断开当前会话,退回默认会话,这也是导致没能成功进行27 01的原因。而正常情况下,我们可以使用3E服务对会话状态进行刷新,每隔几秒发送一次3E 80,从而刷新状态,防止回退会话。不过这题3E服务应该是没啥用,也没有大规模的数据处理,为了防止打字过快手跟不上,且是tcp模拟的UDS诊断,我考虑使用pwntools,当然手足够快也行(或许还有其他工具可用,并且可以默认发送3E?有没有佬说说OwO)。pwntools有一个问题,在进入interactive的时候会直接导致无论输入什么内容均返回两个>UDS,无法正常交互(我是菜鸟,依旧不知道是为什么(´。_。`))
那么,经过尝试,发现是10 03的情况下可以使用27 01并返回67,也符合逻辑,因为02会话在常规情况下就比03的权限高,能操作的内容多。
我们根据提示,用上一题的方式构造key得到了这里的key,成功鉴权得到03会话权限。
这时候有点迷茫,不知道应该干什么了,那看看两个还没用上的附件:先看can.asc,感觉会和刚刚进行以及接下来要进行的步骤有点关联。
果然,我们打开asc文件,搜索我们刚刚的步骤:27 01,发现只有一项,而前面跟着的赫然就是我们刚刚完成的步骤10 03:
那么我们当然跟着操作继续走:先发送22 f1 90,收到一串十六进制字节,和asc中的一样(asc中的发送数据那条00 00 00...其实就是保持会话用的而已),这应该就是题目说的22服务的漏洞了,是算越界读取?
得到的内容变成ascii后显示如下:
这个key有16字节,感觉使用场景还是很有一些地方的,有可能就是下一步鉴权需要的key?
不管怎么说,模仿完asc文件末尾的操作,又不知道怎么做了😂。<del>不知道,我的10服务很曼妙</del>,当然是接着提权到02权限啦。
准备进入level2
我们10 02并用27服务提升权限。经过查询我才知道,27服务其实有很多子服务,所有的情况中,奇数代表获取seed,偶数代表提交key(行业惯例是子功能号越大,安全等级越高)。我们这里不清楚具体是什么权限,但是反正最大也就0xFF,浅浅爆破一下:发现只有05的服务会返回seed,那么我们就用05进行提权。
这时候发现刚刚脑抽了,前面得到的key根本用不着seed,怎么可能是这里的key呢。况且key都是4字节的,这个可是有16字节呢。(没错,我也和黄豆✌一样,被前置题目标注的
导致了思维定势,阴了一手😭,后面觉得怪怪的,查了一下才发现原来key没有 ISO 强制固定字节数)
言归正传,前面得到的key肯定不是用在这里,那是用在哪里呢——这还有一个蓝牙的流量包没看呢。
分析蓝牙流量包
这是一个BLE 流量包,我们直接重点关注ATT部分:
可以发现,虽然ATT上面一部分的交互流量没啥特点,但是最下面一大坨都是长度为51的块,同时,简单看一下流量包载荷,发现全是写入操作,其真实的每条内容的有效数据载荷只有16字节:
猜测大概是刷入了什么固件,现在需要得到具体的固件,我们先提取固件内容,然后想办法解析这个固件(依旧没学scapy库,交给ai)。
我们往常见的加密方式去想,加密完得到16字节分组的数据,使用了一个16字节key的加密方式是——AES128或者SM4:
都进行一下尝试,发现SM4-ECB标准版没法解密(我没有iv,后来想想好像默认iv字节全为\x00也行?),手上也没有额外的FK,CK和SBOX,大概就不是了;再尝试AES-ECB,发现成功解密,得到了我看不太懂的内容,丢给ai发现是 AArch64 指令序列,裸 ARM64 代码段(这真的是我可以肉眼看出来的嘛?file,die,ida全认不出,ai还是太强了。有没有佬教教这里应该怎么看?)
那么成功的提取和解密脚本如下:
from scapy.all import rdpcap, Raw
from Crypto.Cipher import AES
pkts = rdpcap('ble.pcapng')
att_data = []
for p in pkts:
if Raw in p:
payload = bytes(p[Raw])
idx = payload.find(b'\x04\x00')
if idx >= 2:
length = int.from_bytes(payload[idx-2:idx], 'little')
att = payload[idx+2:idx+2+length]
if len(att) == length and att.startswith(b'\x52'):
# skip opcode(1) + handle(2)
data = att[3:]
att_data.append(data)
chunks = {}
for data in att_data:
if len(data) == 18:
# 2 bytes offset, 16 bytes payload
offset = int.from_bytes(data[:2], 'little')
chunks[offset] = data[2:]
sorted_offsets = sorted(chunks.keys())
print('Max offset:', max(sorted_offsets) if sorted_offsets else 0, 'Total chunks:', len(chunks))
enc_payload = b''.join([chunks[i] for i in range(max(sorted_offsets) + 1)])
key = b'Polaris_ctf_2026'
cipher = AES.new(key, AES.MODE_ECB)
dec = cipher.decrypt(enc_payload)
with open('out.bin', 'wb') as f:
f.write(dec)
print('Decrypted start:', dec[:32].hex())
分析out.bin文件
ai已经分析出这是arm64指令了,那我们可以用ida指定架构再打开进行分析。
固件中存在 syscall 函数及反调试路径,包括 ptrace 相关流程和环境检测痕迹:/proc/self/status:
如果直接调试,应该会失败。
又注意到关键函数是 sub_1240 ,进行了密钥校验的比较:
其中调用的sub_E70函数更是输入内容,最终产出 16 字节 key(应该就是第二层的鉴权逻辑):
那么,我们可以考虑在绕过反调试的基础上,用unicorn模拟执行逻辑,输入seed,在比较的位置提取出这16字节作为key,从而通过鉴权。(脚本和其他地方的逻辑写在一起了,见下面总exp)
进入第二层,寻找flag
在尝试的时候也是顺利通过了,不过,返回内容中并没有包含本题的flag,那么应该在哪里呢?
回过头去看本题提供的所有服务
10 - DiagnosticSessionControl
14 - ClearDiagnosticInformation
19 - ReadDTCInformation
22 - ReadDataByIdentifier
27 - SecurityAccess
2E - WriteDataByIdentifier
31 - RoutineControl
3E - TesterPresent
可以发现真正没有用到的只有2E和31,2E是写入,不会得到flag,我们尝试31服务——使用31 01 13 37启动服务,直接得到flag:
这里还有出题人最后提示的22 F1 99没搞明白有什么用:返回了62 F1 99 DE AD BE EF,我的确能看出这是被写入过内容了,但这还能说明什么?2E服务也显示无法修改这一块的内存,不太理解。
完整exp(提取固件除外)
其中的各种注释都是我在完成这个exp时候测试使用过的内容。
整个的获取flag的流程可以概括成:
10 03
27 01
27 02 key
10 02
27 05
27 06 key
31 01 13 37
from pwn import *
import struct
from unicorn import *
from unicorn.arm64_const import *
context.log_level = 'info'
context.terminal = ["bash"]
def get_key(seed_val):
"""通过 Unicorn 截获并运算真实的 16 字节算法 Key"""
with open('out.bin', 'rb') as f: CODE = f.read()
mu = Uc(UC_ARCH_ARM64, UC_MODE_ARM)
mu.mem_map(0x0, 2 * 1024 * 1024)
mu.mem_write(0x0, CODE)
mu.mem_map(0x10000000, 2 * 1024 * 1024)
mu.reg_write(UC_ARM64_REG_SP, 0x10000000 + 1024 * 1024)
mu.mem_map(0x20000000, 4096)
mu.mem_map(0x30000000, 4096)
mu.mem_write(0x20000000, b'A'*16)
# UDS seed bytes are network-order in payload. Keep the same byte order in buffer.
mu.mem_write(0x30000000, struct.pack('>I', seed_val))
mu.reg_write(UC_ARM64_REG_X0, 0x20000000)
mu.reg_write(UC_ARM64_REG_X1, 0x30000000)
key_out = []
def hook_code(uc, address, size, user_data):
# 拦截 16 字节比较生成处
if address == 0x12c4:
x20 = uc.reg_read(UC_ARM64_REG_X20)
key_out.append(uc.mem_read(x20, 16))
uc.emu_stop()
return
code = uc.mem_read(address, 4)
instr = int.from_bytes(code, 'little')
# 强势绕过所有反调试检查 (ptrace, getpid 等)
if (instr & 0xfc000000) == 0x94000000:
offset = (instr & 0x03ffffff)
if offset & 0x02000000: offset -= 0x04000000
target = address + (offset * 4)
if target in [0x50, 0x200, 0x2d0, 0x350, 0x30, 0x400]:
uc.reg_write(UC_ARM64_REG_X0, 0)
uc.reg_write(UC_ARM64_REG_PC, address + 4)
return
mu.hook_add(UC_HOOK_CODE, hook_code)
try:
mu.emu_start(0x1240, 0x1304)
except: pass
return key_out[0].hex() if key_out else None
def calculate_key(seed):
key = seed ^ 0xA5A5A5A5
key = ((key << 3) | (key >> 29)) & 0xFFFFFFFF
key = (key + 0x12345678) & 0xFFFFFFFF
return key
p = remote('nc1.ctfplus.cn', 24327)
p.sendlineafter(b'UDS> ', b'10 03')
p.sendlineafter(b'UDS> ', b'27 01')
p.recvuntil(b'67 01 ')
seed = p.recv(11) # 4 bytes seed + newline
print(f"Received seed: {seed.decode().strip()}")
key = calculate_key(int(seed.decode().replace(' ',''), 16))
payload = b'2702' + hex(key)[2:].rjust(8, '0').encode()
print(f'payload: {payload.decode()}')
p.sendlineafter(b'UDS> ', payload)
if p.recv(6).startswith(b'67 02'):
log.success("Key accepted!!!")
# # p.sendlineafter(b'UDS> ', b'3E 80')
# p.sendlineafter(b'UDS> ', b'22 f1 90')
# log.info('22 f1 90 ->' + p.recvline().decode())
# p.sendlineafter(b'UDS> ', b'19 01 ff')
# log.info('19 01 ff ->' + p.recvline().decode())
# # for i in range(0xff):
# # str = '''22 F1''' + hex(i)[2:].rjust(2, '0')
# # p.sendlineafter(b'UDS> ', str.encode())
# # log.info(p.recvline().decode())
# bb = b"19 02 FF"
# p.sendlineafter(b'UDS> ', bb)
# log.info(bb.decode() + ' -> ' + p.recvline().decode())
# # bb = b"19 06 01 00 00 ff" # 读取DTC 0x123456的所有的扩展数据
# # p.sendlineafter(b'UDS> ', bb)
# # log.info(bb.decode() + ' -> ' + p.recvline().decode())
# # bb = b"19 06 03 00 00 ff" # 读取DTC 0x123456的所有的扩展数据
# # p.sendlineafter(b'UDS> ', bb)
# # log.info(bb.decode() + ' -> ' + p.recvline().decode())
# # bb = b"19 06 C0 35 00 ff" # 读取DTC 0x123456的所有的扩展数据
# # p.sendlineafter(b'UDS> ', bb)
# # log.info(bb.decode() + ' -> ' + p.recvline().decode())
# # bb = b"19 04 01 00 00 02" # 读取DTC 0x123456的编号为02的快照数据
# # p.sendlineafter(b'UDS> ', bb)
# # log.info(bb.decode() + ' -> ' + p.recvline().decode())
# # bb = b"19 04 12 34 56 02" # 读取DTC 0x123456的编号为02的快照数据
# # p.sendlineafter(b'UDS> ', bb)
# # log.info(bb.decode() + ' -> ' + p.recvline().decode())
# # bb = b"19 0a"
# # p.sendlineafter(b'UDS> ', bb)
# # log.info(bb.decode() + ' -> ' + p.recvline().decode())
bb = b"10 02"
p.sendlineafter(b'UDS> ', bb)
log.info(bb.decode() + ' -> ' + p.recvline().decode())
# for i in range(3, 0xff, 2):
# str = '''27''' + hex(i)[2:].rjust(2, '0')
# p.sendlineafter(b'UDS> ', str.encode())
# log.info(str + ' -> ' + p.recvline().decode())
bb = b"27 05"
p.sendlineafter(b'UDS> ', bb)
res = p.recvline().decode()
log.info(bb.decode() + ' -> ' + res)
parts = res.strip().split()
if len(parts) < 6 or parts[0] != '67' or parts[1] != '05':
log.error('No level-2 seed available on this instance (expecting 67 05 .. .. .. ..).')
p.interactive()
exit(0)
seed_hex = "".join(parts[2:6])
seed_val = int(seed_hex, 16)
log.info(f" Parsed Target Seed: {hex(seed_val)}")
key_hex = get_key(seed_val)
if key_hex is None:
log.error("Failed to get key from Unicorn!")
exit(1)
log.success(f"[+] Unicorn Output 16-bytes Key: {key_hex}")
key_parts = [key_hex[i:i+2].upper() for i in range(0, 32, 2)]
bb = ("27 06 " + " ".join(key_parts)).encode()
p.sendlineafter(b'UDS> ', bb)
res = p.recvline().decode()
log.info(bb.decode() + ' -> ' + res)
bb = b"22 F1 99"
p.sendlineafter(b'UDS> ', bb)
res = p.recvline().decode()
log.info(bb.decode() + ' -> ' + res)
bb = b"2E F1 99 00 00 00 00"
p.sendlineafter(b'UDS> ', bb)
res = p.recvline().decode()
log.info(bb.decode() + ' -> ' + res)
# bb = b"22 F1 99"
# p.sendlineafter(b'UDS> ', bb)
# res = p.recvline().decode()
# log.info(bb.decode() + ' -> ' + res)
bb = b"31 01 13 37"
p.sendlineafter(b'UDS> ', bb)
res = p.recvline().decode()
log.info(bb.decode() + ' -> ' + res)
p.interactive()
感悟
这次也是简单了解了一下UDS诊断的逆向和一些服务,下次再见就不会过于迷茫了。
参考资料
最后放点查找UDS诊断过程中的参考资料以及本文还未写完的时候见到的黄豆佬的wp:
https://mp.weixin.qq.com/s/CX7YirKDIGFmWl0RFCZWTg?scene=1
https://www.zhihu.com/column/c_1717607955724083200
UDSCTF:UDS 协议安全挑战 | CN-SEC 中文网
https://zhuanlan.zhihu.com/p/2011747422565589523