Hexo
TS3480折腾记录
Posted on: 2024-01-09 Edited on: 2024-03-14 In:  Views: 

端口扫描,TCP端口

1
2
3
4
5
6
PORT     STATE SERVICE    VERSION
80/tcp open http
443/tcp open ssl/https?
515/tcp open printer
631/tcp open ssl/ipp
9100/tcp open jetdirect?

UDP端口

1
2
3
4
5
6
7
8
9
10
11
PORT      STATE         SERVICE      VERSION
67/udp open|filtered dhcps
68/udp open|filtered dhcpc
161/udp open snmp SNMPv1 server; CANON Inc. SNMPv3 server (public)
1900/udp open|filtered upnp
1901/udp open|filtered fjicl-tep-a
3702/udp open|filtered ws-discovery
5353/udp open mdns DNS-based service discovery
5355/udp open|filtered llmnr
54721/udp open|filtered unknown
54726/udp open|filtered unknown

官网没有固件,打印机走的https流量,抓不到东西,打印机设置了http代理之后抓到了这么个流量

image-20230505150025159

但是不是固件,只有几百字节,而且没有可见字符

image-20230505172135971

个人猜测这是一个加密之后的存有固件下载链接的文件,打印机将其下载之后进行解密得到固件更新地址。

还是直接拆设备提固件吧,经过一顿折腾,拆下来了pcb板

image-20230506144657983

flash上的丝印为winbond25Q128JVSQ,一款winbond公司的16MB大小的flash

热风枪吹下来用编程器进行提取,吹下来的时候不小心力气用大了一点把焊盘给拉起来了,不知道焊回去了还能不能用

image-20230506164656961

可惜的是,从flash上提取下来的固件居然也是加密状态,只有极少数可见字符串

binwalk -E走一波

image-20230506164840390

看那几道几乎水平的熵值,就知道这肯定有加密或这被压缩过了。

加密的固件必不可能运行,所以在被运行之前肯定有一个解密的过程。解密的过程通常由bootloader来完成,我们知道嵌入式系统在上电之后会先运行bootload初始化硬件并加载内核,所以bootloader肯定是不能处于加密状态的,bootloader都加密了那整个固件就没有一个可以运行的部分了。

对于有些设备,bootloader和固件是存在一个flash中的,而有些设备则是一个8M或者其他大小的flash用于存储flash,另一个NAND flash或者emmc用于存放固件。对于ts3480这款打印机而言只有一个16MB的flash,其bootloader和固件是存在一起的。

根据前面的分析,固件虽然处于加密状态,但肯定是有一小段未加密的数据的,这些数据我们要重点关注,其中应该会存在解密固件的关键。

binwalk -A 走一波,看看哪里能够识别成指令

image-20230506174309488

最开始的指令是0x2E00FC处,用010打开

image-20230507162154433

实际上,从0x2E0000开始才是有指令的位置,IDA打开并在0x2E0000处进行反编译

image-20230507162507898

image-20230507162745498

注意到0xF02E0190这个值,我们跳转到0x2E0190处看看

image-20230507162841120

很巧,0x2E0190处正好是一段数据,所以猜测flash的加载地址为0xf0000000,重新设置flash的加载地址

继续往下看

image-20230507173338640

将从0xF02E058C开始的数据拷贝到0x400000处,拷贝长度为0x36d4

在这里可以使用如下命令

1
dd if=ts3480.BIN of=first_ram_cpoy.bin bs=1 count=14036 skip=3016076

将0xF02E058C处的数据,也就是固件偏移为0x2E058C处、长度为0x36d4的数据提取出来

然后新建一个段

image-20230507194211122

再使用IDA的附加二进制文件的功能

image-20230507194251566

image-20230507194329773

将这一段数据添加到我们刚刚创建好的段中,这样一来就模拟了前面的数据拷贝的操作。

再看到sub_F02E04F0函数,这个函数又会执行0x400312处的函数,而0x400000-0x4036d4处的数据已经被设置为了0xF02E058C处的数据

image-20230507193612002

而sub_400312如下所示

image-20230507193957812

跟进到sub_40017A函数中

image-20230507194846086

很乱,不知所云,继续看到sub_402B44函数

image-20230507200625833

根据函数内容恢复了两个可能的函数名,后续进入到一个类似于解压缩功能的函数

image-20230507202024683

函数非常大,有一千多行,其中有一些关键的字符串帮助我们确定函数的功能是用来解压缩的,如下所示

image-20230507202214006

后面的sub_4009A0函数意义不明,猜测用于解压缩后的收尾工作,回溯到上一层函数

image-20230508184516134

