Signal-Server Dropwizard admin on :8081
Signal-Server v20261007.0.0 never overrides Dropwizard 5.0.2 admin. Default HTTP :8081 binds all interfaces with no auth. Unauth GET metrics and thread dumps; POST log-level, GC, and Signal's set-request-logging-enabled. Labbed. Not RCE. No CVE yet.
- Name
- Signal-Server Dropwizard admin on :8081
- Type
- 0-day analysis
- CVE
- n/a
- CVE Risk
- high
- Disclosure Status
- public
- Vendor
- Signal Messenger
- Affected
- Signal-Server v20261007.0.0 (a15d5cd) Dropwizard 5.0.2. In-tree sample.yml has no server block. A YAML that only lists application connectors still inherits plaintext 8081 on all interfaces.
- Published
- 11 Oct 2026
- Updated
- 11 Oct 2026
- Tags
- 0-day, signal-server, dropwizard, privilege-leftover, cwe-306, cwe-1188, unauthenticated
Port 8081 was never localhost
I am @abraxas_null. The proof of concept is on GitHub: abraxas/signal-server-dropwizard-admin (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh, poc.py. Authorized lab only. It talks to loopback.
This is Signal-Server v20261007.0.0 (a15d5cdbb937fc5d078b73ec7b4a725ddf4e9473). Dropwizard 5.0.2. No CVE yet. CWE-306 / CWE-1188. 7.3 High (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:L). Remote privilege leftover. Unauthenticated HTTP to the default admin connector.
Caddy's default admin listens on localhost. Dropwizard's default admin listens on every interface, port 8081, with no password. Signal-Server never sets adminConnectors, never sets a bind host, and never installs a security handler on that context. The documented sample YAML does not have a server: block. The test YAML sets applicationConnectors and still omits admin. WhisperServerService patches TLS passwords on application connectors only. ExternalRequestFilter is an application servlet filter. Then it registers one extra admin task and starts Jetty.
That is the leftover. The chat APIs on this pin did not give me a High. The ops plane did.
Why Signal-Server
I had just finished a SQLite 3.53.4 wave and an OpenLiteSpeed cookie leftover. Signal-Server is a large Java tree that people treat as "the server is dumb, the crypto is in the client." That is mostly true for sealed sender. It is not a reason to skip the Dropwizard process that accepts the HTTP.
I only wanted Critical or High. Remote and local. Medium stays on the floor unless it chains. SignalK, the marine NMEA product, has a pile of 2026 CVEs. That is a different GitHub org and a different protocol. I wrote that on the skip list before I opened a controller.
Signal's own intake is security@signal.org. There is no bounty. SECURITY.md on main 404s. The README still points at that mailbox.
Pinning v20261007.0.0
GitHub Releases for this repo is empty. The interesting tags are dated v2026*. Latest GA on the day I cloned was v20261007.0.0, commit a15d5cd, "Add SandboxAccountPoolCommand", 2026-10-07 14:55:33 -0500. Same SHA as origin/main HEAD. pom.xml still says <version>JGITVER</version> and artifactId TextSecureServer. Modules: api-doc, integration-tests, service, websocket-resources. Dropwizard 5.0.2, FoundationDB 7.3.76, Jetty 12.1.9, Java --release 26.
service/config/sample.yml is the documented minimal file that passes io.dropwizard.cli.CheckCommand. The comment at the top says unset values need filling and the rest is a local demonstration. It still has no server: key. Unspecified Dropwizard fields keep Java defaults.
The skip list is half the hunt
NVD has no CVE for signalapp/Signal-Server. GitHub advisories on that repo are empty. The names on signal.org/security are client and enclave work: Truong/Terzo sealed-sender papers, Nihal Talur on CDSI SGX V12. GHSL-2026-102 is Signal-Android. I did not re-file any of that.
Closed on this pin, from the recent dated tags:
9b8502bTOTP reuse. RedisSET NX totp_used::<aci>::<otp>. REST and gRPC login both callverifyTotp.3f7012dlarge gRPC responses truncated becausesetAutoReadran off the source event loop. The helper now hops.740116emulti-recipient Redis views for ACIs the server could not resolve. Empty resolved set writes nothing.bdf3e1aWebAuthn origin list. Register and authenticate shareSet<Origin>.fe39fcd/51af17egRPC unidentified PNI send/prekeys require a group-send token.92fdac8gRPC story rate limits match REST.e6aef34device-linking tokens must be canonical Base64URL.
Enrollment checkTotpMatches still treats REUSED as success. That path is confirm-retry of a pending TOTP key, not recovery. I left it.
Eighteen surfaces, one leftover
I split the tree: auth and registration lock, accounts and devices, keys and sealed sender, messages and MRM, attachments and CDN, backup and SVR, websocket and provisioning, gRPC omnibus TLS, payments and donations, calling and TURN, profile and directory, MFA WebAuthn TOTP, JWT and external credentials, workers and admin CLI, key transparency and the empty spam-filter submodule, outbound HTTP, rate limits and path join, patch siblings.
Most of that is Signal doing what Signal said it would do.
Account Basic is an ACI UUID plus SaltedTokenHash.verify. Change-number is primary-only and checks the target number's lock. Link tokens are HMAC, ten minutes, one Dynamo use. Combined UAK XOR is over resolved restricted keys. GST is GroupSendFullToken.verify on the destination set. Identified sends stamp sourceServiceId from AuthenticatedDevice only. Sealed-sender omits source. Backup anonymous ops want a ZK presentation and a signature that matches the stored public key. CDN object keys are server-random. Outbound FaultTolerantHttpClient defaults redirect NEVER. Omnibus H2 routes by exact :path to loopback Jetty or in-process LocalAddress("grpc"). Captcha in this clone is a no-op because spam-filter/ is an empty REDACTED submodule. failOpen still defaults false.
Unauth stories are intended. Unauth ACI existence is a Medium enum. PayPal returnUrl is passed to Braintree, not fetched. I did not file those.
The workers hunt was the one that did not come back empty.
The default admin connector
Dropwizard 5.0.2 DefaultServerFactory initializes:
private List<ConnectorFactory> adminConnectors =
Collections.singletonList(HttpConnectorFactory.admin());HttpConnectorFactory.admin() sets port = 8081 and leaves bindHost null. buildConnector calls connector.setHost(bindHost). A null host is Jetty for all interfaces. Dropwizard 5 AdminFactory has healthChecks and tasks. It does not have adminUsername / adminPassword. Those keys were an older Dropwizard. createAdminServlet mounts AdminServlet at /* with no ConstraintSecurityHandler.
Signal-Server never names adminConnectors under src/. sample.yml has no server: block. test.yml sets applicationConnectors to h2c :8080 and still omits admin, so 8081 stays the default. WhisperServerService.run walks application connectors for the TLS keystore password, then:
environment.admin().addTask(new SetRequestLoggingEnabledTask());ExternalRequestFilter is environment.servlets(), application context, configured paths only.
Three worker commands extend io.dropwizard.core.cli.ServerCommand and call super.run: message-persister-service, scheduled-apn-push-notification-sender-service, process-idle-device-notification-jobs. Their dummy Application.run() is empty, so the worker application port has no Signal Jersey resources. Admin is still the framework default. The in-tree comment on the idle-job worker is honest: even though they are not serving traffic, ServerCommand subclasses need a valid server configuration.
A production YAML that only lists TLS applicationConnectors on 443 still gets plaintext 8081 on all interfaces. Binding admin to loopback, emptying adminConnectors, or putting auth on that context is extra config this pin does not ship.
Wrong turn: booting the real jar
I wanted java -jar … server sample.yml. sample.yml is valid for check. run wants FoundationDB 7.3.76, Dynamo tables, Redis, Stripe, Braintree, a secrets bundle, and the rest of Signal's managed objects. That is not a loopback lab. The leftover is the connector ServerCommand starts before any of that is healthy.
The lab is a Dropwizard 5.0.2 gadget: Configuration extends io.dropwizard.core.Configuration the same way WhisperServerConfiguration does, YAML with no server: block, and Signal's extra task name, query string, and println contract. Java 21 JRE. Not JDK 26. Same dropwizard-core artifact the pin depends on.
A second service uses bindHost: 127.0.0.1. A third service is an attacker on the compose network. From the attacker, http://vuln:8081/ping is 200 pong. http://patched:8081/ping is a URLError. /proc/net/tcp6 on vuln is *:8081. On patched it is IPv4-mapped loopback ::ffff:127.0.0.1. The first parser I wrote treated that mapped address as "not loopback" and spun for a minute. That was a wrong turn inside the lab, not inside Signal.
What an attacker can do
Anyone who can open TCP to :8081 gets the whole Dropwizard 5 admin servlet plus Signal's extra task. No Authorization header. This is the privilege set. It is not RCE, not account takeover, and not a read of sealed message bodies.
Recon and process data
GET /ping. Body pong. The admin listener is up.
GET /metrics. Full Codahale registry as JSON. JVM heap and non-heap, GC, thread counts, Jetty connector meters, 4xx/5xx gauges, timers. Fingerprint the process. Watch load. Confirm that a later log-level or gc did something. Lab body started with "version":"4.0.0","gauges":{...jetty...}.
GET /threads. Full JVM thread dump: name, id, state, stack frames. Lab dump opened on "dw-admin-51" id=51 state=RUNNABLE in sun.nio.ch.EPoll.wait. On a live Signal node the same dump can include class names, logger names, file paths, hostnames, and any string that happens to sit on a stack. It is not a heap dump. It is not a queue dump. It is every stack in the JVM at that instant.
GET /healthcheck. Registered checks. This tree never registers extras, so you get the default deadlock check. Lab: {"deadlocks":{"healthy":true,...}}. 200 vs 500 is the JVM's opinion of a deadlock.
GET /tasks. Enumerate tasks. Lab: gc, log-level, set-request-logging-enabled.
Integrity of logging and GC
POST /tasks/log-level?logger=<name>&level=<LEVEL>. Set any Logback logger to TRACE, DEBUG, INFO, WARN, ERROR, or OFF. TRACE makes chatty loggers print more, including values a developer put in a log statement. OFF on a security or access logger goes dark. The attacker does not receive the log stream over this HTTP. They change what the process writes to disk or stdout. Lab oracle: logger SIGNAL-SERVER-DROPWIZARD-ADMIN-WITNESS to TRACE. Response: Configured logging level for SIGNAL-SERVER-DROPWIZARD-ADMIN-WITNESS to TRACE.
POST /tasks/gc. System.gc(). Body Running GC... / Done!. A stop-the-world nudge. Not process death.
POST /tasks/set-request-logging-enabled?enabled=true. Signal-only. WhisperServerService registers it. Enables RequestLogManager's access-log filter. After that, HTTP to the application connector can write method, URI, and headers (including Authorization Basic) to the access log on disk. The admin client does not get those lines back. On the three worker ServerCommands the application port has no Signal Jersey resources, so this task matters on server. enabled=false turns it off. Lab body: Request logging now enabled.
What this leftover does not grant
No shell. No ProcessBuilder. No Runtime.exec. No heap-dump servlet: GET /heapdump is 404. No sealed envelopes, prekeys, SVR secrets, or Dynamo/FDB rows through admin. No account create or delete, no device link, no registration-lock skip. No rewrite of YAML or bind addresses. Enabling request logging is not by itself credential theft. Theft needs a later read of those files: disk, a log shipper, or a logger you turned to TRACE.
Caddy's localhost admin is a documented trust boundary. This bind is unrestricted. That is why it is in bar.
Lab
cd lab
./run.shLoopback 127.0.0.1:19010. Compose project signal-server-dropwizard-admin. Witness SIGNAL-SERVER-DROPWIZARD-ADMIN-WITNESS. Last line:
SUCCESS signal-server-dropwizard-admin how=unauth-admin-8081 ping=200 metrics=200 threads=200 healthcheck=200 tasks=200 log-level=TRACE gc=200 set-log=enabled bind=* remote-vuln=200 remote-patched=fail SIGNAL-SERVER-DROPWIZARD-ADMIN-WITNESSFix
Bind admin to 127.0.0.1, set adminConnectors: [], or put basic-auth / mTLS on the admin context. Do not ship a YAML that only lists application connectors and assume 8081 stayed off.
Contact
Abraxas Labs - abraxaslabs.tech - github.com/abraxas - @abraxas_null - abraxas.null@proton.me