Add apk patcher. Update readme and added disclaimer.

This commit is contained in:
BillyCool
2026-03-14 17:15:48 +11:00
parent 62d90edbea
commit ca31192d55
5 changed files with 613 additions and 23 deletions

481
scripts/patcher.ipynb Normal file
View File

@@ -0,0 +1,481 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "400a2588",
"metadata": {},
"source": [
"# Black Bird APK patcher for Google Colab\n",
"\n",
"Installs tools, configure the target server, upload an APK, patch it for a private server, rebuild, sign, and download the patched APK.\n",
"\n",
"**Patches**\n",
"- `global-metadata.dat` — rewrite IL2CPP string literals (URLs and hostnames)\n",
"- `libil2cpp.so` — ARM64 binary patches (SSL bypass, encryption passthrough, plain Octo list)\n",
"- `AndroidManifest.xml` — add `networkSecurityConfig` for cleartext HTTP (when using HTTP)\n",
"- `res/xml/network_security_config.xml` — allow cleartext traffic\n",
"\n",
"**Quick Instructions**\n",
"- Configure `protocol`, `server_host`, and optional `server_port` below.\n",
"- Run the single code cell. It installs tools, patches, and downloads the result.\n",
"\n",
"**Notes**\n",
"- Choose `http` to enable cleartext network patches; choose `https` to skip manifest/network changes.\n",
"- Replacements must not be longer than the original strings; use short hostnames or omit the port if needed."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "combined-cell",
"metadata": {},
"outputs": [],
"source": [
"# ── Configuration ────────────────────────────────────────────────────────────\n",
"protocol = 'http' #@param ['http', 'https']\n",
"server_host = '192.168.1.1' #@param {type:'string'}\n",
"server_port = '' #@param {type:'string'}\n",
"\n",
"# ── Imports & constants ───────────────────────────────────────────────────────\n",
"import os\n",
"import re\n",
"import shutil\n",
"import stat\n",
"import struct\n",
"import subprocess\n",
"import urllib.parse\n",
"from dataclasses import dataclass\n",
"from pathlib import Path\n",
"\n",
"WORK_ROOT = Path('/content/nier_patch_work')\n",
"TOOLS_DIR = WORK_ROOT / 'tools'\n",
"ANDROID_SDK_ROOT = TOOLS_DIR / 'android-sdk'\n",
"APKTOOL_VERSION = '3.0.1'\n",
"ANDROID_BUILD_TOOLS = '36.1.0'\n",
"ANDROID_CMDLINE_TOOLS_URL = 'https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip'\n",
"\n",
"METADATA_MAGIC = 0xFAB11BAF\n",
"HDR_STRING_LITERAL_OFF = 8\n",
"HDR_STRING_LITERAL_DATA_OFF = 16\n",
"\n",
"MOV_X0_0 = struct.pack('<I', 0xD2800000)\n",
"MOV_X0_X1 = struct.pack('<I', 0xAA0103E0)\n",
"RET = struct.pack('<I', 0xD65F03C0)\n",
"YEAR_9999 = struct.pack('<I', 0x5284E1E1)\n",
"\n",
"IL2CPP_PATCHES = [\n",
" {\n",
" 'name': 'ToNativeCredentials',\n",
" 'desc': 'SSL bypass — return NULL to force insecure gRPC channel',\n",
" 'rva': 0x35C8670,\n",
" 'bytes': MOV_X0_0 + RET,\n",
" },\n",
" {\n",
" 'name': 'HandleNet.Encrypt',\n",
" 'desc': 'encryption passthrough — return payload as-is',\n",
" 'rva': 0x279410C,\n",
" 'bytes': MOV_X0_X1 + RET,\n",
" },\n",
" {\n",
" 'name': 'HandleNet.Decrypt',\n",
" 'desc': 'decryption passthrough — return receivedMessage as-is',\n",
" 'rva': 0x279420C,\n",
" 'bytes': MOV_X0_X1 + RET,\n",
" },\n",
" {\n",
" 'name': 'OctoManager.Internal.GetListAes',\n",
" 'desc': 'Octo list: force plain list (return false = no AES); server serves raw list.bin',\n",
" 'rva': 0x4C27038,\n",
" 'bytes': MOV_X0_0 + RET,\n",
" },\n",
" {\n",
" 'name': 'TitleScreen.ForceYear9999',\n",
" 'desc': 'Replace EoS year 2024 with 9999',\n",
" 'rva': 0x2F11E7C,\n",
" 'bytes': YEAR_9999\n",
" },\n",
"]\n",
"\n",
"# ── Helper functions ──────────────────────────────────────────────────────────\n",
"def run(cmd, check=True, shell=False, env=None, cwd=None):\n",
" printable = cmd if isinstance(cmd, str) else ' '.join(str(part) for part in cmd)\n",
" print(f'$ {printable}')\n",
" return subprocess.run(cmd, check=check, text=True, shell=shell, env=env, cwd=cwd)\n",
"\n",
"def ensure_dir(path: Path) -> Path:\n",
" path.mkdir(parents=True, exist_ok=True)\n",
" return path\n",
"\n",
"# ── Tool installation ─────────────────────────────────────────────────────────\n",
"def install_apktool():\n",
" ensure_dir(TOOLS_DIR)\n",
" jar_path = TOOLS_DIR / f'apktool_{APKTOOL_VERSION}.jar'\n",
" wrapper_path = TOOLS_DIR / 'apktool'\n",
"\n",
" if not jar_path.exists():\n",
" run([\n",
" 'wget', '-q', '-O', str(jar_path),\n",
" f'https://github.com/iBotPeaches/Apktool/releases/download/v{APKTOOL_VERSION}/apktool_{APKTOOL_VERSION}.jar',\n",
" ])\n",
"\n",
" wrapper_path.write_text(\n",
" '#!/usr/bin/env bash\\n'\n",
" f'exec java -jar \"{jar_path}\" \"$@\"\\n',\n",
" encoding='utf-8',\n",
" )\n",
" wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC)\n",
" os.environ['PATH'] = f'{TOOLS_DIR}:{os.environ[\"PATH\"]}'\n",
"\n",
"def install_android_commandline_tools() -> Path:\n",
" ensure_dir(TOOLS_DIR)\n",
" ensure_dir(ANDROID_SDK_ROOT)\n",
"\n",
" zip_path = TOOLS_DIR / 'commandlinetools.zip'\n",
" temp_dir = TOOLS_DIR / 'cmdline-tools-temp'\n",
" cmdline_root = ANDROID_SDK_ROOT / 'cmdline-tools'\n",
" latest_dir = cmdline_root / 'latest'\n",
"\n",
" if not latest_dir.exists():\n",
" if temp_dir.exists():\n",
" shutil.rmtree(temp_dir)\n",
" run(['wget', '-q', '-O', str(zip_path), ANDROID_CMDLINE_TOOLS_URL])\n",
" ensure_dir(temp_dir)\n",
" run(['unzip', '-q', str(zip_path), '-d', str(temp_dir)])\n",
" ensure_dir(cmdline_root)\n",
" extracted_dir = temp_dir / 'cmdline-tools'\n",
" if latest_dir.exists():\n",
" shutil.rmtree(latest_dir)\n",
" shutil.move(str(extracted_dir), str(latest_dir))\n",
"\n",
" return latest_dir\n",
"\n",
"def install_android_build_tools():\n",
" latest_dir = install_android_commandline_tools()\n",
" sdkmanager = latest_dir / 'bin' / 'sdkmanager'\n",
"\n",
" env = os.environ.copy()\n",
" env['ANDROID_SDK_ROOT'] = str(ANDROID_SDK_ROOT)\n",
" env['JAVA_HOME'] = '/usr/lib/jvm/java-17-openjdk-amd64'\n",
" env['PATH'] = f'{latest_dir / \"bin\"}:{env[\"PATH\"]}'\n",
"\n",
" run(f'yes | \"{sdkmanager}\" --sdk_root=\"{ANDROID_SDK_ROOT}\" --licenses >/dev/null', check=False, shell=True, env=env)\n",
" run([\n",
" str(sdkmanager),\n",
" f'--sdk_root={ANDROID_SDK_ROOT}',\n",
" f'build-tools;{ANDROID_BUILD_TOOLS}',\n",
" 'platform-tools',\n",
" ], env=env)\n",
"\n",
" build_tools_dir = ANDROID_SDK_ROOT / 'build-tools' / ANDROID_BUILD_TOOLS\n",
" os.environ['ANDROID_SDK_ROOT'] = str(ANDROID_SDK_ROOT)\n",
" os.environ['PATH'] = f'{build_tools_dir}:{latest_dir / \"bin\"}:{os.environ[\"PATH\"]}'\n",
"\n",
"def install_dependencies():\n",
" ensure_dir(WORK_ROOT)\n",
" ensure_dir(TOOLS_DIR)\n",
"\n",
" run(['apt-get', 'update'])\n",
" run([\n",
" 'apt-get', 'install', '-y',\n",
" 'openjdk-17-jdk-headless',\n",
" 'wget',\n",
" 'unzip',\n",
" 'ca-certificates',\n",
" ])\n",
"\n",
" install_apktool()\n",
" install_android_build_tools()\n",
"\n",
" missing = [tool for tool in ('apktool', 'apksigner', 'zipalign', 'keytool') if shutil.which(tool) is None]\n",
" if missing:\n",
" raise RuntimeError(f'Missing required tools after installation: {missing}')\n",
"\n",
" print('\\nInstalled tool paths:')\n",
" for tool in ('apktool', 'apksigner', 'zipalign', 'keytool'):\n",
" print(f' {tool}: {shutil.which(tool)}')\n",
"\n",
" print('\\nRequested versions:')\n",
" print(f' apktool: {APKTOOL_VERSION}')\n",
" print(f' Android build-tools: {ANDROID_BUILD_TOOLS}')\n",
"\n",
"# ── Patch helpers ─────────────────────────────────────────────────────────────\n",
"@dataclass\n",
"class RuntimeConfig:\n",
" protocol: str\n",
" host: str\n",
" port: str = ''\n",
"\n",
" @property\n",
" def web_base_url(self) -> str:\n",
" base = f'{self.protocol}://{self.host}'\n",
" if self.port:\n",
" base += f':{self.port}'\n",
" return base\n",
"\n",
" @property\n",
" def grpc_host(self) -> str:\n",
" return self.host\n",
"\n",
"def require_file(path: Path):\n",
" if not path.is_file():\n",
" raise FileNotFoundError(f'Not found: {path}')\n",
"\n",
"def build_replacements(cfg: RuntimeConfig):\n",
" replacements = [\n",
" ('api.app.nierreincarnation.com', cfg.grpc_host),\n",
" (\n",
" 'https://web.app.nierreincarnation.com/assets/release/{0}/database.bin',\n",
" f'{cfg.web_base_url}/assets/release/{{0}}/database.bin',\n",
" ),\n",
" ('https://web.app.nierreincarnation.com', cfg.web_base_url),\n",
" ('https://resources-api.app.nierreincarnation.com/', f'{cfg.web_base_url}/'),\n",
" ]\n",
"\n",
" for old_value, new_value in replacements:\n",
" if len(new_value.encode('utf-8')) > len(old_value.encode('utf-8')):\n",
" raise ValueError(\n",
" 'Replacement too long: '\n",
" f'{old_value!r} -> {new_value!r}. '\n",
" 'Use a shorter host name or omit the port.'\n",
" )\n",
"\n",
" return replacements\n",
"\n",
"def patch_metadata_strings(meta_path: Path, replacements) -> int:\n",
" data = bytearray(meta_path.read_bytes())\n",
"\n",
" magic = struct.unpack_from('<I', data, 0)[0]\n",
" if magic != METADATA_MAGIC:\n",
" raise RuntimeError(f'Bad metadata magic: 0x{magic:08X}')\n",
"\n",
" version = struct.unpack_from('<i', data, 4)[0]\n",
" print(f' metadata v{version}, {len(data)} bytes')\n",
"\n",
" sl_off, sl_size = struct.unpack_from('<II', data, HDR_STRING_LITERAL_OFF)\n",
" sld_off, sld_size = struct.unpack_from('<II', data, HDR_STRING_LITERAL_DATA_OFF)\n",
" n_entries = sl_size // 8\n",
" print(f' stringLiteral: {n_entries} entries @ 0x{sl_off:X}')\n",
" print(f' stringLiteralData: {sld_size} bytes @ 0x{sld_off:X}')\n",
"\n",
" patched = 0\n",
" for old_str, new_str in replacements:\n",
" old_bytes = old_str.encode('utf-8')\n",
" new_bytes = new_str.encode('utf-8')\n",
"\n",
" blob_pos = data.find(old_bytes, sld_off, sld_off + sld_size)\n",
" if blob_pos < 0:\n",
" print(f' [!] NOT FOUND in blob: {old_str!r}')\n",
" continue\n",
"\n",
" data_index = blob_pos - sld_off\n",
" entry_found = False\n",
" for i in range(n_entries):\n",
" entry_offset = sl_off + i * 8\n",
" entry_len, entry_index = struct.unpack_from('<II', data, entry_offset)\n",
" if entry_index == data_index and entry_len == len(old_bytes):\n",
" struct.pack_into('<I', data, entry_offset, len(new_bytes))\n",
" entry_found = True\n",
" print(f' entry #{i}: length {entry_len} -> {len(new_bytes)}')\n",
" break\n",
"\n",
" if not entry_found:\n",
" print(f' [!] No table entry found for {old_str!r} (dataIndex=0x{data_index:X})')\n",
" continue\n",
"\n",
" data[blob_pos:blob_pos + len(old_bytes)] = new_bytes + (b'\\x00' * (len(old_bytes) - len(new_bytes)))\n",
" print(f' PATCHED: {old_str!r} -> {new_str!r}')\n",
" patched += 1\n",
"\n",
" meta_path.write_bytes(data)\n",
" return patched\n",
"\n",
"def patch_manifest(manifest_path: Path) -> bool:\n",
" text = manifest_path.read_text(encoding='utf-8')\n",
"\n",
" if 'networkSecurityConfig' in text:\n",
" print(' networkSecurityConfig already present')\n",
" return True\n",
"\n",
" new_attr = 'android:networkSecurityConfig=\"@xml/network_security_config\"'\n",
" patched_text, count = re.subn(r'<application\\b', f'<application {new_attr}', text, count=1)\n",
" if count != 1:\n",
" raise RuntimeError('Could not find <application> tag in AndroidManifest.xml')\n",
"\n",
" manifest_path.write_text(patched_text, encoding='utf-8')\n",
" print(f' added {new_attr}')\n",
" return True\n",
"\n",
"NETWORK_SECURITY_CONFIG = '''<?xml version=\"1.0\" encoding=\"utf-8\"?>\n",
"<network-security-config>\n",
" <base-config cleartextTrafficPermitted=\"true\" />\n",
"</network-security-config>\n",
"'''\n",
"\n",
"def create_network_security_config(res_xml_dir: Path) -> bool:\n",
" res_xml_dir.mkdir(parents=True, exist_ok=True)\n",
" output_path = res_xml_dir / 'network_security_config.xml'\n",
" output_path.write_text(NETWORK_SECURITY_CONFIG, encoding='utf-8')\n",
" print(f' wrote {output_path}')\n",
" return True\n",
"\n",
"def patch_libil2cpp(so_path: Path) -> int:\n",
" patched = 0\n",
" with so_path.open('r+b') as handle:\n",
" file_size = handle.seek(0, 2)\n",
" for patch in IL2CPP_PATCHES:\n",
" rva = patch['rva']\n",
" patch_bytes = patch['bytes']\n",
" if rva + len(patch_bytes) > file_size:\n",
" print(f\" [!] SKIP {patch['name']}: RVA 0x{rva:X} beyond file size\")\n",
" continue\n",
"\n",
" handle.seek(rva)\n",
" original = handle.read(len(patch_bytes))\n",
" handle.seek(rva)\n",
" handle.write(patch_bytes)\n",
"\n",
" patched += 1\n",
" print(f\" {patch['name']} @ 0x{rva:X}: {original.hex()} -> {patch_bytes.hex()}\")\n",
" print(f\" {patch['desc']}\")\n",
"\n",
" return patched\n",
"\n",
"def ensure_debug_keystore() -> Path:\n",
" keystore_path = TOOLS_DIR / 'debug.keystore'\n",
" if keystore_path.exists():\n",
" return keystore_path\n",
"\n",
" run([\n",
" 'keytool',\n",
" '-genkeypair',\n",
" '-v',\n",
" '-keystore', str(keystore_path),\n",
" '-storepass', 'android',\n",
" '-alias', 'androiddebugkey',\n",
" '-keypass', 'android',\n",
" '-keyalg', 'RSA',\n",
" '-keysize', '2048',\n",
" '-validity', '10000',\n",
" '-dname', 'CN=Android Debug,O=Android,C=US',\n",
" '-noprompt',\n",
" ])\n",
" return keystore_path\n",
"\n",
"def patch_uploaded_apk(uploaded_apk: Path, cfg: RuntimeConfig) -> Path:\n",
" job_dir = WORK_ROOT / 'job'\n",
" decoded_dir = job_dir / 'decoded'\n",
" unsigned_apk = job_dir / f'{uploaded_apk.stem}-unsigned.apk'\n",
" aligned_apk = job_dir / f'{uploaded_apk.stem}-aligned.apk'\n",
" signed_apk = job_dir / f'{uploaded_apk.stem}-patched.apk'\n",
"\n",
" if job_dir.exists():\n",
" shutil.rmtree(job_dir)\n",
" job_dir.mkdir(parents=True, exist_ok=True)\n",
"\n",
" print(f'[*] Decompiling {uploaded_apk.name} ...')\n",
" run(['apktool', 'd', '-f', str(uploaded_apk), '-o', str(decoded_dir)])\n",
"\n",
" metadata_path = decoded_dir / 'assets/bin/Data/Managed/Metadata/global-metadata.dat'\n",
" so_path = decoded_dir / 'lib/arm64-v8a/libil2cpp.so'\n",
" manifest_path = decoded_dir / 'AndroidManifest.xml'\n",
" res_xml_dir = decoded_dir / 'res/xml'\n",
"\n",
" for required_path in (metadata_path, so_path, manifest_path):\n",
" require_file(required_path)\n",
"\n",
" replacements = build_replacements(cfg)\n",
"\n",
" print(f'\\n[*] Patching for server {cfg.host} (web={cfg.web_base_url}, gRPC host={cfg.grpc_host})')\n",
"\n",
" print('\\n[1] Patching global-metadata.dat string literals ...')\n",
" patched_strings = patch_metadata_strings(metadata_path, replacements)\n",
" print(f' {patched_strings}/{len(replacements)} strings patched')\n",
"\n",
" print('\\n[2] Patching libil2cpp.so ...')\n",
" patched_methods = patch_libil2cpp(so_path)\n",
" print(f' {patched_methods}/{len(IL2CPP_PATCHES)} methods patched')\n",
"\n",
" if cfg.protocol == 'http':\n",
" print('\\n[3] Enabling cleartext traffic in AndroidManifest.xml ...')\n",
" patch_manifest(manifest_path)\n",
"\n",
" print('\\n[4] Writing network_security_config.xml ...')\n",
" create_network_security_config(res_xml_dir)\n",
" else:\n",
" print('\\n[3] HTTPS selected; skipping cleartext network security changes')\n",
"\n",
" print('\\n[5] Rebuilding APK ...')\n",
" run(['apktool', 'b', str(decoded_dir), '-o', str(unsigned_apk)])\n",
"\n",
" print('\\n[6] Aligning APK ...')\n",
" run(['zipalign', '-f', '4', str(unsigned_apk), str(aligned_apk)])\n",
"\n",
" print('\\n[7] Signing APK ...')\n",
" keystore_path = ensure_debug_keystore()\n",
" if signed_apk.exists():\n",
" signed_apk.unlink()\n",
" shutil.copy2(aligned_apk, signed_apk)\n",
" run([\n",
" 'apksigner', 'sign',\n",
" '--ks', str(keystore_path),\n",
" '--ks-pass', 'pass:android',\n",
" '--key-pass', 'pass:android',\n",
" '--ks-key-alias', 'androiddebugkey',\n",
" str(signed_apk),\n",
" ])\n",
"\n",
" print('\\n[8] Verifying signature ...')\n",
" run(['apksigner', 'verify', '--verbose', str(signed_apk)])\n",
"\n",
" print(f'\\n[+] Finished: {signed_apk}')\n",
" return signed_apk\n",
"\n",
"# ── Validate config ───────────────────────────────────────────────────────────\n",
"server_host = server_host.strip()\n",
"server_port = server_port.strip()\n",
"\n",
"if not server_host:\n",
" raise ValueError('server_host cannot be empty')\n",
"\n",
"if server_port and not server_port.isdigit():\n",
" raise ValueError('server_port must be blank or numeric')\n",
"\n",
"display_port = f':{server_port}' if server_port else ''\n",
"print(f'Configured web base URL: {protocol}://{server_host}{display_port}')\n",
"print(f'Configured gRPC host patch: {server_host}')\n",
"\n",
"# ── Install dependencies ──────────────────────────────────────────────────────\n",
"install_dependencies()\n",
"\n",
"# ── Download, patch, and export APK ──────────────────────────────────────────\n",
"apk_url = 'https://archive.org/download/nierreincarnation/Global/apk/NieR%20Re%5Bin%5Dcarnation%203.7.1.apk'\n",
"output_name = Path(urllib.parse.unquote(apk_url.split('/')[-1]))\n",
"input_apk = WORK_ROOT / output_name.name\n",
"ensure_dir(WORK_ROOT)\n",
"\n",
"print(f'Downloading APK from {apk_url} ...')\n",
"run(['wget', '-q', '-O', str(input_apk), apk_url])\n",
"if not input_apk.exists():\n",
" raise RuntimeError('Download failed')\n",
"print(f'Downloaded {input_apk.name} ({input_apk.stat().st_size:,} bytes)')\n",
"\n",
"cfg = RuntimeConfig(protocol=protocol, host=server_host, port=server_port)\n",
"output_apk = patch_uploaded_apk(input_apk, cfg)\n",
"print(f'Patched APK: {output_apk}')\n",
"\n",
"try:\n",
" from google.colab import files\n",
" files.download(str(output_apk))\n",
"except Exception:\n",
" print('Browser download failed; patched APK is at:', output_apk)"
]
}
],
"metadata": {
"language_info": {
"name": "python"
}
},
"nbformat": 4,
"nbformat_minor": 5
}