看到这,0xF12E6000并不在我们定义的地址范围内,flash的加载地址为0xF0000000,我们看到固件中偏移为0x2E6000处正好是一段数据的起始部分

image-20230508192055595

猜测flash的数据并不止被映射到了0xf0000000-0xf1000000的地址范围,从0xf1000000-0xf2000000处同样被映射为了flash的数据

所以再创建一个段用于存放flash的副本。

image-20230508185544916

这里这个for循环的v1实际上计算出来为4,循环四次,下面进入解压缩的代码有一个if判断,i为0的时候,也就是循环的第一次实际上是不进入到解压缩的函数的

image-20230508185822676

compressed_data是被压缩的数据,从i=0之后,compressed_data被赋值为v15的值,而v15如下

image-20230508185935301

正是flash中偏移为0x2E6000处开始的数据

image-20230508190124066

for循环中用于解压缩的函数由于i=0时不执行,所以实际上只执行了3次,每次解压缩0x10000的数据

for循环结束之后

image-20230508190426162

当i=4时,有下面的代码

1
(v0 & 0xFFFFF) != 0 ? (compress_length = v0 & 0xFFFFF) : (compress_length = 0x100000)

image-20230508190539253

v0为0x319140,所以compress_length会被更新为(compress_length = v0 & 0xFFFFF)=0x19140

也就是说前三次解压缩一共解压缩了0x300000的数据,最后一次把剩下的0x19140的数据解压缩,总共要解压缩的数据长度为0x319140

那么这个用的是什么压缩算法,binwalk看一看

image-20230508191329846

识别出来的是zlib,并且很巧,识别出来的第一个zilb压缩数据正好是在0x2E6004处,也就是在0x2E6000+4处。第二个zlib压缩包位于0x375C9C处,我们到0x2E6000处看看这四个字节

image-20230508192201659

0x8fc93,zlib的起始位置为0x2E6004,0x2E6004+0x8fc93=375C97,而第二块zlib数据的起始位置为0x375C9C,0x375C9C-4=0x375C98,在循环解压缩的函数中

image-20230508193207842

这里有一个4字节对齐的操作,因此每一个zlib压缩数据前面的4字节实际上就是压缩数据的大小,zlib数据位置加上这个size后4字节对齐就能够得到下一个zlib数据的起始位置,其格式如下

1
[size_of_compressed_data][compressed_data]

到这里就理清楚了解压缩的思路,写一个脚本手动对其中的数据进行解压缩

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import zlib
import struct

class Firmware:
def __init__(self):
self.frimware=open("./ts3480.BIN","rb").read()
self.compressed_start=0x2E6000

def decompress_next(self):
length=struct.unpack("<I",self.frimware[self.compressed_start:][:4])[0]
compressed_data=self.frimware[self.compressed_start+4:][:length]
decompressed_data=zlib.decompress(compressed_data)
self.compressed_start=self.compressed_start+4+length
if self.compressed_start%4!=0:
self.compressed_start=self.compressed_start+(4-(self.compressed_start%4))
return decompressed_data

def decompress(self):
decompressed=bytes()
decompressed+=self.decompress_next()
decompressed+=self.decompress_next()
decompressed+=self.decompress_next()
decompressed+=self.decompress_next()
print(len(decompressed))
return decompressed


firmware=Firmware()
with open("decompressed_data.bin","wb") as fd:
fd.write(firmware.decompress())


查看一下其中的字符串

image-20230508194040395

证明我们解压缩的没错。

那么这里解压缩的是什么呢,在一开始0x2e0000处执行的代码实际上就是bootloader,按照嵌入式设备一般的执行流程,bootloader会加载kernel,kernel再加载文件系统,所以这里解压缩的实际上是kernel。

kernel会被加载到哪去?

image-20230508195756089

在解压缩函数中,第一个参数就是数据会被解压缩到哪个地址

image-20230508200103798

1
((i - 1) << 20)=0xi0000

所以内核会从内存的0地址处开始加载

新建一个段,起始地址为0,然后将解压出来的kernel附加上去。

image-20230508214455066

在执行完解压缩功能后,bootloader会跳转到内核去

image-20230508214526830

内核设置好各种系统参数和模式,然后跳转执行另外的代码

image-20230508214632407

image-20230508214641899

image-20230508214655516

image-20230508214710465

看看sub_F02E0484函数做了什么

image-20230508214740285

问问万能的gpt

image-20230508214803303

也就是说这个函数执行的也是解压缩功能,将a1处的数据解压缩到a2处,a3是解压缩的长度

好在IDA给出的这个函数的伪代码非常贴近于真实代码,扒下来稍微修改一下就能跑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>


