Skip to content

Instantly share code, notes, and snippets.

@saagarjha
Last active August 17, 2026 18:10
Show Gist options
  • Select an option

  • Save saagarjha/ae93a2a404ea8cec1acfc0bb0d85c9e5 to your computer and use it in GitHub Desktop.

Select an option

Save saagarjha/ae93a2a404ea8cec1acfc0bb0d85c9e5 to your computer and use it in GitHub Desktop.
Installs Binary Ninja given a license file
#!/usr/bin/env python3
"""Install Binary Ninja from license.dat alone. Pure stdlib + `openssl` CLI.
bn_install.py LICENSE TARGET [--channel dev|release|test]
[--platform linux-arm|linux|macosx|win64]
Each layer is the same primitive — IV(16) || AES-128-CBC(zlib(payload)) — and
hands you the AES key for the next. /manifest and /channel additionally have a
256-byte RSA-PSS signature prefix.
"""
import argparse, base64, hashlib, json, os, stat, subprocess, sys
import urllib.parse, urllib.request, zlib
MASTER = "https://master.binary.ninja"
UA = "Binary Ninja/installer"
# User-facing channels mapped to concrete channel names, in preference order.
# The update server decides which manifests a license may access; we discover
# those manifests first, then choose the most capable matching channel.
CHANNELS = {
"dev": ["dev-enterprise", "dev", "dev-personal", "headless-dev"],
"release": ["enterprise", "release", "release-personal", "headless"],
"test": ["test-enterprise", "test"],
}
def aes_cbc(key, iv, ct):
"""AES-128-CBC decrypt by shelling out to `openssl`. `-nopad` because our zlib
stream is followed by trailing PKCS#7-style padding that zlib happily ignores."""
p = subprocess.run(
["openssl", "enc", "-aes-128-cbc", "-d", "-nopad",
"-K", key.hex(), "-iv", iv.hex()],
input=ct, capture_output=True, check=True)
return p.stdout
def unwrap_envelope(blob, key):
"""RSA-sig(256) || IV(16) || AES-CBC(zlib(content))."""
return zlib.decompress(aes_cbc(key, blob[256:272], blob[272:]))
def unwrap_chunk(blob, key):
"""IV(16) || AES-CBC(zlib(content))."""
return zlib.decompress(aes_cbc(key, blob[:16], blob[16:]))
def http_get(url):
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req) as r:
return r.read()
def main():
ap = argparse.ArgumentParser(formatter_class=argparse.RawDescriptionHelpFormatter,
description=__doc__)
ap.add_argument("license", help="path to license.dat")
ap.add_argument("target", help="target install directory")
ap.add_argument("--channel", default="dev", choices=["dev", "release", "test"])
ap.add_argument("--platform", default="linux-arm", choices=["linux-arm", "linux", "macosx", "win64"])
args = ap.parse_args()
# 1-4. Try every license entry. Like the real client, /update returns the
# manifests/roles available to that license; each manifest returns channels.
# Collect them all, then choose the best concrete channel for the requested
# user-facing channel.
with open(args.license) as f:
licenses = json.load(f)
if isinstance(licenses, dict):
licenses = [licenses]
available = {}
for lic in licenses:
serial = lic["serial"]
key_hex = base64.b64decode(lic["data"])[:256].hex()
qs = urllib.parse.urlencode({
"product": lic.get("product", "Binary Ninja"),
"serial": serial,
"key": key_hex,
"version": "0.0.0",
"platform": args.platform,
})
update = json.loads(http_get(f"{MASTER}/update?{qs}"))
if not update.get("ok"):
continue
cdn = update["url"].rstrip("/")
for trust in update["keys"]:
channels = json.loads(unwrap_envelope(
http_get(f"{cdn}/manifest/{trust['name']}"),
bytes.fromhex(trust["key"])
))["channels"]
for channel in channels:
# Match the real client's behavior: de-duplicate by channel name.
available.setdefault(channel["name"], (cdn, trust, channel))
selected = None
for channel_name in CHANNELS[args.channel]:
selected = available.get(channel_name)
if selected:
break
if not selected:
wanted = ", ".join(CHANNELS[args.channel])
found = ", ".join(sorted(available)) or "none"
sys.exit(f"no usable channel found for {args.channel!r} (wanted {wanted}; found {found})")
cdn, trust, channel = selected
print(f"[*] {trust['name']}/{channel['name']} → {cdn}")
versions = json.loads(unwrap_envelope(
http_get(f"{cdn}/channel/{channel['name']}"),
bytes.fromhex(channel["key"])
))["versions"]
version = versions[0]
build = version["builds"][args.platform]["release"]
print(f"[*] installing {version['version']} ({args.platform})")
# 5. /download/<chunk-manifest-sha> → file index
cm = json.loads(unwrap_chunk(
http_get(f"{cdn}/download/{build['object']}"),
bytes.fromhex(build["key"])
))
bundle_sha = next(e["bundle"]["object"] for e in cm if isinstance(e.get("bundle"), dict))
print(f"[*] {len(cm)} entries; fetching bundle ({bundle_sha[:16]}…)")
# 6. /download/<bundle> — the only large transfer
bundle = http_get(f"{cdn}/download/{bundle_sha}")
if hashlib.sha256(bundle).hexdigest() != bundle_sha:
sys.exit("bundle SHA mismatch")
# 7. extract everything into target
os.makedirs(args.target, exist_ok=True)
for i, ent in enumerate(cm, 1):
if not isinstance(ent, dict): continue
out = os.path.join(args.target, ent["path"])
os.makedirs(os.path.dirname(out) or ".", exist_ok=True)
if ent.get("type") == "link":
if os.path.lexists(out): os.unlink(out)
os.symlink(ent.get("link") or ent.get("target"), out)
else:
b = ent["bundle"]
pt = unwrap_chunk(bundle[b["offset"] : b["offset"]+b["size"]],
bytes.fromhex(ent["key"]))
if hashlib.sha256(pt).hexdigest() != ent["object"]:
sys.exit(f"plaintext SHA mismatch for {ent['path']}")
with open(out, "wb") as f: f.write(pt)
if ent.get("exec"):
os.chmod(out, os.stat(out).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
if i % 500 == 0:
print(f" [{i}/{len(cm)}] {ent['path']}")
print(f"[*] DONE → {args.target}")
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment