吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 862|回复: 2
上一主题 下一主题
收起左侧

[CTF] UDS诊断初探 & PolarisCTF中ez_uds_plus的wp

[复制链接]
跳转到指定楼层
楼主
milkdragon 发表于 2026-3-31 01:10 回帖奖励
本帖最后由 milkdragon 于 2026-3-31 01:18 编辑

UDS诊断初探 & polarisctf2026ez_uds_pluswp

同学来问我问题的时候提到了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,怎么有六字节?不符合上面seed0~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.pcapngcan.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 代码段(这真的是我可以肉眼看出来的嘛?filedieida全认不出,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

可以发现真正没有用到的只有2E312E是写入,不会得到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

免费评分

参与人数 1威望 +1 吾爱币 +20 热心值 +1 收起 理由
Hmily + 1 + 20 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!

查看全部评分

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

沙发
geesehoward 发表于 2026-4-3 09:30
从官方群里的wp看,得到的.bin文件中,最核心的函数是0x0e20,这个算了一个hash值,key是Polaris_ctf_2026,得到hash值0x8391156A,发送2E F1 98 83 91 15 6A,在发送22 F1 99,最后通过31 01 13 37得到flag
3#
abcde1224 发表于 2026-4-3 15:44
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-4-5 15:02

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表