Shadow APIs & Zombie Endpoints
Severity: High | CWE: CWE-200, CWE-284 OWASP: A09:2021 – Security Logging and Monitoring Failures | A01:2021 – Broken Access Control
What Are Shadow APIs?
Shadow APIs (also called zombie or undocumented APIs) are endpoints that exist in a running application but are not included in the official API documentation, not monitored by security teams, and often not protected by the same controls as documented APIs. They fall into several categories:
- Zombie endpoints: old API versions (
/api/v1/) left running after v2 was deployed — often lack new security controls - Internal endpoints: admin/debug/management routes exposed unintentionally
- Legacy endpoints: deprecated routes kept for backward compatibility
- Framework-generated endpoints: auto-generated by ORM or framework (Spring Data REST, Django REST Framework browsable API, Loopback)
- Mobile/native app endpoints: separate API surface for mobile clients, often with weaker auth
Documented: GET /api/v2/users/{id} → proper auth + authorization
Shadow: GET /api/v1/users/{id} → old endpoint, no JWT verification
Shadow: GET /internal/admin/users → debug endpoint, no auth at all
Shadow: GET /api/v2/users/{id}/export → undocumented bulk export endpoint
Discovery Checklist
Phase 1 — Passive Discovery
- Check JS bundles for API endpoint strings (
/api/,/v1/,/internal/,/admin/) - Examine browser network tab during full application walkthrough
- Check mobile app (APK/IPA decompilation) for API strings
- Search GitHub/GitLab for the target’s source code — search for route definitions
- Check Wayback Machine / web.archive.org for historical API documentation
- Search in Google:
site:target.com /api/v1orsite:target.com swagger - Check common documentation endpoints:
/swagger.json,/openapi.yaml,/api-docs - Check JS source map files:
.js.mapfiles contain full source including routes
Phase 2 — Active Enumeration
- Fuzz API paths from wordlists (SecLists API paths)
- Try version number variations:
/api/v0/,/v1/,/v2/,/v3/,/2.0/,/2.1/ - Try common admin/debug paths:
/admin/,/debug/,/internal/,/management/ - Check Spring Boot Actuator, Django Debug Toolbar, Laravel Telescope
- Fuzz parameter names on existing endpoints — undocumented params
- Mirror known endpoint structure: if
/api/usersexists, try/api/users/export,/api/users/bulk,/api/users/admin
Phase 3 — Analyze Discovered Endpoints
- Test auth requirements: does old version require auth? Same token format?
- Test authorization: does v1 endpoint check same RBAC as v2?
- Test rate limiting: are shadow endpoints rate-limited?
- Test input validation: are shadow endpoints properly validated?
- Test for undocumented functionality: extra capabilities not in documented API
Payload Library
Payload 1 — API Version Discovery
# Fuzz API version numbers systematically:
ffuf -u https://target.com/FUZZ/users \
-w - << 'EOF' \
-mc 200,201,400,401,403 -o versions.json
api/v0
api/v1
api/v2
api/v3
api/v4
v0
v1
v2
v3
api/1
api/2
api/1.0
api/2.0
api/beta
api/alpha
api/dev
api/test
api/stable
api/preview
api/internal
api/private
api/legacy
api/old
api/deprecated
api/mobile
api/public
api/external
api/partner
EOF
# Or with numbered suffix:
for v in $(seq 0 10); do
for prefix in "api/v" "v" "api/" ""; do
url="https://target.com/${prefix}${v}/users"
status=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer TOKEN" "$url")
[ "$status" != "404" ] && echo "$status → $url"
done
done
# Test REST path with auth token from current session:
# Sometimes v1 accepts same Bearer token as v2:
TOKEN="CURRENT_SESSION_JWT_OR_COOKIE"
for endpoint in users orders products accounts payments invoices; do
for version in v0 v1 v2 v3 api api/v1 api/v2; do
url="https://target.com/$version/$endpoint"
status=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer $TOKEN" "$url")
[ "$status" != "404" ] && echo "[$status] $url"
done
done
Payload 2 — Documentation Endpoint Discovery
# Swagger / OpenAPI discovery:
swagger_paths=(
"/swagger.json"
"/swagger.yaml"
"/swagger-ui.html"
"/swagger-ui/"
"/swagger-ui/index.html"
"/openapi.json"
"/openapi.yaml"
"/api-docs"
"/api-docs.json"
"/api/swagger.json"
"/api/openapi"
"/v1/swagger.json"
"/v2/swagger.json"
"/api/v1/swagger.json"
"/api/v2/swagger.json"
"/docs"
"/docs/api"
"/redoc"
"/rapidoc"
"/api/schema"
"/schema.json"
"/graphql"
"/graphiql"
"/playground"
"/__docs__"
"/api/__docs__"
)
for path in "${swagger_paths[@]}"; do
status=$(curl -s -o /tmp/api_resp -w "%{http_code}" "https://target.com$path")
size=$(wc -c < /tmp/api_resp)
[ "$status" != "404" ] && echo "[$status, ${size}B] https://target.com$path"
done
# Download and parse Swagger/OpenAPI if found:
curl -s "https://target.com/swagger.json" | \
python3 -c "
import sys, json
spec = json.load(sys.stdin)
base = spec.get('basePath', '') or spec.get('servers', [{}])[0].get('url', '')
for path, methods in spec.get('paths', {}).items():
for method in methods:
if method in ['get','post','put','patch','delete']:
print(f'{method.upper():6} {base}{path}')
" 2>/dev/null || \
# YAML format:
curl -s "https://target.com/openapi.yaml" | python3 -c "
import sys; import yaml; spec=yaml.safe_load(sys.stdin)
[print(m.upper(), p) for p,ms in spec.get('paths',{}).items() for m in ms if m in ['get','post','put','patch','delete']]
" 2>/dev/null
# Spring Boot Actuator (common shadow API source):
for actuator_path in actuator actuator/health actuator/info actuator/env actuator/beans \
actuator/configprops actuator/mappings actuator/metrics actuator/logfile \
actuator/threaddump actuator/heapdump actuator/httptrace actuator/sessions \
manage manage/health manage/info; do
status=$(curl -s -o /dev/null -w "%{http_code}" "https://target.com/$actuator_path")
[ "$status" != "404" ] && echo "[$status] /$actuator_path"
done
Payload 3 — JS Bundle Endpoint Extraction
# Extract API endpoints from JavaScript bundles:
# Step 1: Find JS files:
curl -s "https://target.com/" | grep -oE 'src="[^"]+\.js[^"]*"' | sed 's/src="//;s/"//'
# Step 2: Download and extract API paths:
js_url="https://target.com/static/main.chunk.js"
curl -s "$js_url" | \
grep -oE '"(/api[^"]{1,100}|/v[0-9][^"]{1,100})"' | \
sort -u | \
grep -v '\.js\|\.css\|\.png\|\.svg'
# More comprehensive extraction:
python3 << 'EOF'
import re, requests, sys
target = "https://target.com"
# Get all JS URLs from homepage:
resp = requests.get(target, timeout=10)
js_urls = re.findall(r'(?:src|href)=["\']([^"\']*\.js[^"\']*)["\']', resp.text)
all_endpoints = set()
for js_path in js_urls:
url = js_path if js_path.startswith('http') else target + js_path
try:
js = requests.get(url, timeout=10).text
# Extract API endpoint patterns:
patterns = [
r'"(/(?:api|v\d|internal|admin|graphql|rest)[^"]{1,200})"',
r"'(/(?:api|v\d|internal|admin|graphql|rest)[^']{1,200})'",
r"`(/(?:api|v\d|internal|admin|graphql|rest)[^`]{1,200})`",
# Template literals with variables:
r'`(/(?:api|v\d)[^`]*)\$\{',
]
for pattern in patterns:
matches = re.findall(pattern, js)
all_endpoints.update(matches)
except Exception as e:
print(f"Error fetching {url}: {e}", file=sys.stderr)
for ep in sorted(all_endpoints):
print(ep)
EOF
# Source map extraction (if .map files are accessible):
# .js.map files contain full source code with all routes:
for js_file in $(curl -s https://target.com/ | grep -oE '[^"]+\.js'); do
map_url="${js_file}.map"
status=$(curl -s -o /tmp/sourcemap.json -w "%{http_code}" "https://target.com/$map_url")
if [ "$status" = "200" ]; then
echo "[!!!] Source map found: $map_url"
python3 -c "
import json
with open('/tmp/sourcemap.json') as f:
sm = json.load(f)
sources = sm.get('sources', [])
for s in sources: print(s)
"
fi
done
Payload 4 — Mobile App API Discovery
# Android APK — extract API endpoints:
# Step 1: Download APK (from Google Play or target)
# Step 2: Decompile:
apktool d target.apk -o target_decompiled/
# Step 3: Extract API strings:
grep -rE "https?://[^\"'<>]{5,}target\.com[^\"'<> ]{1,200}" target_decompiled/ \
--include="*.xml" --include="*.smali" --include="*.json" -h | sort -u
# Step 4: Search for API path constants:
grep -rE '"/api/|/v[0-9]/|/internal/|/graphql|/rest/' target_decompiled/ \
--include="*.smali" -h | grep -oE '"[^"]{5,200}"' | sort -u
# Step 5: Jadx decompilation for Java/Kotlin code:
jadx -d target_jadx/ target.apk 2>/dev/null
grep -rE '"(/api|/v[0-9]|/graphql|/rest|/internal|/admin)[^"]{1,200}"' \
target_jadx/sources/ -h | sort -u
# iOS IPA — extract from binary strings:
# Unzip IPA:
unzip target.ipa -d target_ipa/
# Extract strings from binary:
strings target_ipa/Payload/App.app/App | \
grep -E "^/api|^/v[0-9]|^https://api\." | sort -u
# Frida — hook HTTP calls at runtime (Android/iOS):
frida -U -l - PackageName << 'EOF'
// Hook OkHttp (Android):
Java.perform(function() {
var OkHttpClient = Java.use('okhttp3.OkHttpClient');
var Request = Java.use('okhttp3.Request');
var RealCall = Java.use('okhttp3.internal.connection.RealCall');
RealCall.execute.implementation = function() {
var req = this.request();
console.log('[HTTP] ' + req.method() + ' ' + req.url());
return this.execute();
};
});
EOF
Payload 5 — Undocumented Parameter Discovery
# Fuzz parameters on existing endpoints:
# Known endpoint: GET /api/v1/users/123
# Test for undocumented query parameters:
ffuf -u "https://target.com/api/v1/users/123?FUZZ=1" \
-H "Authorization: Bearer TOKEN" \
-w /usr/share/seclists/Discovery/Web-Content/burp-parameter-names.txt \
-mc 200 -fs BASELINE_RESPONSE_SIZE
# High-value undocumented params to try manually:
for param in debug verbose admin export format include fields embed expand \
all raw limit offset version v format output include_deleted with_deleted \
full bypass override internal dev test preview draft; do
resp=$(curl -s -o /dev/null -w "%{http_code}:%{size_download}" \
-H "Authorization: Bearer TOKEN" \
"https://target.com/api/v1/users/123?$param=true")
echo "$param → $resp"
done
# Test HTTP verb changes on known endpoints:
for method in GET POST PUT PATCH DELETE HEAD OPTIONS TRACE; do
status=$(curl -s -o /dev/null -w "%{http_code}" -X "$method" \
-H "Authorization: Bearer TOKEN" \
"https://target.com/api/v1/users/123")
echo "$method → $status"
done
# Test path variations on known endpoints:
base="https://target.com/api/v1/users/123"
for suffix in "" "/export" "/admin" "/raw" "/full" "/details" "/profile" \
"/permissions" "/roles" "/tokens" "/sessions" "/audit" "/history" "/logs" \
".json" ".xml" ".csv" ".xlsx" "?format=json" "?format=xml"; do
status=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer TOKEN" \
"$base$suffix")
[ "$status" != "404" ] && echo "[$status] $base$suffix"
done
Payload 6 — Admin / Internal API Probing
# Common admin/internal paths to probe:
admin_paths=(
"admin" "administrator" "admin/api" "manage" "management"
"internal" "internal/api" "private" "ops" "operations"
"debug" "debug/info" "debug/routes" "debug/env"
"console" "dashboard" "panel" "cp" "controlpanel"
"system" "sys" "config" "configuration" "settings"
"health" "status" "metrics" "stats" "monitoring"
"tools" "utils" "helpers" "maintenance" "maint"
"_api" "__api__" "api_admin" "admin_api"
"staff" "internal/users" "internal/admin"
"api/admin" "api/internal" "api/private" "api/system"
# Framework specific:
"telescope" "horizon" "nova" # Laravel
"sidekiq" "resque" "delayed_job" # Rails
"flower" # Celery monitoring
"kibana" "grafana" "prometheus"
"phpmyadmin" "adminer" "pgadmin"
)
for path in "${admin_paths[@]}"; do
status=$(curl -s -o /tmp/resp -w "%{http_code}" "https://target.com/$path")
size=$(wc -c < /tmp/resp)
if [ "$status" != "404" ]; then
echo "[$status, ${size}B] /$path"
[ "$status" = "200" ] && head -c 200 /tmp/resp && echo
fi
done
# Check for Spring Boot Actuator full exposure:
curl -s "https://target.com/actuator" | python3 -m json.tool 2>/dev/null | \
grep '"href"' | grep -oE 'http[^"]+' | while read url; do
echo "[Actuator endpoint] $url"
curl -s "$url" | python3 -m json.tool 2>/dev/null | head -20
done
Payload 7 — Wayback Machine & Google Dorking
# Wayback Machine API — find historical endpoints:
python3 << 'EOF'
import requests
domain = "target.com"
# CDX API — list all archived URLs:
url = f"http://web.archive.org/cdx/search/cdx"
params = {
"url": f"{domain}/api/*",
"output": "json",
"fl": "original,timestamp,statuscode",
"filter": "statuscode:200",
"collapse": "urlkey",
"limit": 500
}
resp = requests.get(url, params=params, timeout=30)
rows = resp.json()
# First row is headers:
if rows:
headers = rows[0]
for row in rows[1:]:
record = dict(zip(headers, row))
print(f"[{record.get('statuscode')}] [{record.get('timestamp')[:8]}] {record.get('original')}")
EOF
# Google dorking for exposed API docs:
# site:target.com swagger OR openapi OR api-docs OR "API Reference"
# site:target.com inurl:v1 OR inurl:v2 filetype:json
# site:target.com ext:yaml OR ext:json inurl:api
# GitHub dorking for target's API routes:
# org:target-github-org "api/v1" OR "api/v2" route path endpoint
# site:github.com target.com "/api/internal"
# Shodan / Censys for exposed API docs:
shodan search "hostname:target.com swagger"
shodan search "hostname:target.com api-docs"
# Or via Shodan web interface: https://www.shodan.io/search?query=hostname%3Atarget.com+swagger
Tools
# kiterunner — API endpoint discovery with context-aware routing:
git clone https://github.com/assetnote/kiterunner
# Using assetnote wordlists:
kr scan https://target.com -w routes-large.kite \
-H "Authorization: Bearer TOKEN" \
-x 10 --fail-status-codes 404,301,302
# Download kiterunner wordlists:
wget https://wordlists-cdn.assetnote.io/data/kiterunner/routes-large.kite.tar.gz
# ffuf — API path fuzzing:
ffuf -u https://target.com/FUZZ \
-w /usr/share/seclists/Discovery/Web-Content/api/api-endpoints.txt \
-H "Authorization: Bearer TOKEN" \
-mc 200,201,400,401,403 -of json -o ffuf_results.json
# Arjun — hidden parameter discovery on known endpoints:
arjun -u https://target.com/api/v2/users -m GET \
-H "Authorization: Bearer TOKEN" \
--stable --rate-limit 10
# gau — get all URLs from Wayback Machine, Common Crawl, OTX:
gau target.com | grep api | sort -u
# gospider — spider target for API endpoints:
gospider -s https://target.com -d 3 -c 10 \
-H "Cookie: session=SESSION_COOKIE" \
-o /tmp/spider_results/
# linkfinder — extract endpoints from JS:
python3 linkfinder.py -i https://target.com/static/app.js -o cli
# SecLists API wordlists:
ls /usr/share/seclists/Discovery/Web-Content/api/
# api-endpoints.txt, api-endpoints-res.txt, objects.txt
# nuclei — detect common shadow API issues:
nuclei -target https://target.com \
-t exposures/apis/swagger-api.yaml \
-t exposures/apis/openapi.yaml \
-t default-logins/ \
-t exposures/apis/
Remediation Reference
- API inventory: maintain a complete, up-to-date inventory of all API endpoints — include internal, mobile, and legacy endpoints in security reviews
- Deprecation process: when retiring an API version, actually decommission it — don’t just stop documenting it; add a sunset date and hard-deadline removal
- Same security controls on all versions: apply authentication, authorization, rate limiting, and input validation uniformly across all API versions — older versions should not be exempt
- Deny by default: use a gateway or reverse proxy that explicitly allowlists documented API paths — return 404/410 for anything not in the allowlist
- Remove debug endpoints from production: Spring Actuator, Django Debug Toolbar, Laravel Telescope — disable or properly secure all management endpoints in production
- Source map protection: do not serve
.js.mapfiles in production — they expose full source code including route definitions - API gateway: route all traffic through an API gateway that enforces auth and authorization — shadow endpoints that bypass the gateway are automatically blocked
Part of the Web Application Penetration Testing Methodology series.