最近在捣鼓新买的 ESP32
玩具。在翻家里的电子垃圾堆能当外设的组件时想起了这块 GeekPwn 2015 的胸牌。
(附图:实现在屏幕上打印任意字符的效果,解开了会场广播消息的机制谜题)
作为第一届嘉年华式的 GeekPwn (总的来说是第二届,但第一届是个纯挑战赛的闭门活动),当年这块胸牌比较新鲜的东西。 拿回家就只是个只会循环播放图片的电子画框,但在会场不仅能同步显示现场广播(比如恭喜选手挑战成功的消息),还留了一个挑战题目,通过胸牌能攻入某个挑战服务器,从而获得一些奖励。
只可惜当年参会的时候还是个普通大学生,行程仓促也来不及仔细研究内中玄机。回来之后胸牌一扔就被遗忘在了角落里,就这么静静地躺了那么多年。
翻它出来的时候一拨开关,竟然还有电! 热泪盈眶的同时也不免产生了好奇心 —— 所以它到底是怎么玩的,在玩什么呢?
全网能找到的唯一一篇关于 GP2015 胸牌的分析文章止步在了「抛砖引玉」。不过它指的路节省了我大量前期试探的时间。
*比如我的胸牌屏幕已经不亮了,那篇文章的附图让我迅速排除了软件猜想,很快确定是屏幕接地不良导致的(飞了根线解决)
参考链接就不贴了,google 还能找到若干副本。
该文最大的成果是贴出了胸卡侧边引脚孔的定义和 MCU 模块型号,我只需要验证一下就够了。用万用表寻找通路可以得到如下定义:
另外 MCU 的 TX / RX 与屏幕底板上的互连,两者通过串口传输的指令来进行通信。
*顺带一提屏幕开关控制的是 MCU 到屏幕的 TX 线,断开它屏幕收不到控制指令,就不会切换画面。
由于我原本在捣鼓的是 ESP32
,开发板自带了 USB 串口转换,并不需要 、我也没有USB-串口转换线。因此考虑使用 ESP32
充当中转器来连接这块 GP2015 胸牌。
感恩于 ESP32
的先进性,它的 UART 可以自由映射到任何引脚上,且可以同时使用 3 组 UART 工作在各自的模式和波特率上。这样一来我只需要随便定义一下连线,再写点简单代码互相转发两组 UART 上的数据就能无缝读写胸牌上的串口了。
线连好后大概是这个样子,加了点 LED 来指示 TX / RX 的工作情况。可以观察到 RX 灯亮后瞬间屏幕开始切换图片
TX / RX 分别接到了 GPIO 40
/ 41
,对端设备两线对调,所以需要把 40
设置为 RX 脚,41
设置为 TX。与此同时对调两根线能「劫持」来自屏幕的通信,不过其实并无必要,要调试屏幕设备可以通过屏幕底板上自带的引出引脚来连接。
- Arduino 统治一切!
想起在学校时半途而废的
msp430
和更早玩过的c51
, 手搓寄存器控制指令真的太痛苦了,那时候连能用上 c++ 都难以奢求,哪曾想过写单片机程序能像写 Java 一样通用轻松。
虽然我的 ESP32
项目配置成了 ESP-IDF + Arduino
, 但其实 vanilla 的 Arduino 就已经完全够用了。核心逻辑无非是初始化两个 Serial
对象然后互相写入:
这里用到一个协程库来并发任务,用 ESP-IDF
提供的 FreeRTOS API 创建新线程也是可以的。但 IDF
框架就太繁琐了,不够优雅。
上电 / Reset 后可以从串口监视器看到打印的 banner 信息和一些奇怪的 PIC(0,0,N);
字串:
从这个 banner 可以得知板上程序采用的是 NodeMCU
框架,这是一个用 lua 来写主要逻辑的系统,在串口就可以直接通过 lua 的 interpreter 控制板子[1]的行为。 比如简单地 print
一下:
观察到 PIC(...
消息是由 MCU 发出的,且每发出一次屏幕就会切换图片,于是模仿该消息,让板子打印 PIC(0,0,N);
就能手动切换了:
另外 NodeMCU
提供了完善的 API 文档,但其实相当一部分都不可用,这块老古董的版本实在太旧了。好在 GitHub 上还有当年版本的历史快照,虽然得翻半天,不过总归是有得对照了。
到以上为止,全网能找到的唯一一篇相关文章透露的全部信息就完结了,接下来完全是未知的探索。
我注意到的第一个现象是板子每隔一段时间就会重启,所以不把原程序 dump 下来并想办法停止原来的循环是没法进一步分析的。
NodeMCU
的 API 里提供了 file
模块可供读写文件:
我在第一次尝试下载原程序的时候是直接通过串口循环打印 chunk 内容并拼接完成的;但其实这个办法并不好,有两个原因:
PIC(
指令相互混杂。如果按照我初次尝试的办法,必须趁着 PIC
指令刚过去,迅速 print
一两个 chunk, 等待下一个 PIC
指令出现再迅速 print
,还得注意重启的时间,最好手动 Reset 完立马发出 print
指令。
不过我在后来尝试写回新脚本的时候发现这种串口手动的方式完全不可行,于是又研究出了通过 wifi 网络的互传方式,要稳定快速得多。
由于 NodeMCU
官方文档已经非常详尽,这段就不详述了。总的思路就是让 8266
切换到 AP 工作模式,让电脑连入 wlan 再通过 socket 建立连接:
注意在这个旧版系统上没有 http
模块,socket
/ net
模块的 API 也与新版不同。
然后电脑连入 GEEKPWN
网络,用 nc
监听一个端口:
发起连接,并发送首个 chunk, 如果成功,板子会立即传送 init.lua
文件的剩余内容:
值得一提的是切换到 AP 模式并重启后 wifi 协议栈占用的 RAM 会增加,于是 lua runtime 可能会报 not enough memory
的错误然后停住。 手动切换到 STATION 模式然后使用 dofile()
函数重新运行或许可以恢复:
dump 下来的脚本是 minify 过的,重新格式化后的脚本内容全文放在了 Gist 上。
有点意思的是,我测试了如果还原成 human readable 缩进再放回去将不够内存运行,甚至 SSID 等静态字符串太长重新 minify 后都无法运行。可见原来的设计者程序写得也挺极限的。
这段代码是一段Lua脚本,看起来是为某个嵌入式设备(可能是基于NodeMCU或类似平台)编写的。以下是主要逻辑和各个函数的功能:
全局变量定义:
gserverip
: 服务器IP地址。gnetwork
: 设备连接的WiFi网络名称。gnetkey
: WiFi网络的密码。gdebugmode
: 调试模式,当为1时,输出调试信息。- 其他一些全局变量,用于配置网络、服务器、重连等参数。
函数
p(m)
:
- 用于打印信息到控制台。
函数
lmsg(msg)
:
- 如果调试模式开启,则在信息前添加时间戳并调用
p
函数输出信息。函数
createNewSocket()
:
- 创建一个TCP连接的socket,并设置接收、连接、断开连接的回调函数。
- 用于与服务器进行通信。
函数
showWords(words)
:
- 根据传入的字符串,解析其中的三段文本(以
#
分隔),并输出对应的显示命令。函数
dofs()
:
- 在接收到特定命令后,定时刷新显示或隐藏文字。
函数
doReconnect()
:
- 在连接断开时,定时尝试重新连接服务器,同时检测WiFi连接状态。
函数
goto01()
:
- 将命令设置为 "01",用于设备状态的切换。
函数
tmrf()
:
- 定时器回调函数,用于定时执行一些任务,如心跳检测、WiFi连接等。
WiFi配置及定时器设置:
- 设置WiFi工作模式为
STATION
。- 配置WiFi连接信息。
- 定时器1用于定时执行
tmrf
函数。- 定时器2用于定时执行
doReconnect
函数。总体来说,这段代码主要完成了与服务器的TCP通信、处理接收到的命令、定时执行任务等功能,其中涉及到了一些图形显示的控制和与服务器的通信协议。
主要的功能都在 createNewSocket()
里,让 GPT 帮忙分割一下各个回调的内容:
以下是
createNewSocket
函数中的回调函数的提取:
"receive" 回调函数:
"connection" 回调函数:
"disconnection" 回调函数:
下面是 "receive" 回调函数的逻辑分析:
接收并打印信息:
lmsg(c)
: 输出接收到的字符串信息。心跳计数重置:
ghb = ghblimit
: 将心跳计数重置为上限值。长度检查:
- 如果接收到的字符串长度小于2,则直接返回,不进行后续处理。
提取命令:
cmd = c:sub(1, 2)
: 从接收到的字符串中提取前两个字符作为命令。命令校验:
- 如果命令不在指定的合法命令列表中("00", "01", "02", "03", "04"),或者在特定情况下长度不符合要求,则输出错误信息并返回。
处理各种命令:
- 根据提取到的命令执行相应的操作。
- 如果是 "01" 命令,则调用
goto01()
函数。- 如果是 "02" 命令,则提取额外信息,打印对应的显示命令。
- 如果是 "03" 命令,则进行一系列初始化操作,并根据额外信息执行文字显示任务。
- 如果是 "04" 命令,则打印额外信息。
- 如果是 "00" 命令,则清屏。
特殊命令处理:
- 如果是 "05" 命令,则重新启动设备(
node.restart()
)。总体来说,该回调函数主要是对接收到的命令进行解析和处理,包括命令的校验、特殊命令的执行以及一些状态的更新。
DSxx
命令打印消息。关于这些奇怪指令的完整列表可以 百度 (这个型号是国产的,必须搜中文资料) 「USART GPU 显示屏 / gpumaker」 得到。看起来有点遗憾,毕竟下发指令的服务器已经不在了,我们永远无法得知那个「神秘挑战」究竟是什么。也许可以想象 hello message 存在一个溢出漏洞?或者是挑战本身指的就是胸牌接受命令的程序本身? Anyway 我们终于得知这块牌子全部玄机,之后的交给想象力了。
分析完原程序之后当然不能止步于此,至少得 hack 一点自己的东西,这才称得上尊重。 我准备给它新加一个「后门」,当周围搜索到指定名字的 wifi 网络时显示自己的图片和文字。
这里我们先创建一个新文件,增加发现特定网络就切换图片并写字的「后门」:
上传新代码可以用与下载相似的办法,用 nc
把新程序通过网络发回去:
然后再修改原初始化脚本,引用新加的「后门」:
打开手机热点,就可以看到「被黑」的效果了:
从前面的程序逻辑可以得知屏幕播放的图片是其内置 flash 存储的预设,所以想要修改显示图,必须重新刷写 屏幕 flash 的数据。
从该产品的官方支持网站可以下载到详细说明文档和它自有的「编辑器」。按照官方说明和引导,首先要对固件进行升级才能使用新版「编辑器 gpumaker」。 由于要对屏幕上的 MCU (STM32)进行烧写,保险起见我还是另外准备了一个 FT232R
的串口转换器,没有使用「调试桥」。
升级过程就不赘述了,留两张图吧:
其实到这里直接使用它的编辑器重新上传图片就可以了,但我的想法是 保留原有内容和逻辑都不变 的基础上增加「自己的后门」。但我翻找了一下相关配套软件的功能,并没有提到增量编辑或者下载片内数据的方法,所以我并不敢直接上传自己的新图,万一原图就这样被刷没了呢?(全网搜索了一下,除了 GeekPwn 的 logo, 其它图片都找不到原图。)
于是只好采取复杂得多的办法:
全量 dump 屏上 Flash,尝试提取原图或修改固件逻辑
从详细说明文档中找到 PCB 设计图,可以看到官方贴心地为我们引出了最重要的 SPI 引脚:
那么理论上来说只要想办法把 Flash 接上 ESP32
「调试桥」就能读出里面的数据了。
试错了好几天 的过程略,简要说下这里头的坑点:
ESP-IDF
框架提供了强大的 Flash 通用驱动,所以用上 ESP-IDF
是必然的,那么整个工程结构就必须重新配置调整。我最后用 PlatformIO
重新组织了所有逻辑和代码文件。 但是 PlatformIO
的工程管理依赖一堆它自己实现的文件扫描逻辑,这些逻辑是难以配置且灵活性很差的。 痛苦程度堪比 CMake
.BOOT
引脚固定在了高电平来阻止它引导。(附图:这坨线屎见证了调试「缠斗」的惨烈)
(附图:第一次跑通 初始化 的测试用例)
在下面这个协程里,ESP32
开机后会尝试由指定的那些引脚通过 SPI 协议寻找可用的 Flash 外设,成功读取一些基础信息后会把整个 Flash 读到内存里等待发送。 由于我的 ESP32
开发板选配了 8MB PSRAM, 所以我就不麻烦地写成 「by chunk」的方式了。
还有一件很重要的事,直至目前我们所有的操作都可以在串口/ PC 的串口监视器上完成,但想要把整个 Flash 的二进制数据拖回来,串口绝不是个靠谱的选择;且不说上位机客户端怎么写,就光数据纠错就够喝一壶的。所以还必须实现一个 WiFi Web Server,通过 TCP socket 来传输数据,速率和可靠性就都有保障得多。
成功获得 Flash 和 MCU ROM (升级固件)的数据后,即可展开逆向分析。我还是第一次这么仔细逆一个单片机 ROM,入手才发现一点不比「消费级软件」来得轻松。
首先单片机的逻辑地址存在大量硬编码的外设地址映射,需要找到对应的 datasheet 对照着映射表[2]重建 segments 才能知道代码访问的地址是什么含义。
ARM 架构指令集天生就比 x86 难搞,IDA 的支持进度还贼慢,直到最近的 IDA8.3 泄露才终于能正确识别这个 cortexM0 的 bin 文件,7.7 连中断向量表的结构都没有内置,我这没啥经验的估计找到正确的代码映射地址都要费老功夫。
硬编码的控制指令太多,这又是需要翻着 datasheet 才能搞得动的。比如这个函数
spi_send
是从 SPI 寄存器映射地址 的 xref 推测的;而这个函数被调用的地方,这一大堆 xref 则 全部 是从 Flash 型号的 datasheet 里给出的控制指令[3]推测的。七歪八扭的自主实现和全局变量。lumen 上也找不到函数签名我不意外,但全局变量实在是多得恐怖。
还好 官方产品使用文档帮了大忙! 起码命令 handler 函数的作用和参数意义都不用猜了。
由于全过程太多太繁杂,时间跨度也很长了记不过来,就只记录关键思路和突破点了。
第一步,找到读 Flash 的关键实现函数。上面提过了,是从 SPI 控制寄存器的地址映射推出的。
找到其中一种数据的存放方式和地址,再辅助推测图片数据的存储。我选中的是 SPG
命令,这个命令用于执行一系列保存在 Flash 上的「批命令」。
GP2015!
字样,在 Dump 中搜索它可以找到明文字串,说明「批命令」是明文存储的,放在 0xe0000
的位置。分析 SPG
命令有关的函数,尤其是读 Flash 的部分,可以找到代码中对应 Flash 的实际地址:
可以看到传入的参数就是 0xe0000,那么显然读写地址一一对应,没有额外的转换计算。
分析读取图片有关的函数,同样找到地址
FF FF FF ...
)。当时时有两种可能性,一是逆向的代码没找对,二是我 dump 的数据有问题;显然,这两种可能性我最后都验证过了……(写点 python 脚本尝试还原对应的像素来验证存取逻辑:
成功重现 Flash 中的图片:
(附图:Google 认证,确实不存在原图)
验证完存取逻辑,那么终于可以实行自定图片的计划了。找一张新图片用 python 转回字节流,然后修改一下之前用来 dump 的代码,将新图片的数据写回选好的偏移地址:
这里最后还有个要注意的点, Flash 颗粒的「空状态」是 0b1
,而且「写入」操作只能将比特从 1 翻转成 0. 所以想要覆盖原有数据的区域,必须分开两个操作
然而擦除是不可随机寻址的,每次必须擦除一整个 sector (4k),这就要求在写入数据时必须先一次性读一个大 chunk,修改好后再一次性全重写,既麻烦也大量增加了丢数据的风险,还会消磨 Flash 颗粒寿命(不过 NOR 寿命也已经很长就是了);因此最好是先找到足够大的空白区域,这样可以减少 判断非空 - 擦除 - 重写
的逻辑冗余(只要重写存元数据的sector就行了)。
现在,当有预设名称的 wifi 热点出现时,这块胸牌会显示一个 Intel core i9
的 logo 并弹出一个「有黑客!」的警告 🤣👉
见视频演示
如果不能正常工作,通常是外围电路电平/电压有异常。比如可能外接的3.7v的锂电池过度拉高了vcc,或者接线不良导致了压降。 ↩︎
参考 [Table 17. STM32F030x4/x6/x8/xC peripheral register boundary addresses] ↩︎