Skip to content

Instantly share code, notes, and snippets.

@NoelJacob
Last active July 3, 2026 11:37
Show Gist options
  • Select an option

  • Save NoelJacob/165df50dc508d397f88e9be2b75fcee7 to your computer and use it in GitHub Desktop.

Select an option

Save NoelJacob/165df50dc508d397f88e9be2b75fcee7 to your computer and use it in GitHub Desktop.
#!/usr/bin/env python3
"""
chrome_mv2_patch_linux.py
==========================
Linux port of tophf's Windows PowerShell chrome.dll patcher:
https://gist.github.com/tophf/3fac58988fb94bd83aef69c9f18d1948
which restores the same behaviour that the ungoogled-chromium project's
source-level patch produces:
https://github.com/ungoogled-software/ungoogled-chromium/blob/master/patches/core/ungoogled-chromium/extensions-manifestv2.patch
WHAT THIS DOES
--------------
Since Chrome ~140, the "Manifest V2 extensions are deprecated" behaviour is
driven by a handful of internal base::Feature flags:
ExtensionManifestV2Unsupported
ExtensionManifestV2Disabled
ExtensionsManifestV3Only
AllowLegacyMV2Extensions
Each base::Feature is a small C++ struct that starts with a pointer to the
flag's name (a string literal) immediately followed by its default state
(0 = disabled by default, 1 = enabled by default). Once a milestone locks
these down, --enable-features/--disable-features on the command line stops
working, so the only lever left is to change the compiled-in default
directly inside the binary on disk.
The Windows script finds each name string in the .rdata section, then scans
the .data section for a pointer that matches that string's address, and
flips the very next word (the default-state byte) to the desired 0/1.
Windows PE and Linux ELF differ in one important way for this technique:
on ELF, a pointer-to-a-string-literal stored inside otherwise-const data
lives in a read-only-after-relocation section (.data.rel.ro / .data.rel.ro.local)
and is fixed up at load time via a RELATIVE relocation entry in .rela.dyn.
Rather than blindly byte-scanning for the address (which depends on
implementation details of whether the linker also wrote the redundant
addend into the section), this script uses the relocation table itself as
the authoritative source of truth: it looks up which relocation's addend
equals the address of each feature-name string, which tells us exactly
where the pointer field lives and therefore where the default-state byte
that follows it lives.
USAGE
-----
sudo python3 chrome_mv2_patch_linux.py # auto-detect chrome/chromium
sudo python3 chrome_mv2_patch_linux.py /path/to/chrome # patch a specific binary
python3 chrome_mv2_patch_linux.py --dry-run [path] # show findings, write nothing
sudo python3 chrome_mv2_patch_linux.py --restore [path] # restore from the .bak backup
A "<binary>.bak" backup is made automatically the first time a byte is
actually changed. --restore copies it back over the patched binary.
READ BEFORE RUNNING
--------------------
* You need write access to the binary -- on a normal apt/dnf install under
/opt/google/chrome*, that means running with sudo.
* This edits Google's shipped binary. It's unsupported and against the
spirit of Chrome's intended behaviour; Google can and does change string
names / struct layout between releases, which will break this and require
an update to the FEATURES list below or a rewrite.
* Chrome's auto-updater will silently replace your patched binary the next
time it updates. Either disable auto-update or expect to re-run this
after every Chrome update (that's also true of the Windows version).
* If Chrome exits/restarts after patching without you having acknowledged
any pending MV2 extension warnings first, some MV2 extensions and their
data can reportedly be removed -- close Chrome fully, patch, then relaunch.
* This script refuses to write anything unless it can find an unambiguous,
single relocation match for a feature's name string. It does not guess.
"""
import argparse
import mmap
import shutil
import struct
import sys
from pathlib import Path
# --- feature flags to patch: name -> desired default state (True=enabled) --
FEATURES = {
"ExtensionManifestV2Unsupported": False, # keep MV2 "unsupported" mode OFF
"ExtensionManifestV2Disabled": False, # keep MV2 "disabled" mode OFF
"ExtensionsManifestV3Only": False, # don't force MV3-only
"AllowLegacyMV2Extensions": True, # explicitly allow legacy MV2 (if present)
}
DEFAULT_SEARCH_PATHS = [
"/opt/google/chrome/chrome",
"/opt/google/chrome-beta/chrome",
"/opt/google/chrome-unstable/chrome",
"/opt/google/chrome-dev/chrome",
"/usr/lib/chromium/chromium",
"/usr/lib/chromium-browser/chromium-browser",
"/usr/lib64/chromium/chromium",
]
R_X86_64_RELATIVE = 8
ELF64_EHDR = struct.Struct("<16sHHIQQQIHHHHHH")
ELF64_SHDR = struct.Struct("<IIQQQQIIQQ")
ELF64_RELA = struct.Struct("<QQq")
class ElfParseError(RuntimeError):
pass
def find_binary(explicit_path=None):
if explicit_path:
p = Path(explicit_path)
if not p.is_file():
sys.exit(f"error: {p} does not exist or is not a file")
return p
for cand in DEFAULT_SEARCH_PATHS:
p = Path(cand)
if p.is_file():
return p
found = shutil.which("chrome") or shutil.which("google-chrome-stable") or shutil.which("chromium")
if found:
# this is very likely a wrapper shell script rather than the real
# binary -- warn rather than silently patch the wrong file
print(f"warning: only found a launcher script at {found}; "
"pass the real binary path explicitly (see /opt/google/chrome*/chrome).")
sys.exit(
"error: could not auto-detect the Chrome/Chromium binary.\n"
"Pass the path explicitly, e.g.:\n"
" sudo python3 chrome_mv2_patch_linux.py /opt/google/chrome/chrome"
)
def parse_sections(data):
ident = data[:16]
if ident[:4] != b"\x7fELF":
raise ElfParseError("not an ELF file")
if ident[4] != 2:
raise ElfParseError("only 64-bit ELF binaries are supported (Linux Chrome is 64-bit only)")
if ident[5] != 1:
raise ElfParseError("only little-endian ELF binaries are supported")
(e_ident, e_type, e_machine, e_version, e_entry, e_phoff, e_shoff,
e_flags, e_ehsize, e_phentsize, e_phnum, e_shentsize, e_shnum,
e_shstrndx) = ELF64_EHDR.unpack_from(data, 0)
if e_shoff == 0 or e_shnum == 0:
raise ElfParseError("binary has no section headers (is it stripped in an unusual way?)")
raw_sections = []
for i in range(e_shnum):
off = e_shoff + i * e_shentsize
(sh_name, sh_type, sh_flags, sh_addr, sh_offset, sh_size, sh_link,
sh_info, sh_addralign, sh_entsize) = ELF64_SHDR.unpack_from(data, off)
raw_sections.append(dict(
name_off=sh_name, type=sh_type, flags=sh_flags, addr=sh_addr,
offset=sh_offset, size=sh_size, link=sh_link, info=sh_info,
entsize=sh_entsize,
))
shstrtab = raw_sections[e_shstrndx]
strtab_data = data[shstrtab["offset"]: shstrtab["offset"] + shstrtab["size"]]
def read_str(off):
end = strtab_data.find(b"\x00", off)
return strtab_data[off:end].decode("ascii", "replace")
sections = {}
for s in raw_sections:
name = read_str(s["name_off"])
s["name"] = name
sections[name] = s
return sections
def va_to_file_offset(sections, va):
"""Map a virtual address (assuming load bias 0, i.e. the raw preferred
address baked into the file -- true for PIE binaries) to a file offset."""
for s in sections.values():
if s["type"] == 8: # SHT_NOBITS (.bss) -- no file backing
continue
if s["addr"] <= va < s["addr"] + s["size"]:
return s["offset"] + (va - s["addr"])
return None
def find_string_occurrences(data, section, needle):
"""Find byte offsets (as VAs) of an exact, null-terminated occurrence of
`needle` inside `section`. Rejects partial/substring matches."""
hits = []
blob = data[section["offset"]: section["offset"] + section["size"]]
needle_b = needle.encode("ascii")
start = 0
while True:
idx = blob.find(needle_b, start)
if idx < 0:
break
start = idx + 1
before_ok = idx == 0 or blob[idx - 1] == 0
after_idx = idx + len(needle_b)
after_ok = after_idx >= len(blob) or blob[after_idx] == 0
if before_ok and after_ok:
hits.append(section["addr"] + idx)
return hits
def parse_relocations(data, sections):
"""Return {addend_va: [r_offset, ...]} for all R_X86_64_RELATIVE entries
across every .rela* section (normally .rela.dyn)."""
addend_map = {}
for name, s in sections.items():
if not name.startswith(".rela"):
continue
blob = data[s["offset"]: s["offset"] + s["size"]]
count = len(blob) // ELF64_RELA.size
for i in range(count):
r_offset, r_info, r_addend = ELF64_RELA.unpack_from(blob, i * ELF64_RELA.size)
r_type = r_info & 0xffffffff
if r_type != R_X86_64_RELATIVE:
continue
addend_map.setdefault(r_addend, []).append(r_offset)
return addend_map
def analyze(path):
with open(path, "rb") as f:
data = f.read()
sections = parse_sections(data)
rodata = sections.get(".rodata")
if rodata is None:
raise ElfParseError("could not find a .rodata section")
addend_map = parse_relocations(data, sections)
if not addend_map:
raise ElfParseError(
"could not find any R_X86_64_RELATIVE relocations (.rela.dyn) -- "
"this binary may be built differently than expected, or may not be a PIE."
)
results = {}
for feature, want_enabled in FEATURES.items():
entry = {"feature": feature, "want_enabled": want_enabled, "status": None}
string_vas = find_string_occurrences(data, rodata, feature)
if not string_vas:
entry["status"] = "not-found-in-binary"
results[feature] = entry
continue
matches = []
for va in string_vas:
for r_offset in addend_map.get(va, []):
matches.append(r_offset)
if not matches:
entry["status"] = "string-found-but-no-pointer-reloc"
results[feature] = entry
continue
if len(matches) > 1:
entry["status"] = f"ambiguous ({len(matches)} candidate pointer locations)"
entry["candidates"] = matches
results[feature] = entry
continue
ptr_va = matches[0]
state_va = ptr_va + 8 # default_state field immediately follows the name pointer
state_file_off = va_to_file_offset(sections, state_va)
if state_file_off is None:
entry["status"] = "state-field-outside-known-section"
results[feature] = entry
continue
current = data[state_file_off]
if current not in (0, 1):
entry["status"] = f"unexpected-state-byte(0x{current:02x}) -- refusing to touch"
entry["state_file_off"] = state_file_off
results[feature] = entry
continue
entry["status"] = "ok"
entry["state_file_off"] = state_file_off
entry["current_enabled"] = bool(current)
entry["needs_patch"] = bool(current) != want_enabled
results[feature] = entry
return results
def apply_patch(path, results, dry_run=False):
to_write = {r["state_file_off"]: (1 if r["want_enabled"] else 0)
for r in results.values()
if r["status"] == "ok" and r["needs_patch"]}
print(f"\n{path}")
for feature, r in results.items():
if r["status"] != "ok":
print(f" [SKIP] {feature}: {r['status']}")
continue
cur = "enabled" if r["current_enabled"] else "disabled"
want = "enabled" if r["want_enabled"] else "disabled"
if r["needs_patch"]:
print(f" [PATCH] {feature}: {cur} -> {want} (offset 0x{r['state_file_off']:x})")
else:
print(f" [OK] {feature}: already {want}")
if not to_write:
print("\nNothing to patch.")
return
if dry_run:
print(f"\n(dry run) would write {len(to_write)} byte(s), no changes made.")
return
backup = Path(str(path) + ".bak")
if not backup.exists():
print(f"\nBacking up original to {backup} ...")
shutil.copy2(path, backup)
else:
print(f"\nBackup already exists at {backup}, leaving it as-is.")
with open(path, "r+b") as f:
mm = mmap.mmap(f.fileno(), 0)
try:
for off, val in to_write.items():
mm[off] = val
mm.flush()
finally:
mm.close()
print(f"Patched {len(to_write)} byte(s) in {path}.")
print("Fully quit and relaunch Chrome for the change to take effect.")
def restore(path):
backup = Path(str(path) + ".bak")
if not backup.exists():
sys.exit(f"error: no backup found at {backup}")
shutil.copy2(backup, path)
print(f"Restored {path} from {backup}.")
def main():
ap = argparse.ArgumentParser(description=__doc__.split("USAGE")[0],
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("path", nargs="?", help="path to the chrome/chromium binary")
ap.add_argument("--dry-run", action="store_true", help="analyze and print, write nothing")
ap.add_argument("--restore", action="store_true", help="restore the binary from its .bak backup")
args = ap.parse_args()
binary = find_binary(args.path)
if args.restore:
restore(binary)
return
try:
results = analyze(binary)
except ElfParseError as e:
sys.exit(f"error: {e}")
if not args.dry_run and not (Path(binary).stat().st_mode & 0o200):
sys.exit(f"error: {binary} is not writable -- try running with sudo.")
apply_patch(binary, results, dry_run=args.dry_run)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import platform
import re
import shutil
import struct
import subprocess
import sys
import tempfile
import urllib.error
import urllib.parse
import urllib.request
import zipfile
from datetime import datetime, timezone
from pathlib import Path
DEFAULT_CHROME_VERSION = "101.0.4951.57"
META_FILENAME = ".crx-meta.json"
URL_ID_PATTERNS = [
re.compile(r"/detail/[^/]+/([a-z]{32})(?:[/?#]|$)"),
re.compile(r"/webstore/detail/[^/]+/([a-z]{32})(?:[/?#]|$)"),
re.compile(r"[?&]id=([a-z]{32})(?:[&#]|$)"),
re.compile(r"^([a-z]{32})$"),
]
def fail(message: str, code: int = 1) -> None:
print(f"error: {message}", file=sys.stderr)
raise SystemExit(code)
def normalize_extension_ref(value: str) -> str:
value = value.strip()
for pattern in URL_ID_PATTERNS:
match = pattern.search(value)
if match:
return match.group(1)
fail(f"invalid extension id or web store URL: {value}")
def detect_chrome_version() -> str:
for cmd in (
["google-chrome", "--version"],
["google-chrome-stable", "--version"],
["chromium", "--version"],
["chromium-browser", "--version"],
):
try:
result = subprocess.run(cmd, check=True, capture_output=True, text=True)
except (FileNotFoundError, subprocess.CalledProcessError):
continue
match = re.search(r"(\d+\.\d+\.\d+\.\d+)", result.stdout)
if match:
return match.group(1)
return DEFAULT_CHROME_VERSION
def detect_os() -> str:
return "mac" if platform.system() == "Darwin" else "linux"
def detect_arch() -> str:
return "arm64" if platform.machine().lower() in {"aarch64", "arm64"} else "x86-64"
def build_download_url(extension_id: str, chrome_version: str) -> str:
return "https://clients2.google.com/service/update2/crx?" + urllib.parse.urlencode(
{
"response": "redirect",
"os": detect_os(),
"arch": detect_arch(),
"os_arch": detect_arch(),
"prod": "chromecrx",
"prodchannel": "unknown",
"prodversion": chrome_version,
"acceptformat": "crx3",
"x": f"id={extension_id}&installsource=ondemand&uc",
}
)
def request_headers(extension_id: str, chrome_version: str) -> dict[str, str]:
return {
"User-Agent": f"Mozilla/5.0 Chrome/{chrome_version}",
"Referer": f"https://chromewebstore.google.com/detail/{extension_id}",
}
def resolved_url(extension_id: str, chrome_version: str) -> str:
request = urllib.request.Request(
build_download_url(extension_id, chrome_version),
method="HEAD",
headers=request_headers(extension_id, chrome_version),
)
try:
with urllib.request.urlopen(request) as response:
return response.geturl()
except urllib.error.HTTPError as exc:
fail(f"failed to resolve download URL for {extension_id}: HTTP {exc.code}")
except urllib.error.URLError as exc:
fail(f"network error while resolving {extension_id}: {exc.reason}")
def parse_version_from_url(url: str) -> str:
match = re.search(r"_([0-9][0-9A-Za-z._-]*)\.crx(?:[?#].*)?$", url)
if not match:
fail(f"could not determine version from URL: {url}")
return match.group(1)
def download_crx(extension_id: str, chrome_version: str, destination: Path) -> tuple[str, str]:
url = build_download_url(extension_id, chrome_version)
final_url = resolved_url(extension_id, chrome_version)
request = urllib.request.Request(url, headers=request_headers(extension_id, chrome_version))
try:
with urllib.request.urlopen(request) as response, destination.open("wb") as handle:
shutil.copyfileobj(response, handle)
except urllib.error.HTTPError as exc:
fail(f"failed to download CRX for {extension_id}: HTTP {exc.code}")
except urllib.error.URLError as exc:
fail(f"network error while downloading {extension_id}: {exc.reason}")
return parse_version_from_url(final_url), final_url
def crx_payload_offset(crx_path: Path) -> int:
with crx_path.open("rb") as handle:
if handle.read(4) != b"Cr24":
fail(f"not a CRX file: {crx_path}")
version = struct.unpack("<I", handle.read(4))[0]
if version == 2:
return 16 + struct.unpack("<I", handle.read(4))[0] + struct.unpack("<I", handle.read(4))[0]
if version == 3:
return 12 + struct.unpack("<I", handle.read(4))[0]
fail(f"unsupported CRX version in {crx_path}")
def extract_crx(crx_path: Path, destination: Path) -> None:
zip_path = destination.parent / "payload.zip"
with crx_path.open("rb") as src, zip_path.open("wb") as dst:
src.seek(crx_payload_offset(crx_path))
shutil.copyfileobj(src, dst)
try:
with zipfile.ZipFile(zip_path) as archive:
archive.extractall(destination)
except zipfile.BadZipFile:
fail(f"invalid ZIP payload in {crx_path}")
finally:
zip_path.unlink(missing_ok=True)
def load_manifest(extension_dir: Path) -> dict:
path = extension_dir / "manifest.json"
if not path.exists():
fail(f"manifest.json not found in {extension_dir}")
try:
return json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
fail(f"invalid manifest.json in {extension_dir}: {exc}")
def meta_path(extension_dir: Path) -> Path:
return extension_dir / META_FILENAME
def read_meta(extension_dir: Path) -> dict:
path = meta_path(extension_dir)
if not path.exists():
fail(f"no metadata found in {extension_dir}; run install first")
try:
return json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
fail(f"invalid metadata in {path}: {exc}")
def write_meta(extension_dir: Path, extension_id: str, version: str, download_url: str) -> None:
meta_path(extension_dir).write_text(
json.dumps(
{
"extension_id": extension_id,
"version": version,
"download_url": download_url,
"fetched_at": datetime.now(timezone.utc).isoformat(),
},
indent=2,
sort_keys=True,
) + "\n",
encoding="utf-8",
)
def replace_contents(src: Path, dst: Path) -> None:
if not src.is_dir():
fail(f"source directory is invalid: {src}")
if not dst.exists():
fail(f"extension directory does not exist: {dst}")
if not dst.is_dir():
fail(f"extension path is not a directory: {dst}")
for child in dst.iterdir():
if child.name == META_FILENAME:
continue
shutil.rmtree(child) if child.is_dir() else child.unlink()
for child in src.iterdir():
if child.name != META_FILENAME:
shutil.move(str(child), str(dst / child.name))
def prepare_unpacked(extension_id: str, chrome_version: str, workdir: Path) -> tuple[str, str, Path]:
crx_path = workdir / "extension.crx"
unpacked_dir = workdir / "unpacked"
unpacked_dir.mkdir()
version, download_url = download_crx(extension_id, chrome_version, crx_path)
extract_crx(crx_path, unpacked_dir)
load_manifest(unpacked_dir)
if not any(unpacked_dir.iterdir()):
fail("extracted extension is empty")
return version, download_url, unpacked_dir
def install(extension: str, extension_dir: Path) -> None:
extension_id = normalize_extension_ref(extension)
extension_dir.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="crx-manager-") as tmp:
version, download_url, unpacked_dir = prepare_unpacked(extension_id, detect_chrome_version(), Path(tmp))
replace_contents(unpacked_dir, extension_dir)
write_meta(extension_dir, extension_id, version, download_url)
print(f"Installed unpacked extension into {extension_dir}")
def update(extension_dir: Path) -> None:
meta = read_meta(extension_dir)
extension_id = meta.get("extension_id")
local_version = meta.get("version")
if not extension_id:
fail(f"missing extension_id in {meta_path(extension_dir)}")
chrome_version = detect_chrome_version()
remote_version = parse_version_from_url(resolved_url(extension_id, chrome_version))
if remote_version == local_version:
print(f"Already up to date: version {local_version}")
return
with tempfile.TemporaryDirectory(prefix="crx-manager-") as tmp:
version, download_url, unpacked_dir = prepare_unpacked(extension_id, chrome_version, Path(tmp))
replace_contents(unpacked_dir, extension_dir)
write_meta(extension_dir, extension_id, version, download_url)
print(f"Updated extension: {local_version or 'unknown'} -> {version}")
def info(extension_dir: Path) -> None:
print(json.dumps(read_meta(extension_dir), indent=2, sort_keys=True))
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser()
sub = parser.add_subparsers(dest="command", required=True)
p = sub.add_parser("install", aliases=["i"])
p.add_argument("extension")
p.add_argument("extension_dir")
p = sub.add_parser("update", aliases=["u"])
p.add_argument("extension_dir")
p = sub.add_parser("info", aliases=["x"])
p.add_argument("extension_dir")
return parser
def main() -> None:
args = build_parser().parse_args()
if args.command in {"install", "i"}:
install(args.extension, Path(args.extension_dir).expanduser())
elif args.command in {"update", "u"}:
update(Path(args.extension_dir).expanduser())
elif args.command in {"info", "x"}:
info(Path(args.extension_dir).expanduser())
else:
fail("unknown command", 2)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment