学海遗珠 —— GeekPwn 2015 胸牌到底在玩什么?


最近在捣鼓新买的 ESP32 玩具。在翻家里的电子垃圾堆能当外设的组件时想起了这块 GeekPwn 2015 的胸牌。

screen hack
(附图:实现在屏幕上打印任意字符的效果,解开了会场广播消息的机制谜题)

作为第一届嘉年华式的 GeekPwn (总的来说是第二届,但第一届是个纯挑战赛的闭门活动),当年这块胸牌比较新鲜的东西。 拿回家就只是个只会循环播放图片的电子画框,但在会场不仅能同步显示现场广播(比如恭喜选手挑战成功的消息),还留了一个挑战题目,通过胸牌能攻入某个挑战服务器,从而获得一些奖励。


只可惜当年参会的时候还是个普通大学生,行程仓促也来不及仔细研究内中玄机。回来之后胸牌一扔就被遗忘在了角落里,就这么静静地躺了那么多年。

翻它出来的时候一拨开关,竟然还有电! 热泪盈眶的同时也不免产生了好奇心 —— 所以它到底是怎么玩的,在玩什么呢?


Revealing

硬件检查

全网能找到的唯一一篇关于 GP2015 胸牌的分析文章止步在了「抛砖引玉」。不过它指的路节省了我大量前期试探的时间。

*比如我的胸牌屏幕已经不亮了,那篇文章的附图让我迅速排除了软件猜想,很快确定是屏幕接地不良导致的(飞了根线解决)

参考链接就不贴了,google 还能找到若干副本。

该文最大的成果是贴出了胸卡侧边引脚孔的定义和 MCU 模块型号,我只需要验证一下就够了。用万用表寻找通路可以得到如下定义:

# Pins 从上到下  
- VCC 3.3v    #(连接电池)  
- TX0         #(MCU UART0 TX)  
- RX0         #(MCU UART0 RX)  
- Reset       #(MCU RST)  
- GND  
- GPIO0       #(ESP8266-12E,与 ESP8266 引脚定义不同,沿用原文)

另外 MCU 的 TX / RX 与屏幕底板上的互连,两者通过串口传输的指令来进行通信。

*顺带一提屏幕开关控制的是 MCU 到屏幕的 TX 线,断开它屏幕收不到控制指令,就不会切换画面。


设置「调试桥」

由于我原本在捣鼓的是 ESP32,开发板自带了 USB 串口转换,并不需要 、我也没有USB-串口转换线。因此考虑使用 ESP32 充当中转器来连接这块 GP2015 胸牌。

感恩于 ESP32 的先进性,它的 UART 可以自由映射到任何引脚上,且可以同时使用 3 组 UART 工作在各自的模式和波特率上。这样一来我只需要随便定义一下连线,再写点简单代码互相转发两组 UART 上的数据就能无缝读写胸牌上的串口了。


连线

线连好后大概是这个样子,加了点 LED 来指示 TX / RX 的工作情况。可以观察到 RX 灯亮后瞬间屏幕开始切换图片

连线照片 1
连线照片 2
连线照片 3


TX / RX 分别接到了 GPIO 40 / 41,对端设备两线对调,所以需要把 40 设置为 RX 脚,41 设置为 TX。与此同时对调两根线能「劫持」来自屏幕的通信,不过其实并无必要,要调试屏幕设备可以通过屏幕底板上自带的引出引脚来连接。


软件

  • Arduino 统治一切!

想起在学校时半途而废的 msp430 和更早玩过的 c51, 手搓寄存器控制指令真的太痛苦了,那时候连能用上 c++ 都难以奢求,哪曾想过写单片机程序能像写 Java 一样通用轻松。

虽然我的 ESP32 项目配置成了 ESP-IDF + Arduino, 但其实 vanilla 的 Arduino 就已经完全够用了。核心逻辑无非是初始化两个 Serial 对象然后互相写入:

#include <AceRoutine.h>  
#include <HardwareSerial.h>  

