
电动滑板车逆向实战:嗅探 CAN 总线、Ghidra 拆固件,用 Rust 重写显示屏程序
一位开发者拆开自己的电动滑板车做逆向:从发现 USB-C 口其实是 CAN 总线,到用 Ghidra 还原通信协议、搞清一个免鉴权的固件更新流程,最后给只有 32KB 内存的显示屏 MCU 写了一套 Rust 固件。
原文来源:bensimms.moe — 作者逆向自己的 Egret GT 电动滑板车:嗅探 CAN 总线、用 OpenOCD 和 Ghidra 拆出固件、还原免鉴权的升级协议,最后给显示屏 MCU 写了一套 Rust 固件。
这篇记录的目标很简单:搞清楚一台 Egret GT 电动滑板车是怎么工作的,然后给它写一套自己的显示屏固件。它也是一份相当完整的嵌入式逆向教程——从物理接口的发现,到协议解码,到固件提取,再到在只有 32KB 内存的芯片上重写一整套 UI。
起因:一次不用 PIN 的启动
作者买这台车是因为它标称 100 公里续航、轮胎很大,骑着舒服。为了证明自己是高端货,它带一块 320x480 的 LCD 当仪表盘,显示速度、驾驶模式、电量和续航。
动手的理由有点偶然:按住手把上的"下"键再开机,车子会进入固件升级模式;在这个菜单里按一下退出,就直接进入正常骑行模式——不需要输 PIN。 车锁再好,这个绕过方式还是让人不舒服。
—— 广告 ——
从手机 App 入手
第一步是看官方 App 的蓝牙协议,作者形容这部分枯燥但有收获:
- 车可以通过蓝牙升级固件,而且看起来有几个不同的目标位置(显示屏、控制器、按键面板);
- App 和车上都不显示的指标其实一直在蓝牙里传:各驾驶模式的时长、设备温度、电机电流、电池电压、充电历史;
- 总驾驶时长、里程、充电记录这些数据会传到厂商那里,挂在这台车的 ID 下保存——App 里并没有清楚说明这一点;
- 车辆的 VIN 不是出厂就有的,而是 App 连上以后写进去的。用一个蓝牙调试工具自己写 VIN,就能让官方 App 以为这是另一款车型。作者试过冒充 45km/h 版本的车,看看限速是不是靠这个简单判断做的——不是。
蓝牙玩腻之后,他把注意力转到了显示屏上的 USB-C 口。厂商说这个口只能给手机充电,用几台设备测下来也确实像:只要数据线接通,显示屏既不当主机也不当从机。但作者还是买了一块 USB-C 转接板,插上以后用示波器逐个引脚探——然后就发现,其中两个引脚被当成了 CAN 总线。作者认为这个做法明显不合规。
搭一个 CAN 报文记录器
要听 CAN 总线上的对话,他临时拼了一个装置:ESP32-C6、SN65HVD230 和 MCP2515。用两个收发器是有原因的——SN65HVD230 配合 ESP32 的 CAN 外设收报文没问题,但发送会出总线错误,后来加的 MCP2515 能发。两个都留着,是因为 SN65HVD230 在所用的 Rust 库里有异步接口,把收报文写进状态机很方便。
有了记录器,他写了个小程序初始化 CAN 外设、记录每一条报文,然后插到车上录下开机过程。CAN 总线的数据很吵,于是他顺手用 egui 写了个可视化工具:横轴是时间,纵轴是报文 ID,每条报文画成一个点。这么一画,哪些是命令、哪些是响应、哪些是周期性数据,一眼就能分出来。
再配合实际骑行时的抓取,几条关键报文很快就被认出来了:
0x300:显示屏发给控制器。载荷里有当前驾驶模式(walk、eco、drive、sport)、大灯开关,walk 模式下载荷最后一个半字节还是个计数器。有意思的是,把第四字节从通常的5a改成a5,控制器会重置。0x306:同样是显示屏发控制器的。里面有油门位置、转向灯和车辆限速。限速对标准 GT 控制器没用,但在 GTS 上会把限速设成 25、35 或 45km/h。不知为何,油门被编成了一个9 位无符号整数,最高位放在第二字节的第一位上。0x201:包含电机转速和一些状态标志。
到这里作者已经能远程控制车子的电机了,但他自己觉得这不实用也不有趣。项目卡住了:拿不到固件,就没法再往下走。过了一阵子,他发现网上能买到替换的控制器和显示屏,立刻下单——这就有了后面的拆机。
拆控制器,取出固件
控制器是最难拆的一个:后盖的十字螺丝锁得很紧,两颗当场滑丝,只能拿电磨切出一字槽。外壳里灌了封装胶,好在胶很软,刮掉就行。
刮完胶有一个意外之喜:所有有源器件的丝印都没被磨掉,板子背面还留着一排四个焊盘。MCU 上标着 APM32E103xCxE(STM32F103 的国产克隆),那四个焊盘基本可以断定是 SWD 口。用 OpenOCD 就能在启动后不久把 flash 和 RAM 内容 dump 出来。
拿到固件后,作者给出了用 Ghidra 分析这类裸机固件的完整步骤,值得抄作业:
- 导入 flash 镜像,语言选 ARM Cortex little(默认),在选项里把基址设为
0x8000000; - 打开代码浏览器,先跳过自动分析;
- 用 File → Add to program 把 RAM 镜像也加进来,基址设为
0x20000000; - 用 SVD loader 插件加载这颗 MCU 的 SVD 文件——这一步很关键,加载后能清楚看到外设(GPIO、CAN 等)在哪里被用到;
- 现在再跑分析,但不要开 aggressive instruction finder,它会误判大量数据区里的函数;
- 跳到
0x8000004,这里是复位函数的指针(也就是 main),跟过去、反汇编、建函数; - 从复位入口开始翻。前面通常是大量 HAL 样板代码(时钟初始化、把静态变量加载进 RAM),也有 Ghidra 认不出的 noreturn 函数,会让反编译结果在多个位置重复出现。唯一的建议就是一路点过去,直到看到像应用代码的东西——应用代码一般从初始化外设开始,看到 GPIO、UART、CAN 就说明找对地方了。
还有个细节:0x8000000 开始的代码可能是 bootloader。如果 Ghidra 说这个函数修改了栈指针,那它多半就是"加载"函数——设置栈指针然后跳到复位处理程序。顺着它取栈指针和复位函数的地址找过去,一般就能找到主应用的向量表。
另外一个小插曲挺有意思:应用固件的长度存在 0x8006000,但不是 4 字节或 8 字节的整数,而是这个数字的 ASCII 十进制字符串。更离谱的是,从长度之后一直到 0x80061ff 全用 ASCII 空格填充,再以 \r\n 结尾。
显示屏:更难拆,料也更多
显示屏比控制器费劲得多。外壳是 2mm 厚的注塑件,挺结实,只能上电磨切背面。作者原本以为前面的屏幕盖是热焊上去的,还沿着边缘切了一圈,结果切开一个口子有了着力点以后直接撬了下来——只是粘住的。
拆开后有两组调试接口:一组是主 MCU 的 SWD,另一组直通 SPI flash。除了 dump 固件,还摸清了这套硬件的构成:
- 固件里有 bootloader 和位于
0x8008000的主应用; - 图形界面用 SEGGER EMWin 绘制;
- 蓝牙 MCU 通过 GPIOA 2 和 3 用 UART 通信(配置里写的 57500,看着像笔误)。读蓝牙属性时,CH573 发一条带编号的请求,主 MCU 用同样的编号回一条响应;
- NFC 模块也走 UART,协议略有不同,作者没有深挖;
- 手把上的按键面板同样走 UART,只发一个按下状态的位域。有意思的是转向灯闪烁是按键面板自己在管的,它能自己驱动灯闪,报文里用两位报告当前的闪烁状态,而且它看起来也能接收固件更新;
- CAN 接在 GPIOA 11 和 12;
- 屏幕是 ST7796 控制器,走并口:GPIOB 全部 16 个引脚当并行数据总线,这样固件用一条指令就能更新全部引脚状态;
- ADC 读三个通道:环境光、油门电压和电池电压。固件只拿电池电压来触发低电量报错,其他电量用途读的是 CAN 总线上电池发来的一个变量(对,电池自己也挂在总线上)。
显示屏固件的逆向一开始很痛苦:作者当时用的 Ghidra 版本有个 bug,函数指针因为低位置位(表示用 THUMB 指令)而没有被正确标记在数据区里,而固件又是围绕回调表组织的(CAN、蓝牙、GUI 界面各一套),导致大量函数找不到调用者。运气不错的是,他撞见了扫描 CAN 处理表的那段代码,于是搞清了表结构——每个表项指定要匹配的 ID,还可以带一个周期,以及收发回调。有了这个,就能快速把观察到的每条 CAN 报文对应到代码上。
通过交叉比对显示屏和控制器的固件,CAN 报文的含义基本补全了。唯一没做的是 Apple Find My 相关的那几条——作者没有 iPhone,他用 openhaystack 自己做了个定位器,额外好处是每 30 分钟轮换一次身份,不会触发"有追踪器跟着你"的提示。
至于外设配置,因为固件用的是厂商提供的外设库、编译时也没开 LTO,反编译出来的 HAL 函数和源码几乎一模一样,对着源码就能把 GPIO 和外设配置全对上。
固件更新协议:没鉴权,也没加密
能拆机用调试探头刷固件是一回事,让固件能装进一台正常的车是另一回事——所以还得逆向升级流程。
好消息是流程简单到令人愉快:整个升级过程没有任何密码学,主体逻辑就是 bootloader 里的一个函数。
升级从 CAN ID 0x384 的一条报文开始。更新方发一条初始化消息,随后设备重启,并擦掉存放 VIN 和车辆配置的 flash 区域。之后更新方持续发消息,直到 bootloader 启动、看到初始化消息并以一条应答回应。接着数据以 64 字节一块的形式,分散在 9 帧 0x384 报文里发过去,每帧带一个 CRC(CRC-16-CCITT,覆盖该帧数据,数据先补零到 64 字节),还有一个从 0 开始、到 0xFF 后回绕的序号。
第一块不是固件的前 64 字节,而是更新文件名(例如 AT_R2_JHZY_GT1_GE_FM_HW02_4.0.2)的 null 结尾字符串,后面跟着固件长度的十进制字符串(同样 null 结尾)。bootloader 对第一块回四条应答,之后每块回一条。全部传完后,更新方再发一条结束消息,显示屏重启。
这套机制的后果很直接:任何人不需要鉴权就能改这台车的固件;而且更新是直接覆盖 flash 里的应用镜像,刷失败就会变砖。唯一的安全垫是 bootloader 在每次上电时都会检查有没有升级包,所以就算应用代码已经跑不起来,也还能重新开始升级。
作者用之前从硬件上 dump 下来的镜像做了一次实测,一次成功。
开始写 Rust 固件
显示屏的主控是 AT32F415,又是一颗 STM 克隆。麻烦在于它不是某一颗 STM 芯片的克隆,而是把 STM32 的各种外设拼在了一起——大部分和 STM32F1 对得上,但 RTC 看起来来自 STM32F3。这意味着没法直接套 Embassy 开写,得先自己搭一套 HAL。
他借用了 Kossnikita 基于 stm32-rs fork 的成果。作者很诚实地承认自己"基本是抄":大部分外设的实现直接拿 Embassy 的来,对照着 STM32F1 和 AT32F415 两份数据手册,把寄存器名改成 AT32 的叫法。他也提到更正规的做法是把这颗芯片加成 stm32-metapac(Embassy 的子项目,能处理 SVD 文件生成 PAC crate)的一个条目,只是他一开始把 AT32 想得过于不一样了。
点亮外设的顺序是:时钟和定时器先来(有了定时器就能接上 embassy-time-driver),然后是 ADC、外部 GPIO 中断、UART、CAN、RTC。驱动齐了,才谈得上驱动屏幕、读 ADC、在 CAN 和 UART 上说话。
屏幕这块很顺利:直接用 mipidsi crate 做驱动,唯一要自己写的是一个并口接口实现,让 HAL 能一次把 u16 写到 GPIO 上:
pub struct Bus<const P: char, const SHIFT: u8, const MASK: u16, MODE = DefaultMode>;
impl<const P: char, const SHIFT: u8, const MASK: u16, MODE> Bus<P, SHIFT, MASK, MODE> {
fn _set_state(&mut self, state: u16) {
(*Gpio::<P>::ptr()).odt().modify(|r, w| {
let prev = if const { MASK & 0xFFFF != 0xFFFF } { r.bits() & !(MASK as u32) } else { 0 };
let new = ((state << SHIFT) & MASK) as u32;
unsafe { w.bits(prev | new) }
});
}
}引脚于是可以用 Rust 类型声明出来:并口总线是 Bus<'B', 0, 0xFFFF>,片选、数据/命令、读、写、复位分别落在 PC13、PC14、PC0、PC15、PC1 上,背光挂在 TMR2 的 PWM 通道上。
协议编解码用 deku,它用宏就能声明字节级、位级的解析器。比如油门那个 9 位整数的古怪排布、刹车灯这种单比特字段,都能直接写在结构体上,而且这些定义还能拿到另一个程序里,把 CAN 日志解码成人能读的内容——作者顺手展示了同一段日志解码前后的对比。
之后是状态机。用 Embassy 的 actor 模型来处理收到的消息(CAN 总线来的、蓝牙 MCU 来的,以及内部定义的按键、UI 事件、ADC 读数),比共享全局变量清爽得多。最终落到一张图上的,是若干任务和资源:ADC 任务周期性读数,把结果发到 channel 上让其他任务订阅;还有一个配置存储 worker 负责写入变更项,带冷却机制,避免频繁改动反复擦写 flash。
界面也要好看
作者自认为最"新"的一段是 HUD 界面。C 项目里有 LVGL、SEGGER EMWin 这些选择;Rust 里 GUI 库也不少(egui、slint、gpui),其中有些也面向嵌入式,但它们要么依赖 std,要么依赖分配器,要么需要 framebuffer——在只有 32KB 内存的 MCU 上全都不行。
他偶然找到 Buoyant:一个 Rust 库,提供类似 SwiftUI 的界面写法,同时不需要 framebuffer、不需要内存分配、不依赖标准库,还自带焦点和键盘导航支持——这台车没有触摸屏,正好需要。
用 Buoyant 写的 PIN 输入界面代码很干净:Lens 把四个数字位绑定到状态上,上/下/确认键映射成焦点动作,选中态用圆角矩形描边表现。主页用 flexbox 布局拼出来——作者说对固定尺寸屏幕上的静态内容来说有点杀鸡用牛刀,但省掉了手动摆位置。
Buoyant 也带来一个问题:32KB 内存放不下 framebuffer,所以每画一帧都要把所有组件重新画一遍——带文字的像素会被画三次(背景、盒子、文字),还得往屏幕发大量重定位命令,结果就是闪得完全没法用。
解法是只重画发生变化的组件。作者一开始想的朴素办法是维护一个包围矩形,但屏幕上两端同时更新时就不管用了。他的方案是用四叉树记录脏区域,而且记两棵:一棵记"脏"的区域,一棵记"被覆盖"的区域。组件在属性变化、或检查子节点前自己的包围盒与任一棵树相交、或检查子节点后与脏树相交时,被标记为已变化;变化时旧包围盒进脏树,新包围盒进覆盖树;往覆盖树里加矩形时,包含在其中的矩形会从脏树里移除。于是秒数变化时,只有那一小块区域重画,不会整屏闪。
剩下的唯一大缺点是类型:Buoyant 虽然尽量少用泛型,但每个 stack 节点仍然被所有子节点的类型参数化,展开之后的类型签名会长到这种程度——作者贴出来的那一大段 buoyant::view::... 嵌套类型,直接证明了这一点。
看完之后,值得记住的几件事
这篇记录好读,但它顺手暴露出来的问题不太好看。
一是一台车的固件更新可以完全没有鉴权,也不带任何签名。任何人都能改它的固件,而流程本身又简单到可以靠嗅探总线还原。对于把安全寄托在"用户不知道"上的产品,这是一次很干净的示范:接口藏得再好,硬件一通探针就没了。
二是隐藏的数据采集。App 和车机上都不显示的指标一直在传,里程、总驾驶时长、充电历史被关联到车辆 ID 存在厂商那里,而 App 里没写清楚。这个做法在合规上本身就有争议。
三是对想自己动手的人:作者走的这条路——从最开放的接口(App 的蓝牙)开始,再往物理层(USB-C 引脚、CAN)走,最后到固件(SWD + Ghidra)——顺序很值得借鉴。每一层都让下一层更容易,而且拆坏的东西是"买来替换的配件",不是自己那台车。
如果你手里也有一台"看起来很封闭"的设备,这套思路基本上可以照搬:先找出它必须对外说话的那个接口。
© 2026 四月
原文链接:https://www.aprilzz.com/tutorials/escooter-firmware-rust-rewrite
相关文章
从 2500 亿条缓存里抠出 100TB 内存:Cloudflare 的五个内存优化手法,每个都能直接抄
Cloudflare 对 1.1.1.1 的 DNS 缓存做了 5 个存储层优化,每条目占用从 953 字节降到 420 字节,整个机群省下约 100TB 内存,缓存反而更快了。本文逐条拆解这些可复用的 Rust 内存优化技术。
微软画图'隐形水印'逆向实录:你以为本地生成的 AI 图片,像素里藏着一个服务器下发的 GUID
安全研究员逆向 Windows 自带画图和照片应用发现:即使图片在本地 NPU 生成,你的提示词也会发到微软服务器审核,返回的 GUID 会作为隐形水印嵌入图片像素。附完整检测原理和隐私含义。
把可执行文件改成 SQLite 数据库:用 SQL 查询 ELF 的一切,程序照常运行
SELF 格式用 SQLite 数据库替代 ELF 作为可执行文件:file 命令显示它是 SQLite 数据库,./hello 照样输出 Hello world,而 ldd、nm、readelf、strip 全部退化成一条 SQL 查询。附完整原理和实操命令。