char * decompress(char *result, char*a2, int a3)
{
char *v3; // r2
int v4; // r3
int v5; // t1
int v6; // r4
int v7; // t1
int v8; // r5
int v9; // t1
int i; // r4
char v11; // t1
int v12; // t1
int v13; // r5
char *j; // r4
char v15; // t1

v3 = &a2[a3];
do
{
v5 = (uint8_t)*result++;
v4 = v5;
v6 = v5 & 7;
if ((v5 & 7) == 0)
{
v7 = (uint8_t)*result++;
v6 = v7;
}
v8 = v4 >> 4;
if (!(v4 >> 4))
{
v9 = (uint8_t)*result++;
v8 = v9;
}
for (i = v6 - 1; i; ++a2)
{
v11 = *result++;
--i;
*a2 = v11;
}
if ((v4 & 8) != 0)
{
v12 = (uint8_t)*result++;
v13 = v8 + 2;
for (j = &a2[-v12]; --v13 >= 0; ++j)
{
v15 = *j;
*a2++ = v15;
}
}
else
{
while (--v8 >= 0)
*a2++ = 0;
}
} while (a2 < v3);
return result;
}

int main()
{
FILE *fp=fopen("./ts3480.BIN","rb");
if(fp==NULL)
{
perror("fopen error");
return -1;
}
fseek(fp,0x5380F0,SEEK_SET);
char *compressdata=(char*)malloc(0x3ba0);
fread(compressdata,1,0x3ba0,fp);
char *des=(char*)malloc(0x3ba0);
if(des==NULL)
{
perror("malloc error");
return -1;
}
decompress(compressdata,des,0x3BA0);
FILE* out=fopen("out.bin","wb");
fwrite(des,1,0x3ba0,out);
fclose(fp);
fclose(out);
return 0;
}

不过这一段解压缩的数据太小,不像是文件系统,加载到IDA中看看

image-20230508215259729

这段数据将会被加载到0x207AE000处,所以我们在这里新建一个段再加载

image-20230508215935336

image-20230508220011829

感觉是各种各样的指针。。没啥有意义的代码

后面的内核操作也没找到什么突破口,分析一时间陷入僵局

回头看看binwalk的结果

image-20230510132611944

固件中有三个高熵值的区域,在一开始我们解压缩了kernel,起始位置是0x2E6000=3039232。结束位置是0x4ACFF1=4902897

image-20230510132851909image-20230510132951660

而第一个高熵值区域的起始位置恰好位于3000000处,结束位置也在1900000左右,所以可以认为第一个区域就是内核段。

既然这样,第二个区域和第三个区域又是什么,这两个会在什么时候进行解压缩?

image-20230510133816096

第二个区域从0x560004开始,实际上根据我们的分析,应该是从0x560000开始的,前四字节用于存储压缩数据的长度

image-20230510134024487

用010跳转到0x560000可以看到前面都是用0xff来隔开的。

于是尝试搜索在flash中搜索0x560000,还真搜到了一个和前面解压内核类似的函数

image-20230510134826947

image-20230510134859191

有了前面的分析,我们可以确定这个函数实际上就是将flash中从偏移为0x560000开始、长度为0x952E9C-0x40000的数据进行解压缩

image-20230510135350575

将其解压缩到0x40000处。

再搜索一下0xBA0000,同样也能搜到一个解压缩函数

image-20230510140231582

将flash中从偏移为BA0000开始、长度为0x129DF94-0xD40000的数据进行解压缩,解压地址为0xD40000

从binwalk的分析来看依然是zlib的压缩方式,所以还是按照前面的解压缩脚本进行解压缩即可

看看0x560000区域的数据解压出来的字符串

image-20230510141019058

看起来很不错,应该这里就是业务代码了。再看看0xBA0000偏移的

image-20230510141258268 image-20230510141316254

emmmmm,看起来这些才是内核代码,或者是操作系统的代码,不管了先。

用IDA打开业务代码,加载地址设置为0x40000

有一说一,IDA恢复固件是真的差,只有一小部分被解析了

image-20230520141948892

所以还是得先用ghidra来解析

image-20230520142112378

ghidra基本上把整个固件都理了一遍,然后就可以在ghidra中查找感兴趣的函数,再到IDA中进行分析

佳能的固件中有不少的调试信息

image-20230520142412909

类似于[subsys:funcname],可以根据先这些调试信息来恢复一些函数名。

接下来开始找服务,一开始用nmap扫出来了不少服务,我选择从llmnr这个服务入手。

找服务的一个快捷方式就是直接搜索字符串,然后交叉引用到对应函数,在ghidra中可以找到这些字符串