class Foward20Coroutine : public ace_routine::Coroutine {  
  public:  
    void setupCoroutine() override {  
        Serial2.begin(9600, SERIAL_8N1, 40, 41); //! using gpio40/41 = rx/tx  
        log_w("UART Fowarding (UART2 -> UART0) started");  
    }  
    int runCoroutine() override {  
        COROUTINE_LOOP() {  
            COROUTINE_AWAIT(Serial2.available());  
            auto c = Serial2.read();  
            if (c != -1) {  
                Serial0.write(c);  
            }  
        }  
    }  
};  
class Foward02Coroutine : public ace_routine::Coroutine {  
  public:  
    void setupCoroutine() override {  
        Serial0.begin(115200);  
        log_w("UART Fowarding (UART0 -> UART2) started");  
    }  
    int runCoroutine() override {  
        COROUTINE_LOOP() {  
            COROUTINE_AWAIT(Serial0.available());  
            auto c = Serial0.read();  
            if (c != -1) {  
                Serial2.write(c);  
            }  
        }  
    }  
};

这里用到一个协程库来并发任务,用 ESP-IDF 提供的 FreeRTOS API 创建新线程也是可以的。但 IDF 框架就太繁琐了,不够优雅。

上电 / Reset 后可以从串口监视器看到打印的 banner 信息和一些奇怪的 PIC(0,0,N); 字串:
banner


从这个 banner 可以得知板上程序采用的是 NodeMCU 框架,这是一个用 lua 来写主要逻辑的系统,在串口就可以直接通过 lua 的 interpreter 控制板子[1]的行为。 比如简单地 print 一下:
NodeMCU

