Scope: an authorized security assessment of this service, run
entirely against local targets (native macOS build and the local Docker
image) on the developer’s own machine — not a third-party or production
system. Companion to Testing & Validation, which
covers functional correctness; this page covers adversarial
robustness — what happens when inputs and traffic patterns are
deliberately hostile rather than merely varied.
Final results (current state)
Seven phases, five findings worth acting on. The critical one has already been fixed and verified; the rest are real but lower-severity or already mitigated by the service’s architecture.
What held up well: the detector thread-safety fix (zero errors across
all adversarial concurrency levels in Phase 3), the app-level input
validation contract (9/11 multipart edge cases in Phase 4 matched
documented behavior exactly, including magic-byte format checking), the
/v1/health//v1/ready endpoints’ isolation from worker-pool load, and
the non-root/no-secrets/minimal-runtime-package Docker posture. No
crashes, no hangs achieved at production-realistic scale, no information
disclosure found anywhere.
Phase 1: Unhandled-exception hang — code-level gap confirmed, not reproduced by attack
Method:ThreadPool::workerLoop() (src/ThreadPool.h:77-86) catches
(...) around each job and silently drops the result if one throws — the
captured Drogon response callback is destroyed without ever firing, and
no request/idle timeout is configured anywhere in main.cpp. Neither
EmbedController nor CompareController wraps
decodeAndValidate/detectAndEmbed/cosineSimilarity in their own
try/catch. On paper, a crafted-but-decodable image that throws a
cv::Exception/Ort::Exception mid-pipeline should leave the client
connection hanging indefinitely with no response.
We tried to actually trigger that throw, systematically, across four
vector families (~40 crafted images total), all sent sequentially against
the native build with server stderr tailed in parallel:
- Extreme aspect ratios
- Truncated files
- Anisotropic stretching
- Unusual color formats
Images at/near the 20px–4096px validation boundary designed to make
cv::resize in detectFaces() (triggered when the long side exceeds
640px) compute a near-zero short-side dimension: 4096×20, 20×4096,
4095×21, 4096×4095, 4000×22, etc.Result: all handled cleanly — either 422 no_face_detected in
~0.01s (random noise, no face present, as expected) or 413 for the
two combinations that exceeded the 12MB body cap. cv::resize did not
throw for any tested ratio; the short side never actually rounds to
zero across the accepted dimension range given the fixed 640px cap.Phase 2: Decompression-bomb quantification — confirmed, high amplification
Method:ImageUtils::decodeAndValidate (src/ImageUtils.cpp:6-42)
checks raw upload size (10MB) before cv::imdecode, but the 4096px
dimension check runs after — so a small, low-entropy PNG fully decodes
(and allocates its full bitmap) before any dimension rejection can happen.
Two payloads, both solid-color (gray) so they compress to a tiny file
despite large logical dimensions:
4096×4096— exceeds the limit, rejected after decode (measures decode-then-reject cost)4095×4095— the largest image the service will actually accept and process further (measures the real, sustained attack surface)
face_engine process was polled every 20ms via ps -o rss=
throughout each request.
- Single-shot amplification
- Concurrency sweep (the real finding)
*Measured immediately after the 4096×4096 case above, so this
baseline was already elevated — see the concurrency sweep below for
a cleaner picture of accepted-image cost in isolation.Both cases return
422 (image_dimensions_exceed_limit for the
4096 case; no_face_detected for the 4095 case — a solid gray image
has no face, so detection legitimately finds nothing even though the
full decode-and-detect pipeline still runs). The 4096 case’s larger
delta is expected: it briefly holds both the full decode buffer and
momentarily exists before the post-decode check frees it.cv::haveImageReader
plus manually parsing width/height from the format header) before calling
cv::imdecode, so oversized images are rejected without ever being fully
decoded into memory.
Phase 3: Adversarial flood — confirms Phase 1 & Phase 2 findings under sustained load
Method: rebuilt the benign load-test methodology from Testing & Validation §6 (concurrency sweep 1/2/4/8/16/24/32, 12-second sustained windows, RPM/p50/p95/error tracking), but adversarially — each request randomly picks between the Phase 2 decompression bomb, a Phase 1 extreme-aspect-ratio payload, and a normal control photo, instead of only normal photos. RSS polled every 100ms throughout each window, plus again 30 seconds after the window closes (recovery check), plus a mid-floodGET /v1/ready probe
to test I/O-thread responsiveness independent of worker-pool load.
Three findings:
- Zero errors at every concurrency level, including under adversarial payloads. This confirms the earlier detector thread-safety fix (see Architecture → InferenceEngine) holds under hostile, not just benign, concurrent traffic — worth stating explicitly since the pre-fix version of this service produced up to 29 errors/timeouts per 12s window under purely benign concurrent load.
- RSS grows large but does partially recover — e.g. at N=8, RSS dropped from a peak of 980MB to 382MB within 30 seconds idle; at N=16 and N=32, roughly 200MB was reclaimed. This is a more complete picture than Phase 2 alone suggested (that test didn’t wait long enough to observe recovery) — the allocator does return memory to the OS, just slowly and incompletely, not instantly or fully. RSS never returns anywhere close to the 32.5MB cold-start baseline within the timeframes tested here, so sustained adversarial traffic still produces a persistently elevated memory footprint, just not an unbounded runaway leak.
/v1/readystayed fully responsive (200, 1–3ms) at every concurrency level, including N=32. This directly answers the I/O-thread- starvation question raised in the ground truth: at least up to 32 concurrent adversarial requests on this 10-core host,HTTP_IO_THREADS=2was not saturated enough to delay the lightweight health-check path, which — as established in Architecture — bypasses the worker pool entirely and only competes for I/O-thread time.
Raw RPM here (up to 2,518) is not directly comparable to the benign
baseline in
testing-report.mdx (peak 1,944 RPM) — this test’s payload
mix is two-thirds fast-reject images (bomb/extreme-aspect payloads both
return in ~10–30ms since they never reach the expensive align+embed
stage, having no detectable face) versus the benign baseline’s all-real-photo
mix, which always pays the full pipeline cost. Higher RPM here reflects a
cheaper average payload, not a faster server.Phase 4: Multipart edge-case sweep — two real findings, rest matches contract cleanly
Method: hand-built raw multipart bodies over raw sockets/http.client
(not requests’ own encoder, since several cases need to break the format
in ways the library won’t allow) against POST /v1/embed, diffed against
the documented error contract.
- Cases 1-7, 9: all match documented contract
- Case 8 (real finding): declared-Content-Length overrun
- Case 10 (worth knowing): Content-Length under-declaration
Nine of eleven tested cases behaved exactly as documented — no crashes,
no hangs, no info leaks, no surprises.
ImageUtils, both
controllers) is solid — nine of eleven cases matched documented behavior
exactly, including the security-relevant ones (magic-byte validation,
first-match field handling). The two real findings are both at the HTTP
connection-framing layer (Drogon itself, not app code): a genuine
60-second slow-connection hold that doesn’t achieve DoS at small scale
thanks to Drogon’s async I/O model, and standard request-framing behavior
whose risk is entirely about what sits in front of this service, not the
service itself.
Phase 5: Dependency / CVE review — one critical, directly reachable finding
Method: exact installed versions pulled from the actual built image (shell +dpkg confirmed present in the ubuntu:24.04-based runtime
image):
Cross-referenced these plus the three source-built pins (OpenCV
4.10.0,
Drogon v1.9.7, ONNX Runtime 1.19.2, from the Dockerfile ARGs) against
OSV.dev, GitHub’s advisory database, and NVD directly (GitHub’s own
per-repo advisory endpoints returned nothing for opencv/opencv,
drogonframework/drogon, or microsoft/onnxruntime — NVD keyword search
was the source that actually found something).
Fixed and verified.
OPENCV_VERSION in the Dockerfile is now
4.12.0. Rebuilt the image and confirmed libopencv_core.so.4.12.0 is
the version actually linked in the runtime container (not just the
build arg — checked the real .so file inside the built image).
Re-ran functional validation against the patched image: /v1/ready →
200, /v1/embed returns a correct embedding, /v1/compare on identical
images returns similarity: 1.0, match: true — the version bump didn’t
regress anything. The reachability path described above (arbitrary
.jp2 uploads hitting imgcodecs) still exists structurally — this
service still accepts any image format OpenCV can decode, by design —
but the specific vulnerable code this CVE describes no longer ships in
the image.- util-linux (libuuid1) — 6 CVEs, none reachable
- zlib — 1 CVE, unlikely reachable
- Drogon v1.9.7, ONNX Runtime 1.19.2, libjsoncpp, libssl3t64
UBUNTU-CVE-2026-13595, -27456, -53612, -53613, -53614,
-53615 — all in libblkid (partition/filesystem-type probing) or
the mount(8) SUID binary (local privilege escalation via TOCTOU).
This container installs only the libuuid1 runtime library (UUID
generation, used by Drogon) — not libblkid1, not the mount
binary, not util-linux proper. None of the vulnerable code paths
are present in this image at all. Not reachable.Phase 6: Docker image scan (trivy) — confirms Phase 5, plus a meta-finding about trivy itself
Method:brew install trivy (0.73.0), then trivy image (triage +
full) against face-engine:baked and trivy config . against the
Dockerfile.
- Image scan: 0 HIGH/CRITICAL, 18 total
- Meta-finding: trivy is blind to the critical finding
- Config scan: 1 real finding, already known
libmount1, libp11-kit0,
libsmartcols1, libsystemd0, libudev1, login, mount, passwd,
util-linux, zlib1g, libuuid1. The libuuid1/zlib1g entries
corroborate Phase 5’s
manual OSV lookup exactly (same CVE IDs, same reachability
conclusion: not applicable, since this container never invokes
mount, login, or loop-device setup). The rest
(libp11-kit0, libsystemd0, libudev1, login, passwd,
mount, libsmartcols1, libmount1) are base-image packages this
Dockerfile never explicitly installs — they ship with ubuntu:24.04
regardless. None are reachable through this service’s actual
functionality (no shell execution, no local multi-user context, no
systemd/mount usage anywhere in src/).libuuid1/zlib1g findings independently, and
confirmed the Dockerfile misconfiguration count precisely) but cannot
replace the manual review — it is architecturally blind to any dependency
this project builds from source rather than installs as a package. Any
future scanning workflow for this project should treat trivy as covering
“the OS layer” only, with OpenCV/Drogon/ONNX Runtime version tracking
requiring a manual or custom process.
Phase 7: No-auth/TLS/rate-limit confirmation (light touch)
This is a documented design choice, not a surprise finding, so this phase is confirmation rather than deep testing. Confirmed viacurl -v: GET /v1/health, GET /v1/ready, and
POST /v1/embed all accept plain HTTP with zero authentication headers
and return normal 200 responses — no TLS, no API key, no session, no
rate limiting anywhere in this container.
This matches what’s already documented:
“the service is meant to sit behind a gateway that handles
routing/TLS/auth” — api-reference/introduction.mdx
“Rate limiting — belongs at the load balancer/gateway, not baked into
this service” — deployment.mdx
Residual risk, framed concretely rather than abstractly: every finding
in Phases 1–5 of this report assumes an attacker can reach /v1/embed or
/v1/compare directly. That’s exactly what “no auth, no gateway” means in
practice — if this container is ever deployed with its port exposed
directly to untrusted traffic (a misconfiguration, not the documented
intent), every finding above becomes immediately exploitable by anyone,
not just an authorized tester on localhost. This is the strongest
argument for treating the gateway requirement as load-bearing, not
optional — especially given Phase 5’s critical finding is trivially
reachable by design (any file upload triggers imdecode).
Remediation
Ordered by priority, not by discovery order:1
✅ Done — Bump OPENCV_VERSION to >= 4.12.0 (critical, do first)
One-line change in
Dockerfile: ARG OPENCV_VERSION=4.10.0 →
4.12.0, then rebuild. Closes CVE-2025-53644 (CVSS 9.8), confirmed
reachable through the public upload endpoint. See
Phase 5.
Applied and verified: rebuilt, confirmed libopencv_core.so.4.12.0
is the version actually linked in the container, and re-ran functional
validation (/v1/ready, /v1/embed, /v1/compare) against the
patched image with no regressions.2
Reject on decoded dimensions before/during decode, not after
ImageUtils::decodeAndValidate (src/ImageUtils.cpp) currently
checks kMaxDimension after cv::imdecode has already allocated
the full bitmap. Use a header-only peek (parse width/height from the
format header, or cv::imdecode with a size-limiting flag if
available in the OpenCV version landed above) to reject oversized
images before the expensive allocation happens. Closes the
amplification finding from Phase 2/Phase 3.3
Wrap controller pipeline calls in try/catch; add a request timeout
Neither
EmbedController nor CompareController catches exceptions
from decodeAndValidate/detectAndEmbed/cosineSimilarity — add a
try/catch mapping any exception to a proper 500 JSON error (matching
the existing error-response shape) rather than relying on
ThreadPool’s catch-all, which silently drops the callback. Separately,
configure a Drogon request/idle timeout in main.cpp (currently
unset) so even an unanticipated hang self-resolves instead of holding
a connection indefinitely. Addresses the code-level gap from
Phase 1
and reduces the ~60s window found in
Phase 4.4
Add a bounded queue depth to ThreadPool
ThreadPool::enqueue() (src/ThreadPool.h) has no cap and no
rejection path. Add a max-depth check (pendingJobs() already exists
as the read) that rejects new work with a 503/429 once the queue
is saturated, rather than accepting unbounded work that only ever
grows memory usage under sustained load (see
Phase 3).5
Add a HEALTHCHECK instruction to the Dockerfile
Low effort, low severity, but free —
HEALTHCHECK --interval=30s CMD curl -f http://localhost:8080/v1/ready || exit 1 (or equivalent)
lets container orchestrators detect an unhealthy instance without
depending on an external readiness probe being configured correctly.
See Phase 6.6
Pin runtime apt package versions in the Dockerfile
libjsoncpp25, libssl3t64, libuuid1, zlib1g are currently
installed without version pins, so the exact version baked into any
given image build depends on whatever’s current in the Ubuntu 24.04
apt repo that day. Pinning (libssl3t64=3.0.13-0ubuntu3.12, etc.)
makes builds reproducible and makes future CVE reviews exact rather
than approximate — trades that for needing to manually bump pins when
intentionally taking a security update. See
Phase 5.