image-20230520143307040

假设llmnr服务存在问题,那么问题一般存在于接收数据然后处理数据的过程中,网络接收函数一般为recv、recvmsg等等,再结合LLMNRResponder.cpp一起定位,最终交叉引用到了这个函数

image-20230520143750984

在select之前经过分析之后是LLMNRSender.cpp对应的处理函数,也就是发送响应包。

看到sub_26D494函数

image-20230520144030968

有两个if判断,都是以v4为基准进行判断的,而v4来源于0x21DEE2D4,这个地址我猜测用于存储一些配置信息,两个if分别判断select到的地址是ipv4还是ipv6。

最大能够接收1500字节的数据,然后我们看到validate_llmnr_packet函数

image-20230520150002785

我这里创建了一个llmnr协议的结构体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// LLMNR标头
typedef struct llmnr_header {
uint16_t id; // 事务ID
uint16_t flags; // 标志
uint16_t qdcount; // 问题计数
uint16_t ancount; // 回答计数
uint16_t nscount; // 授权计数
uint16_t arcount; // 附加计数
} llmnr_header_t;

// LLMNR查询记录
typedef struct llmnr_query {
uint8_t *qname; // 主机名
uint16_t qtype; // 查询类型
uint16_t qclass; // 查询类
} llmnr_query_t;

// LLMNR请求数据包
typedef struct llmnr_request_packet {
llmnr_header_t header; // LLMNR标头
llmnr_query_t query; // LLMNR查询记录
} llmnr_request_packet_t;

这里就是对llmnr请求包的数据做一个判断,

其中llmnr请求中的qname字段最长只能为255字节,并且不能为空字节。

查询类型要为1,12,28,255其中一种,否则返回-1.

如果查询类型不为-1,就调用sub_26CE08函数

image-20230520150555754

这里应该就是根据请求包构造出响应包,然后发送给对应主机。

看到llmnr_generate_response_packet函数

image-20230520152540427

这里就是根据请求包构造响应包

假设有一个请求包如下

1
2
3
4
5
6
7
8
9
10
11
12
13
+-----------------------+
| 0x1234 | // 事务ID
+-----------------------+
| 0x0000 | // 标志
+-----------------------+
| 0x0001 | // 问题计数
+-----------------------+
| "example" | // 主机名
+-----------------------+
| 0x0001(1) | // 查询类型:IPv4地址
+-----------------------+
| 0x0001 | // 查询类:Internet
+-----------------------+

那么其对应的响应包如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
+-----------------------+
| 0x1234 | // 事务ID(与请求包相同)
+-----------------------+
| 0x8400 | // 标志:响应、不可重复、无差错
+-----------------------+
| 0x0001 | // 问题计数
+-----------------------+
| "example" | // 主机名(与请求包相同)
+-----------------------+
| 0x0001(1) | // 查询类型:IPv4地址(与请求包相同)
+-----------------------+
| 0x0001 | // 查询类:Internet(与请求包相同)
+-----------------------+
| 0x0000 | // 资源记录数:0
+-----------------------+
| 0x0000 | // 授权记录数:0
+-----------------------+
| 0x0001 | // 附加记录数:1
+-----------------------+
| TTL (生存时间) |
+-----------------------+
| 0x0004(4) | // 数据长度:4字节
+-----------------------+
| IP地址 | // 回答:主机名“example”的IPv4地址
+-----------------------+

也就是说,请求包的请求头会原封不动的装入响应包中,包括请求查询的域名

calc_byte_array_length这个函数如下

image-20230520151712049

就是遍历给定的数组,一直找到空字符,也就是字符串的结尾,然后返回字符串的长度。

image-20230520152801660

后面使用strncpy将请求包的query字段拷贝到响应包的字段中,拷贝长度为calc_byte_array_length计算出来的长度。

在这里的拷贝可能存在栈溢出的问题,由于calc_byte_array_length是根据空字符来判断字符串结尾的,所以字符串可以很长。

image-20230520153837689

而response_packet的缓冲区只有0x20c的长度,但是query_packet最长可以有1500字节,因此只需要根据validate_llmnr_packet中的要求设置好llmnr请求包的请求字段数据。

然而在validate_llmnr_packet函数中

image-20230522112617962

由于查询类型占用两字节,而查询类型又必须为1、12、28、255中的一种,所以这两字节中有一字节肯定为0,但是这样的话

在后面的calc_byte_array_length函数中,碰到0就会停下来,所以实际上后续的栈溢出是无法实现的,在类型这里就会被卡住。

非常可惜。

--- 本文结束 The End ---