观察到 PIC(... 消息是由 MCU 发出的,且每发出一次屏幕就会切换图片,于是模仿该消息,让板子打印 PIC(0,0,N); 就能手动切换了:

print([[PIC(0,0,3);]]) -- 切换到第三张预设图片

另外 NodeMCU 提供了完善的 API 文档,但其实相当一部分都不可用,这块老古董的版本实在太旧了。好在 GitHub 上还有当年版本的历史快照,虽然得翻半天,不过总归是有得对照了。


一探究竟

到以上为止,全网能找到的唯一一篇相关文章透露的全部信息就完结了,接下来完全是未知的探索。


Dump / 下载原程序脚本

我注意到的第一个现象是板子每隔一段时间就会重启,所以不把原程序 dump 下来并想办法停止原来的循环是没法进一步分析的。

NodeMCU 的 API 里提供了 file 模块可供读写文件:

for k,v in pairs(file.list()) do print(k,v) end 
-- 相当于 `ls .`  

file.open("init.lua"); print(file.read()); file.close()  
-- 读取初始化脚本的第一个 chunk

我在第一次尝试下载原程序的时候是直接通过串口循环打印 chunk 内容并拼接完成的;但其实这个办法并不好,有两个原因:

  • 串口速率很慢,9600 的波特率打印要半天,有可能与原本定时 print 的PIC( 指令相互混杂。
  • 串口电平不一定足够稳定,有出现信号干扰导致乱码的可能性。

如果按照我初次尝试的办法,必须趁着 PIC 指令刚过去,迅速 print 一两个 chunk, 等待下一个 PIC 指令出现再迅速 print,还得注意重启的时间,最好手动 Reset 完立马发出 print 指令。

不过我在后来尝试写回新脚本的时候发现这种串口手动的方式完全不可行,于是又研究出了通过 wifi 网络的互传方式,要稳定快速得多。


通过 wifi 模块来相互通信

由于 NodeMCU 官方文档已经非常详尽,这段就不详述了。总的思路就是让 8266 切换到 AP 工作模式,让电脑连入 wlan 再通过 socket 建立连接:

wifi.setmode(wifi.SOFTAP,false); -- 切换为 AP 模式  
wifi.ap.config({ssid="GEEKPWN",pwd="12345678",auth=wifi.WPA_PSK,max=2}); 
-- 配置 AP 的参数,注意协议栈有关 auth 的实现有些 bug  
-- 每次重连都必须更换 auth 方式才能让电脑顺利连入  

wifi.ap.setip({ip="192.168.5.1",netmask="255.255.255.0",gateway="192.168.5.1"});  
wifi.ap.dhcp.config({start="192.168.5.100"});  
wifi.ap.dhcp.start();  
-- 手动指定一个新的地址池以便观察 IP 配置是否生效

注意在这个旧版系统上没有 http 模块,socket / net 模块的 API 也与新版不同。

s = net.createConnection(net.TCP, 0); -- 无加密的 TCP socket  
file.open("init.lua",'r');  
s:on("sent", function(sck, c) for i=0,50 do local r=file.read();if r==nil then s:close() break end sck:send(r) end end)  
-- 首次发送成功后调用 callback, 继续发送 50 个 chunk, 不够再加

然后电脑连入 GEEKPWN 网络,用 nc 监听一个端口:

$ ip addr # 记得确认 ip 是否正确  
$ nc -l 8000 | tee init.lua

发起连接,并发送首个 chunk, 如果成功,板子会立即传送 init.lua 文件的剩余内容:

s:connect(8000,"192.168.5.100");s:send(file.read());  
-- 发送完成后  
file.close();

值得一提的是切换到 AP 模式并重启后 wifi 协议栈占用的 RAM 会增加,于是 lua runtime 可能会报 not enough memory 的错误然后停住。 手动切换到 STATION 模式然后使用 dofile() 函数重新运行或许可以恢复:

wifi.setmode(wifi.STATION);  
dofile("init.lua");  
-- 或者  
require("init.lua");

程序逻辑分析

dump 下来的脚本是 minify 过的,重新格式化后的脚本内容全文放在了 Gist 上

有点意思的是,我测试了如果还原成 human readable 缩进再放回去将不够内存运行,甚至 SSID 等静态字符串太长重新 minify 后都无法运行。可见原来的设计者程序写得也挺极限的。

¡Warning: AI Spaming!

这段代码是一段Lua脚本,看起来是为某个嵌入式设备(可能是基于NodeMCU或类似平台)编写的。以下是主要逻辑和各个函数的功能:

  1. 全局变量定义:

    • gserverip: 服务器IP地址。
    • gnetwork: 设备连接的WiFi网络名称。
    • gnetkey: WiFi网络的密码。
    • gdebugmode: 调试模式,当为1时,输出调试信息。
    • 其他一些全局变量,用于配置网络、服务器、重连等参数。
  2. 函数 p(m)

    • 用于打印信息到控制台。
  3. 函数 lmsg(msg)

    • 如果调试模式开启,则在信息前添加时间戳并调用 p 函数输出信息。
  4. 函数 createNewSocket()

    • 创建一个TCP连接的socket,并设置接收、连接、断开连接的回调函数。
    • 用于与服务器进行通信。
  5. 函数 showWords(words)

    • 根据传入的字符串,解析其中的三段文本(以 # 分隔),并输出对应的显示命令。
  6. 函数 dofs()

    • 在接收到特定命令后,定时刷新显示或隐藏文字。
  7. 函数 doReconnect()

    • 在连接断开时,定时尝试重新连接服务器,同时检测WiFi连接状态。
  8. 函数 goto01()

    • 将命令设置为 “01”,用于设备状态的切换。
  9. 函数 tmrf()

    • 定时器回调函数,用于定时执行一些任务,如心跳检测、WiFi连接等。
  10. WiFi配置及定时器设置:

    • 设置WiFi工作模式为STATION
    • 配置WiFi连接信息。
    • 定时器1用于定时执行 tmrf 函数。
    • 定时器2用于定时执行 doReconnect 函数。

总体来说,这段代码主要完成了与服务器的TCP通信、处理接收到的命令、定时执行任务等功能,其中涉及到了一些图形显示的控制和与服务器的通信协议。

主要的功能都在 createNewSocket() 里,让 GPT 帮忙分割一下各个回调的内容:

以下是 createNewSocket 函数中的回调函数的提取:

  1. “receive” 回调函数:

    -- 省略
  2. “connection” 回调函数:

    tempsk:on("connection", function(sck) 
       lmsg("connected server!")  
       sck:send("helloSuccess")  
       gfailcount = 0  
    end)
  3. “disconnection” 回调函数:

    tempsk:on("disconnection", function(sck) 
       lmsg("disconnected!")  
       goto01()  
       tmr.alarm(2, greconnectitvl, 0, doReconnect)  
    end)

下面是 “receive” 回调函数的逻辑分析:

  1. 接收并打印信息:

    • lmsg(c): 输出接收到的字符串信息。
  2. 心跳计数重置:

    • ghb = ghblimit: 将心跳计数重置为上限值。
  3. 长度检查:

    • 如果接收到的字符串长度小于2,则直接返回,不进行后续处理。
  4. 提取命令:

    • cmd = c:sub(1, 2): 从接收到的字符串中提取前两个字符作为命令。
  5. 命令校验:

    • 如果命令不在指定的合法命令列表中(“00”, “01”, “02”, “03”, “04”),或者在特定情况下长度不符合要求,则输出错误信息并返回。
  6. 处理各种命令:

    • 根据提取到的命令执行相应的操作。
    • 如果是 “01” 命令,则调用 goto01() 函数。
    • 如果是 “02” 命令,则提取额外信息,打印对应的显示命令。
    • 如果是 “03” 命令,则进行一系列初始化操作,并根据额外信息执行文字显示任务。
    • 如果是 “04” 命令,则打印额外信息。
    • 如果是 “00” 命令,则清屏。
  7. 特殊命令处理:

    • 如果是 “05” 命令,则重新启动设备(node.restart())。

总体来说,该回调函数主要是对接收到的命令进行解析和处理,包括命令的校验、特殊命令的执行以及一些状态的更新。

  • ChatGPT 基本上已经分析完了,我补充一点点:02 命令用于切换图片;04 用于debug;03 命令的作用是使用DSxx 命令打印消息。关于这些奇怪指令的完整列表可以 百度 (这个型号是国产的,必须搜中文资料) 「USART GPU 显示屏 / gpumaker」 得到

看起来有点遗憾,毕竟下发指令的服务器已经不在了,我们永远无法得知那个「神秘挑战」究竟是什么。也许可以想象 hello message 存在一个溢出漏洞?或者是挑战本身指的就是胸牌接受命令的程序本身? Anyway 我们终于得知这块牌子全部玄机,之后的交给想象力了。


Hack to the END!

分析完原程序之后当然不能止步于此,至少得 hack 一点自己的东西,这才称得上尊重。 我准备给它新加一个「后门」,当周围搜索到指定名字的 wifi 网络时显示自己的图片和文字。


修改程序

这里我们先创建一个新文件,增加发现特定网络就切换图片并写字的「后门」:

function regbd()  
    gsecssid = "GP2015BadgeBackdoor"  
    gbd1 = false  
    function backdoorShow(t)  
        if gbd1 then  
            return  
        end  
        for k, v in pairs(t) do  
            print("found wifi", ' -- ', k, v)  
            print("\n")  
            print("CLS();")  
            print("BPIC(0,0,0,3);")  
            print("PS32(0,60,16,'Warning',1);")  
            print([[BS16(20,60,200,4,'hacker detected, keep away from any RF or Wi-Fi signals, which may be harmful to your devices!',1);]])  
            print([[DS12(0,160,'okay it\'s just a joke, never mind :)',5)]])  
            -- 指令含义  
            -- 清屏  
            -- 选择 3 号图片设为 0 号背景  
            -- 选择 32 号字在背景上用 1 号颜色透明绘制文字  
            -- 选择 16 号字绘制段落文字,行距 4,颜色 1  
            -- 选择 12 号字绘制不透明文字,颜色 5  
            gbd1 = true  
            tmr.delay(10 * 1000 * 1000)  
            break  
        end  

    end  
    tmr.alarm(6, 10 * 1000, tmr.ALARM_AUTO, function()  
        print("scan...")  
        wifi.sta.getap({  
            ssid = gsecssid  
        }, backdoorShow)  
    end)  
end  
regbd()

上传新代码可以用与下载相似的办法,用 nc 把新程序通过网络发回去:

$ nc -l 8000 < modified.lua
s = net.createConnection(net.TCP, 0);file.open("backdoor.lua",'w');  
s:on("receive", function(sck, c) print(c); file.write(c) end ); -- 方便观察文件发送完毕

然后再修改原初始化脚本,引用新加的「后门」:

require("backdoor")  
-- 或者  
dofile("backdoor.lua")  
-- 还可以编译成字节码存放,「更加隐蔽 :P」  
node.compile("backdoor.lua")  
file.remove("backdoor.lua")  
-- require("backdoor") still works

打开手机热点,就可以看到「被黑」的效果了:
"Hacked" under AP

重写预设图片

从前面的程序逻辑可以得知屏幕播放的图片是其内置 flash 存储的预设,所以想要修改显示图,必须重新刷写 屏幕 flash 的数据。

从该产品的官方支持网站可以下载到详细说明文档和它自有的「编辑器」。按照官方说明和引导,首先要对固件进行升级才能使用新版「编辑器 gpumaker」。 由于要对屏幕上的 MCU (STM32)进行烧写,保险起见我还是另外准备了一个 FT232R 的串口转换器,没有使用「调试桥」。

升级过程就不赘述了,留两张图吧:
Uploading Screenshot
Updated Screenshot

其实到这里直接使用它的编辑器重新上传图片就可以了,但我的想法是 保留原有内容和逻辑都不变 的基础上增加「自己的后门」。但我翻找了一下相关配套软件的功能,并没有提到增量编辑或者下载片内数据的方法,所以我并不敢直接上传自己的新图,万一原图就这样被刷没了呢?(全网搜索了一下,除了 GeekPwn 的 logo, 其它图片都找不到原图。)

于是只好采取复杂得多的办法:

全量 dump 屏上 Flash,尝试提取原图或修改固件逻辑


Own the ROM

从详细说明文档中找到 PCB 设计图,可以看到官方贴心地为我们引出了最重要的 SPI 引脚:

PCB

那么理论上来说只要想办法把 Flash 接上 ESP32 「调试桥」就能读出里面的数据了。

试错了好几天 的过程略,简要说下这里头的坑点:

  1. SPI 是个相当高速的总线/协议,用面包板+杜邦线的稳定性很差,必须先进行一定程度地加固。(没焊牢也比没焊强得多)
  2. SPI 有相当多复杂的工作参数和模式,手搓 Flash 控制指令进行读写是几乎不可能/没意义的事。另外一边,ESP-IDF 框架提供了强大的 Flash 通用驱动,所以用上 ESP-IDF 是必然的,那么整个工程结构就必须重新配置调整。我最后用 PlatformIO 重新组织了所有逻辑和代码文件。 但是 PlatformIO 的工程管理依赖一堆它自己实现的文件扫描逻辑,这些逻辑是难以配置且灵活性很差的。 痛苦程度堪比 CMake.
  3. 还是由于 SPI 的复杂性,即使是最简化的代码也需要测试可工作的参数,如 IO 模式、时钟频率、触发边沿之类的。这些参数有的可以从 datasheet 获得,有的是不匹配要试的(就比如频率)。在一开始的时候你很难 figure out 是硬件连线问题还是软件问题。
  4. 上电之后屏幕自带的 MCU 也会工作,必须想办法屏蔽它的影响,我最后想办法把 BOOT 引脚固定在了高电平来阻止它引导。

Wires
(附图:这坨线屎见证了调试「缠斗」的惨烈)

Test Passed
(附图:第一次跑通 初始化 的测试用例)

在下面这个协程里,ESP32 开机后会尝试由指定的那些引脚通过 SPI 协议寻找可用的 Flash 外设,成功读取一些基础信息后会把整个 Flash 读到内存里等待发送。 由于我的 ESP32 开发板选配了 8MB PSRAM, 所以我就不麻烦地写成 「by chunk」的方式了。

class DumpFlashCoroutine : public ace_routine::Coroutine {  
  protected:  
    esp_flash_t *pflash_ = NULL;  

  public:  
    std::vector<uint8_t> dumped_data_;  

  public:  
    void setupCoroutine() override {  
        log_d("SPI initializing");  
        spi_bus_config_t bus_config;  
        bzero(&bus_config, sizeof(bus_config));  
        bus_config.sclk_io_num = SPI_PIN_SCLK;  
        bus_config.miso_io_num = SPI_PIN_MISO;  
        bus_config.mosi_io_num = SPI_PIN_MOSI;  
        bus_config.quadhd_io_num = -1;  
        bus_config.quadwp_io_num = -1;  

        esp_flash_spi_device_config_t flash_config;  
        bzero(&flash_config, sizeof(flash_config));  
        flash_config.host_id = SPI3_HOST;  
        flash_config.cs_io_num = SPI_PIN_SS;  
        flash_config.cs_id = 0;  
        flash_config.io_mode = SPI_FLASH_FASTRD; // DIO not work!  
        flash_config.speed = ESP_FLASH_10MHZ; // CAN'T BE HIGHER!  

        ESP_ERROR_CHECK(spi_bus_initialize(SPI3_HOST, &bus_config, SPI_DMA_CH_AUTO));  

        ESP_ERROR_CHECK(spi_bus_add_flash_device(&pflash_, &flash_config));  
        ESP_ERROR_CHECK(esp_flash_init(pflash_));  
        log_d("SPI external flash initialized");  
    }  
    int runCoroutine() override {  
        COROUTINE_BEGIN();  
        do {  
            if (!esp_flash_chip_driver_initialized(pflash_)) {  
                break;  
            }  
            uint32_t flash_id = 0;  
            uint32_t flash_size = 0;  
            uint32_t flash_phy_size = 0;  
            ESP_ERROR_CHECK_WITHOUT_ABORT(esp_flash_read_id(pflash_, &flash_id));  
            ESP_ERROR_CHECK_WITHOUT_ABORT(esp_flash_get_size(pflash_, &flash_size));  
            ESP_ERROR_CHECK_WITHOUT_ABORT(esp_flash_get_physical_size(pflash_, &flash_phy_size));  
            log_d("External flash Info: id=<%x>, size=%d, phy_size=%d", flash_id, flash_size, flash_phy_size);  
            
            // my device has a high-capacity PSRAM, thus I can allocate all the space at once  
            dumped_data_.resize(flash_size); 
            ESP_ERROR_CHECK_WITHOUT_ABORT(esp_flash_read(pflash_, dumped_data_.data(), 0, 16384));  
            size_t count = 0;  
            for (auto c : dumped_data_) { // show first 16k bytes to see if read correctly  
                if (count > 16384) {  
                    break;  
                }  
                Serial0.print(c, HEX);  
                if (++count % 16 == 0) {  
                    Serial0.println();  
                    continue;  
                }  
                Serial0.print(" ");  
            }  
            ESP_ERROR_CHECK_WITHOUT_ABORT(esp_flash_read(pflash_, dumped_data_.data(), 0, flash_size));  
            log_w("External flash data dumped! prepare to send.");  

            if (pflash_) {  
                spi_bus_remove_flash_device(pflash_);  
                pflash_ = NULL;  
            }  
        } while (false);  
        COROUTINE_END();  
    }  
};  
static DumpFlashCoroutine co_dump;  
std::vector<uint8_t> get_flash_dump_data() { return std::move(co_dump.dumped_data_); } // EXPORT

Over WiFi!

还有一件很重要的事,直至目前我们所有的操作都可以在串口/ PC 的串口监视器上完成,但想要把整个 Flash 的二进制数据拖回来,串口绝不是个靠谱的选择;且不说上位机客户端怎么写,就光数据纠错就够喝一壶的。所以还必须实现一个 WiFi Web Server,通过 TCP socket 来传输数据,速率和可靠性就都有保障得多。

class WifiCoroutine : public ace_routine::Coroutine {  
  protected:  
    static const char *ap_ssid_;  
    static const char *ap_password_;  
    WiFiServer server_;  

  public:  
    WifiCoroutine() : ace_routine::Coroutine() {}  
    void setupCoroutine() override {  
        if (!WiFi.softAP(ap_ssid_, ap_password_)) {  
            log_e("Soft AP creation failed.");  
            abort();  
        }  
        server_.begin(80);  
        log_w("Server started.");  
    }  
    int runCoroutine() override {  
        COROUTINE_LOOP() {  
            COROUTINE_DELAY(100);  
            {  
                static uint8_t counter = 0;  
                static size_t len_total = 0;  
                auto client = server_.accept();  
                if (!client) {  
                    continue;  
                }  
                log_d("new client.");  
                if (client.connected()) {  
                    log_d("client connected.");  
                    extern std::vector<uint8_t> get_flash_dump_data();  
                    // 前面 dump 好的数据  
                    auto dump_data = get_flash_dump_data();  
                    client.write(dump_data.data(), dump_data.size());  
                }  
                client.stop();  
                log_d("client leave.");  
            }  
        }  
    }  
};

Reverse Engineering, the Bitter

成功获得 Flash 和 MCU ROM (升级固件)的数据后,即可展开逆向分析。我还是第一次这么仔细逆一个单片机 ROM,入手才发现一点不比「消费级软件」来得轻松。

  • 首先单片机的逻辑地址存在大量硬编码的外设地址映射,需要找到对应的 datasheet 对照着映射表[2]重建 segments 才能知道代码访问的地址是什么含义。

  • ARM 架构指令集天生就比 x86 难搞,IDA 的支持进度还贼慢,直到最近的 IDA8.3 泄露才终于能正确识别这个 cortexM0 的 bin 文件,7.7 连中断向量表的结构都没有内置,我这没啥经验的估计找到正确的代码映射地址都要费老功夫。

  • 硬编码的控制指令太多,这又是需要翻着 datasheet 才能搞得动的。比如这个函数
    xrefs

    • 我之所以能知道它应该叫 spi_send 是从 SPI 寄存器映射地址 的 xref 推测的;而这个函数被调用的地方,这一大堆 xref 则 全部 是从 Flash 型号的 datasheet 里给出的控制指令[3]推测的。
  • 七歪八扭的自主实现和全局变量。lumen 上也找不到函数签名我不意外,但全局变量实在是多得恐怖。

    • 很多全局变量都是全局 buffer 的读写「指针」……对了指针的 size 太大了所以用 BYTEWORD 的 index 替代吧!于是 1 字节也是指针,2 字节也是指针,3 字节还是指针(Flash 的读写地址用 3 个字节来传),4字节还是指针,真有趣😅…… 这些读写头与其它 flag 变量的含义基本要直到把使用它的函数完全弄懂才能猜出,有一大堆我直到最后都猜不出来。

  • 还好 官方产品使用文档帮了大忙! 起码命令 handler 函数的作用和参数意义都不用猜了。

Dive into the ROM

由于全过程太多太繁杂,时间跨度也很长了记不过来,就只记录关键思路和突破点了。

  1. 第一步,找到读 Flash 的关键实现函数。上面提过了,是从 SPI 控制寄存器的地址映射推出的。

  2. 找到其中一种数据的存放方式和地址,再辅助推测图片数据的存储。我选中的是 SPG 命令,这个命令用于执行一系列保存在 Flash 上的「批命令」。

    • 胸牌开机时会显示 GP2015! 字样,在 Dump 中搜索它可以找到明文字串,说明「批命令」是明文存储的,放在 0xe0000 的位置。
    0E:0000  55 32 3B 43 4C 53 28 30 29 3B 50 49 43 28 30 2C  U2;CLS(0);PIC(0, 
    0E:0010  31 35 2C 32 29 3B 44 53 34 38 28 31 30 2C 31 2C  15,2);DS48(10,1, 
    0E:0020  27 47 50 32 30 31 35 21 27 2C 35 29 3B 00 35 21  'GP2015!',5);.5! 
    0E:0030  27 2C 35 29 3B 00 32 32 30 2C 32 30 2C 31 35 29  ',5);.220,20,15) 
  3. 分析 SPG 命令有关的函数,尤其是读 Flash 的部分,可以找到代码中对应 Flash 的实际地址:
    SPG 命令
    可以看到传入的参数就是 0xe0000,那么显然读写地址一一对应,没有额外的转换计算。

  4. 分析读取图片有关的函数,同样找到地址
    PIC 命令

    • 可以看到读取了位于 0x4000 的数据。但我的 dump 数据里这个地址是空白( FF FF FF ... )。当时时有两种可能性,一是逆向的代码没找对,二是我 dump 的数据有问题;显然,这两种可能性我最后都验证过了……(我都快能重写 ROM 的全部主逻辑了最后我把 SPI 用到的参数都调成了最低速,才重新读出了完整的数据orz
    • 由 F5 可得 0x4000 放的是一系列元数据,记录了图片大小以及像素存放的偏移:
      PIC 命令
      获得元数据后按 chunk 读取像素数据,通过 DMA 直接送入目标缓冲区,未发现加密实现。
  5. 写点 python 脚本尝试还原对应的像素来验证存取逻辑:

    import numpy as np  
    from PIL import Image  
    
    def toRGB8array(a): # Flash 中的像素格式为 RGB565  
      R5 = (a & 0b1111100000000000) >> 11  
      G6 = (a & 0b0000011111100000) >> 5  
      B5 = (a & 0b0000000000011111)  
      R8 = ( R5 * 527 + 23 ) >> 6  
      G8 = ( G6 * 259 + 33 ) >> 6  
      B8 = ( B5 * 527 + 23 ) >> 6  
      return np.dstack((R8,G8,B8)).astype(np.uint8)  
    
    def readPx(d, f):  
      w,h = struct.unpack('>HH',d[:4]) # 注意大端字节序  
      addr, = struct.unpack('>I',d[4:])  
      f.seek(addr)  
      return f.read(w*h*2)  
    
    # ...  
    
    Image.fromarray(toRGB8array(np.frombuffer(readPx(d1,f),dtype='>u2').reshape(220,176).T)).show()

    成功重现 Flash 中的图片:
    提取原图
    (附图:Google 认证,确实不存在原图)


Make Our Arts

验证完存取逻辑,那么终于可以实行自定图片的计划了。找一张新图片用 python 转回字节流,然后修改一下之前用来 dump 的代码,将新图片的数据写回选好的偏移地址:

# image => RGB565 bytes  
def toRGB565(a): # drop lower bits  
  R5 = ((a[...,0]  & 0b11111000)>>3).astype('>u2')  
  G6 = (((a[...,1]) & 0b11111100)>>2).astype('>u2')  
  B5 = (((a[...,2]) & 0b11111000)>>3).astype('>u2')  
  return ((R5<<11) | (G6<<5) | B5).astype('>u2').T.reshape((-1,))  

a4 = np.array(Image.open('/tmp/pic4.png'))[...,:3]  
b4 = toRGB565(a4).tobytes()  
open('/tmp/pic4.bin','wb').write(b4)
// OverWifi ROM Downloader  
// ...  
while (true) {  
    static bool once = false;  
    auto client = server_.accept();  
    if (!client || once) {  
        continue;  
    }  
    log_d("new client.");  
    auto hd = px_addr; // chosen addr to put new pixel data  
    bool first_done = false;  
    std::vector<uint8_t> write_data;  
    while (client.connected()) {  
        once = true;  
        log_d("client connected.");  
      
        // pre-checked / read 0x4000 header to ensure that the Flash is properly working  
        if (is_write_safe_ && client.available()) { 
            write_data.clear();  
            write_data.resize(4096);  
            auto len = client.readBytes(write_data.data(), write_data.size());  
            ESP_ERROR_CHECK(esp_flash_write(pflash_, write_data.data(), hd, len));  
            hd += len;  
            if (!first_done) {  
                first_done = true;  
                log_d("Download: first chunk wrote successfully.");  
            }  
            log_v("Download: written %d / 77440", hd - px_addr);  
        } else if (!is_write_safe_) {  
          
            // if pre-check mismatched, see what happened  
            client.write(dumped_data_.data(), dumped_data_.size());  
            break; // acturally, a return  
        }  
    }  
    client.stop();  
    log_d("Download: finished.");  
// ...

这里最后还有个要注意的点, Flash 颗粒的「空状态」是 0b1 ,而且「写入」操作只能将比特从 1 翻转成 0. 所以想要覆盖原有数据的区域,必须分开两个操作

  • 擦除
  • 写入

然而擦除是不可随机寻址的,每次必须擦除一整个 sector (4k),这就要求在写入数据时必须先一次性读一个大 chunk,修改好后再一次性全重写,既麻烦也大量增加了丢数据的风险,还会消磨 Flash 颗粒寿命(不过 NOR 寿命也已经很长就是了);因此最好是先找到足够大的空白区域,这样可以减少 判断非空 - 擦除 - 重写 的逻辑冗余(只要重写存元数据的sector就行了)。


成果

现在,当有预设名称的 wifi 热点出现时,这块胸牌会显示一个 Intel core i9 的 logo 并弹出一个「有黑客!」的警告 🤣👉

见视频演示


  1. 如果不能正常工作,通常是外围电路电平/电压有异常。比如可能外接的3.7v的锂电池过度拉高了vcc,或者接线不良导致了压降。 ↩︎

  2. 参考 [Table 17. STM32F030x4/x6/x8/xC peripheral register boundary addresses] ↩︎

  3. 参考 [Table 4. COMMAND DEFINITION] ↩︎