Initial commit: Null DRM Official
Capture, decrypt, and restream toolkit with compiled-in app modules (RTE, TG4, BBC), on-device MITM proxy, streamd control plane, and www. BBC module.yaml is published (clear streams); other module values stay local.
This commit is contained in:
commit
2fa8f2435f
121 changed files with 17802 additions and 0 deletions
102
apps/proxy/README.md
Normal file
102
apps/proxy/README.md
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
# proxy — on-device HTTPS MITM (appproxy)
|
||||
|
||||
Host CLI plus the Android binary that MITMs phone HTTPS, so a capture can see
|
||||
license URLs, manifests and auth headers.
|
||||
|
||||
```text
|
||||
apps/proxy/
|
||||
proxyctlcmd/ host CLI, hosted by bin/drm as `drm proxy`
|
||||
device/ linux/arm64 MITM source (module: appproxy)
|
||||
rteproxy-ca.crt MITM CA (subject hash 6c3578b4)
|
||||
```
|
||||
|
||||
The process name on the phone is **`appproxy`**. Some on-disk names still say
|
||||
`rteproxy-*`, and those paths are still read, so an already-provisioned phone keeps
|
||||
working.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
go -C apps/cli build -o ../../bin/drm . # host CLI
|
||||
./bin/drm proxy build # cross-compile the device binary
|
||||
```
|
||||
|
||||
`proxy build` compiles `device/` for linux/arm64 into
|
||||
`apps/proxy/proxy-android-arm64` and copies it to `bin/proxy-android-arm64`.
|
||||
|
||||
## Use
|
||||
|
||||
```bash
|
||||
# trust the MITM CA (needs Magisk; mounts into the system store)
|
||||
./bin/drm proxy install-ca --reinject
|
||||
./bin/drm proxy reinject-ca # mount only, CA already pushed
|
||||
|
||||
# structured capture proxy
|
||||
./bin/drm proxy start --install-ca --reinject
|
||||
./bin/drm proxy stop
|
||||
|
||||
# record everything while you explore an unknown app
|
||||
./bin/drm proxy discover --install-ca --reinject
|
||||
./bin/drm proxy discover --force-stop <package> # so it inherits the CA mount
|
||||
|
||||
# pull artifacts without re-running, and release the phone
|
||||
./bin/drm proxy pull
|
||||
./bin/drm proxy clear-proxy
|
||||
```
|
||||
|
||||
Always clear the proxy when done, or the phone keeps pointing at a dead listener.
|
||||
|
||||
### Transparent mode (UK VPN OK — no Wi‑Fi HTTP proxy)
|
||||
|
||||
Root `iptables` redirects only one app’s TCP/443 into on-device `appproxy`. The
|
||||
phone can stay on NordVPN UK; nothing sets `http_proxy`.
|
||||
|
||||
```bash
|
||||
# leave UK VPN connected on the phone, then:
|
||||
./bin/drm proxy transparent --package bbc.iplayer.android --reinject
|
||||
# open the app / play — Ctrl+C stops iptables and pulls files
|
||||
```
|
||||
|
||||
Writes on device (adb-readable):
|
||||
|
||||
```text
|
||||
/data/local/tmp/capture/<package>/cap.json
|
||||
/data/local/tmp/capture/<package>/traffic.jsonl
|
||||
/data/local/tmp/capture/<package>/appproxy.log
|
||||
```
|
||||
|
||||
Pull anytime:
|
||||
|
||||
```bash
|
||||
adb pull /data/local/tmp/capture/bbc.iplayer.android ./bbc-cap
|
||||
```
|
||||
|
||||
Force-stop the target app once after CA reinject so it inherits the Magisk CA mount.
|
||||
|
||||
The device binary cannot read an app module's values file, so a module's
|
||||
manifest-scoring hosts are passed to it as flags (`--match`, `--score-host`,
|
||||
`--score-deny`). `drm capture` does this automatically.
|
||||
|
||||
## Artifacts
|
||||
|
||||
| File | What |
|
||||
|---|---|
|
||||
| `appproxy_traffic.jsonl` / `traffic.jsonl` | every HTTP(S) request/response (discover / transparent) |
|
||||
| `appproxy_cap.json` / `cap.json` | structured mpd / license / auth / pssh when detected |
|
||||
| `appproxy.log` | tagged lines: `[MPD]` `[LIC]` `[PSSH]` `[MAN]` `[TPROXY]` |
|
||||
|
||||
Pulled into `outputs/discover/<stamp>/` or `outputs/transparent/<stamp>/`.
|
||||
|
||||
## Device paths
|
||||
|
||||
| Remote | Role |
|
||||
|---|---|
|
||||
| `/data/local/tmp/appproxy` | binary |
|
||||
| `/data/local/tmp/appproxy_cap.json` | structured capture (proxy mode) |
|
||||
| `/data/local/tmp/capture/<pkg>/` | transparent out-dir (cap + traffic + log) |
|
||||
| `/data/local/tmp/tproxy_iptables.sh` | UID REDIRECT helper |
|
||||
| `/data/local/tmp/6c3578b4.0` | system CA hash file |
|
||||
| Wi‑Fi `http_proxy` | used only in classic proxy mode — **not** in transparent mode |
|
||||
|
||||
**Full guide: [docs/capture.md](../../docs/capture.md)** — CA troubleshooting and how
|
||||
to mine a discover dump.
|
||||
BIN
apps/proxy/device/appproxy.exe
Normal file
BIN
apps/proxy/device/appproxy.exe
Normal file
Binary file not shown.
17
apps/proxy/device/build.sh
Normal file
17
apps/proxy/device/build.sh
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
GO="${GO:-$(command -v go || true)}"
|
||||
if [[ -z "$GO" && -x /opt/homebrew/bin/go ]]; then
|
||||
GO=/opt/homebrew/bin/go
|
||||
fi
|
||||
if [[ -z "$GO" ]]; then
|
||||
echo "go not found; brew install go" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "building linux/arm64 with $GO"
|
||||
OUT="${OUT:-../proxy-android-arm64}"
|
||||
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 "$GO" build -ldflags='-s -w' -o "$OUT" .
|
||||
file "$OUT"
|
||||
ls -lh "$OUT"
|
||||
# Prefer: from repo root → bin/proxyctl.exe build
|
||||
14
apps/proxy/device/go.mod
Normal file
14
apps/proxy/device/go.mod
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
module appproxy
|
||||
|
||||
go 1.27.1
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.0.6 // indirect
|
||||
github.com/elazarl/goproxy v1.9.2 // indirect
|
||||
github.com/klauspost/compress v1.17.4 // indirect
|
||||
github.com/refraction-networking/utls v1.8.2 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
)
|
||||
16
apps/proxy/device/go.sum
Normal file
16
apps/proxy/device/go.sum
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
|
||||
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
github.com/elazarl/goproxy v1.9.2 h1:+vXRRSWrznMtBrAb559qfqC+Cny1Q3rR0l51Yu/3WUw=
|
||||
github.com/elazarl/goproxy v1.9.2/go.mod h1:THdE5ix2clxX9lZzcICPpZ67d6CdrPZxdOYsNgU5e30=
|
||||
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
|
||||
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
|
||||
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
1310
apps/proxy/device/main.go
Normal file
1310
apps/proxy/device/main.go
Normal file
File diff suppressed because it is too large
Load diff
56
apps/proxy/device/needles.go
Normal file
56
apps/proxy/device/needles.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package main
|
||||
|
||||
import "strings"
|
||||
|
||||
// CA identity and file names. The legacy names are still honoured when already
|
||||
// present on a device, so an existing phone does not need a CA reinject.
|
||||
const (
|
||||
caCertName = "appproxy-ca.crt"
|
||||
caKeyName = "appproxy-ca.key"
|
||||
legacyCACertName = "rteproxy-ca.crt"
|
||||
legacyCAKeyName = "rteproxy-ca.key"
|
||||
caOrganization = "appproxy MITM CA"
|
||||
caCommonName = "appproxy"
|
||||
)
|
||||
|
||||
// stringList is a repeatable string flag.
|
||||
type stringList []string
|
||||
|
||||
func (s *stringList) String() string { return strings.Join(*s, ",") }
|
||||
|
||||
func (s *stringList) Set(v string) error {
|
||||
for _, part := range strings.Split(v, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
*s = append(*s, part)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
// matchNeedles (--match) mark extra provider traffic as interesting.
|
||||
matchNeedles stringList
|
||||
// scoreHosts (--score-host) are this provider's manifest origins.
|
||||
scoreHosts stringList
|
||||
// scoreDeny (--score-deny) are provider URLs that are never a manifest.
|
||||
scoreDeny stringList
|
||||
// mitmHosts (--mitm-host) force MITM even when a passthrough rule matches.
|
||||
mitmHosts stringList
|
||||
)
|
||||
|
||||
// formatDeny are catalog/analytics URL shapes that are never a manifest for any
|
||||
// provider. Provider-specific noise arrives via --score-deny.
|
||||
var formatDeny = []string{
|
||||
"schedules", "bylistingtime", "maxlistings", "bycallsign",
|
||||
"/feed.", "playback_config", "config.json",
|
||||
}
|
||||
|
||||
func denyNeedles() []string {
|
||||
out := make([]string, 0, len(formatDeny)+len(scoreDeny))
|
||||
out = append(out, formatDeny...)
|
||||
for _, d := range scoreDeny {
|
||||
out = append(out, strings.ToLower(d))
|
||||
}
|
||||
return out
|
||||
}
|
||||
101
apps/proxy/device/passthrough.go
Normal file
101
apps/proxy/device/passthrough.go
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// passthroughAll, when true, splices every host without MITM. Used to prove
|
||||
// transparent redirect + VPN egress work before narrowing MITM targets.
|
||||
var passthroughAll bool
|
||||
|
||||
// passthroughDefault, when true (transparent mode), MITM only forceMITM / open.live.
|
||||
// Avoids breaking connectivity checks on Google/Firebase when Magisk CA is not
|
||||
// in the app mount namespace.
|
||||
var passthroughDefault bool
|
||||
|
||||
// forceMITM hosts (lowercase) always MITM even if a passthrough rule matches.
|
||||
// Use for known license endpoints once identified: -mitm-host license.example.com
|
||||
var forceMITM []string
|
||||
|
||||
// Hosts that must NOT be MITM'd for playback to work (CDN / media / BBC APIs).
|
||||
// Transparent mode defaults to passthrough; carve in with open.live / -mitm-host.
|
||||
func shouldPassthrough(host string) bool {
|
||||
if passthroughAll {
|
||||
return true
|
||||
}
|
||||
h := strings.ToLower(stripHostPort(host))
|
||||
if h == "" {
|
||||
return false
|
||||
}
|
||||
for _, m := range forceMITM {
|
||||
if h == m || strings.HasSuffix(h, "."+m) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// No SNI → only have a destination IP; MITM cert/SNI would be wrong.
|
||||
if ip := net.ParseIP(h); ip != nil {
|
||||
return true
|
||||
}
|
||||
// MITM open.live (mediaselector) — stream + Widevine licence URLs live here.
|
||||
// Upstream MUST dial SO_ORIGINAL_DST (VPN fake-IP) or BBC returns geolocation 403.
|
||||
if h == "open.live.bbc.co.uk" {
|
||||
return false
|
||||
}
|
||||
// Other BBC APIs/CDNs: MITM breaks play; passthrough.
|
||||
if strings.Contains(h, "bbc.co.uk") || strings.Contains(h, "bbci.co.uk") ||
|
||||
strings.Contains(h, "bbc.com") || strings.Contains(h, "bbci.com") {
|
||||
return true
|
||||
}
|
||||
needles := []string{
|
||||
"akamai", "akamaized", "cloudfront.net", "fastly",
|
||||
"edgesuite", "cmaf", "2cnt.net", "springstreams",
|
||||
"fingerprint", "optimizely", "appsflyer", "urbanairship",
|
||||
"googleusercontent", "gvt1.com", "googleapis.com",
|
||||
"firebaselogging", "crashlytics", "app-measurement",
|
||||
}
|
||||
for _, n := range needles {
|
||||
if strings.Contains(h, n) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if passthroughDefault {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func handlePassthrough(client net.Conn, host string, port int, dial func(network, addr string) (net.Conn, error)) {
|
||||
defer client.Close()
|
||||
target := net.JoinHostPort(host, strconv.Itoa(port))
|
||||
up, err := dial("tcp", target)
|
||||
if err != nil {
|
||||
log.Printf("[PASS] dial %s: %v", target, err)
|
||||
return
|
||||
}
|
||||
defer up.Close()
|
||||
log.Printf("[PASS] %s (no MITM)", target)
|
||||
errc := make(chan error, 2)
|
||||
var once sync.Once
|
||||
closeWrite := func(c net.Conn) {
|
||||
once.Do(func() {})
|
||||
if tc, ok := c.(*net.TCPConn); ok {
|
||||
_ = tc.CloseWrite()
|
||||
}
|
||||
}
|
||||
go func() {
|
||||
_, err := io.Copy(up, client)
|
||||
errc <- err
|
||||
closeWrite(up)
|
||||
}()
|
||||
go func() {
|
||||
_, err := io.Copy(client, up)
|
||||
errc <- err
|
||||
closeWrite(client)
|
||||
}()
|
||||
<-errc
|
||||
}
|
||||
41
apps/proxy/device/push.sh
Normal file
41
apps/proxy/device/push.sh
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
#!/usr/bin/env bash
|
||||
# Push rteproxy to the phone and start it in the background.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
BIN="${1:-rteproxy-android-arm64}"
|
||||
REMOTE="${REMOTE:-/data/local/tmp/rteproxy}"
|
||||
OUT="${OUT:-/data/local/tmp/rte_cap.json}"
|
||||
LISTEN="${LISTEN:-:8080}"
|
||||
DNS="${DNS:-1.1.1.1,1.0.0.1,8.8.8.8}"
|
||||
ADB="${ADB:-adb}"
|
||||
|
||||
if [[ ! -f "$BIN" ]]; then
|
||||
echo "missing $BIN — run ./build.sh first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
"$ADB" get-state >/dev/null
|
||||
"$ADB" push "$BIN" "$REMOTE"
|
||||
"$ADB" shell chmod 755 "$REMOTE"
|
||||
|
||||
# Stop a previous instance (ignore if none)
|
||||
"$ADB" shell "pkill -f /data/local/tmp/rteproxy" >/dev/null 2>&1 || true
|
||||
sleep 0.5
|
||||
|
||||
"$ADB" shell "sh -c '$REMOTE -listen $LISTEN -out $OUT -ca-dir /data/local/tmp -dns $DNS >/data/local/tmp/rteproxy.log 2>&1 &'"
|
||||
sleep 1
|
||||
|
||||
echo "--- process ---"
|
||||
"$ADB" shell "ps -A | grep rteproxy || true"
|
||||
echo "--- log ---"
|
||||
"$ADB" shell "cat /data/local/tmp/rteproxy.log || true"
|
||||
echo
|
||||
echo "CA cert on device: /data/local/tmp/rteproxy-ca.crt"
|
||||
echo " adb pull /data/local/tmp/rteproxy-ca.crt ."
|
||||
echo " → Settings → Security → Install a certificate → CA certificate"
|
||||
echo
|
||||
echo "Set Wi‑Fi HTTP proxy to 127.0.0.1${LISTEN}"
|
||||
echo "Play the stream, then:"
|
||||
echo " adb pull $OUT /tmp/rte_cap.json"
|
||||
echo " python getrtelive.py"
|
||||
81
apps/proxy/device/tproxy_iptables.sh
Normal file
81
apps/proxy/device/tproxy_iptables.sh
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
#!/system/bin/sh
|
||||
# Transparent redirect: package UID TCP/443 → local appproxy (no Wi‑Fi http_proxy).
|
||||
# Usage:
|
||||
# tproxy_iptables.sh start <package> [port]
|
||||
# tproxy_iptables.sh stop
|
||||
# tproxy_iptables.sh status
|
||||
|
||||
set -eu
|
||||
CHAIN=APPROXY_TPROXY
|
||||
ACTION="${1:-}"
|
||||
|
||||
uid_for_package() {
|
||||
pkg="$1"
|
||||
# dumpsys package <pkg> | grep userId= OR stat on data dir
|
||||
uid=$(dumpsys package "$pkg" 2>/dev/null | grep -m1 -oE 'userId=[0-9]+' | head -1 | cut -d= -f2 || true)
|
||||
if [ -z "$uid" ]; then
|
||||
uid=$(stat -c %u "/data/user/0/$pkg" 2>/dev/null || true)
|
||||
fi
|
||||
echo "$uid"
|
||||
}
|
||||
|
||||
stop_rules() {
|
||||
iptables -t nat -D OUTPUT -j "$CHAIN" 2>/dev/null || true
|
||||
iptables -t nat -F "$CHAIN" 2>/dev/null || true
|
||||
iptables -t nat -X "$CHAIN" 2>/dev/null || true
|
||||
echo "STOPPED"
|
||||
}
|
||||
|
||||
start_rules() {
|
||||
pkg="$1"
|
||||
port="$2"
|
||||
uid=$(uid_for_package "$pkg")
|
||||
if [ -z "$uid" ] || [ "$uid" = "0" ]; then
|
||||
echo "ERR: cannot resolve uid for package $pkg" >&2
|
||||
exit 1
|
||||
fi
|
||||
proxy_uid=$(stat -c %u /data/local/tmp/appproxy 2>/dev/null || echo "")
|
||||
# Prefer the running process uid if available
|
||||
if pidof appproxy >/dev/null 2>&1; then
|
||||
proxy_uid=$(stat -c %u /proc/$(pidof appproxy | awk '{print $1}') 2>/dev/null || echo "$proxy_uid")
|
||||
fi
|
||||
|
||||
stop_rules >/dev/null
|
||||
iptables -t nat -N "$CHAIN"
|
||||
# Never redirect the mitm itself (loop).
|
||||
if [ -n "$proxy_uid" ]; then
|
||||
iptables -t nat -A "$CHAIN" -m owner --uid-owner "$proxy_uid" -j RETURN
|
||||
fi
|
||||
# Skip localhost / link-local.
|
||||
iptables -t nat -A "$CHAIN" -d 127.0.0.0/8 -j RETURN
|
||||
iptables -t nat -A "$CHAIN" -d 10.0.0.0/8 -j RETURN 2>/dev/null || true
|
||||
# Redirect only the target app's HTTPS.
|
||||
iptables -t nat -A "$CHAIN" -p tcp -m owner --uid-owner "$uid" --dport 443 -j REDIRECT --to-ports "$port"
|
||||
iptables -t nat -A OUTPUT -j "$CHAIN"
|
||||
echo "STARTED pkg=$pkg uid=$uid port=$port proxy_uid=${proxy_uid:-unknown}"
|
||||
}
|
||||
|
||||
status_rules() {
|
||||
echo "=== $CHAIN ==="
|
||||
iptables -t nat -L "$CHAIN" -n -v 2>/dev/null || echo "(no chain)"
|
||||
echo "=== OUTPUT head ==="
|
||||
iptables -t nat -L OUTPUT -n -v 2>/dev/null | head -20
|
||||
}
|
||||
|
||||
case "$ACTION" in
|
||||
start)
|
||||
pkg="${2:?package required}"
|
||||
port="${3:-8080}"
|
||||
start_rules "$pkg" "$port"
|
||||
;;
|
||||
stop)
|
||||
stop_rules
|
||||
;;
|
||||
status)
|
||||
status_rules
|
||||
;;
|
||||
*)
|
||||
echo "usage: $0 start <package> [port] | stop | status" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
382
apps/proxy/device/transparent.go
Normal file
382
apps/proxy/device/transparent.go
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// dialFunc is the DNS-aware upstream dialer (same as MITM transport DialContext).
|
||||
type dialFunc func(network, addr string) (net.Conn, error)
|
||||
|
||||
// serveTransparentAccept accepts iptables-REDIRECTED TCP connections, recovers
|
||||
// the original destination / SNI, then feeds them into the local HTTP MITM via
|
||||
// a synthetic CONNECT so UK-VPN routing stays intact (no Wi‑Fi http_proxy).
|
||||
func serveTransparentAccept(ln net.Listener, mitmAddr string, dial dialFunc) {
|
||||
log.Printf("transparent accept on %s → MITM %s", ln.Addr(), mitmAddr)
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
return
|
||||
}
|
||||
log.Printf("transparent accept: %v", err)
|
||||
continue
|
||||
}
|
||||
go handleTransparent(c, mitmAddr, dial)
|
||||
}
|
||||
}
|
||||
|
||||
func handleTransparent(client net.Conn, mitmAddr string, dial dialFunc) {
|
||||
defer client.Close()
|
||||
_ = client.SetDeadline(time.Now().Add(30 * time.Second))
|
||||
|
||||
bc := newBufConn(client)
|
||||
sni, helloErr := peekSNI(bc)
|
||||
dstHost, dstPort, dstErr := originalDst(client)
|
||||
|
||||
host := strings.TrimSpace(sni)
|
||||
port := 443
|
||||
if dstErr == nil && dstPort > 0 {
|
||||
port = dstPort
|
||||
}
|
||||
if host == "" {
|
||||
if dstErr != nil {
|
||||
log.Printf("transparent: no SNI (%v) and no original dst (%v)", helloErr, dstErr)
|
||||
return
|
||||
}
|
||||
host = dstHost
|
||||
}
|
||||
target := net.JoinHostPort(host, strconv.Itoa(port))
|
||||
|
||||
// CDN / media / mediaselector: splice without MITM so playback works.
|
||||
if shouldPassthrough(host) {
|
||||
_ = client.SetDeadline(time.Time{})
|
||||
// Re-feed ClientHello: peekSNI left bytes in bc; rebuild a reader.
|
||||
left := bc.buffered()
|
||||
var clientR net.Conn = client
|
||||
if len(left) > 0 {
|
||||
clientR = &prefixConn{Conn: client, prefix: left}
|
||||
}
|
||||
upDial := dial
|
||||
if upDial == nil {
|
||||
upDial = func(network, addr string) (net.Conn, error) {
|
||||
return net.DialTimeout(network, addr, 15*time.Second)
|
||||
}
|
||||
}
|
||||
// Prefer the app's original destination IP (same CDN edge / geo) over re-resolve.
|
||||
passHost := host
|
||||
if dstErr == nil && net.ParseIP(dstHost) != nil && !hostIsLoopback(dstHost) {
|
||||
passHost = dstHost
|
||||
log.Printf("[PASS] %s → %s:%d (orig-dst)", host, dstHost, port)
|
||||
}
|
||||
handlePassthrough(clientR, passHost, port, upDial)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[TPROXY] %s → CONNECT %s", client.RemoteAddr(), target)
|
||||
|
||||
mitm, err := net.DialTimeout("tcp", mitmAddr, 5*time.Second)
|
||||
if err != nil {
|
||||
log.Printf("transparent dial mitm: %v", err)
|
||||
return
|
||||
}
|
||||
defer mitm.Close()
|
||||
|
||||
// Pass SO_ORIGINAL_DST so MITM upstream dials the app's IP (NordVPN fake-IP
|
||||
// / same CDN edge). Re-resolving via public DNS breaks BBC geolocation.
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "CONNECT %s HTTP/1.1\r\nHost: %s\r\n", target, target)
|
||||
if dstErr == nil && net.ParseIP(dstHost) != nil && !hostIsLoopback(dstHost) {
|
||||
fmt.Fprintf(&b, "X-Appproxy-Orig-Dst: %s\r\n", net.JoinHostPort(dstHost, strconv.Itoa(port)))
|
||||
}
|
||||
b.WriteString("\r\n")
|
||||
if _, err := io.WriteString(mitm, b.String()); err != nil {
|
||||
log.Printf("transparent CONNECT write: %v", err)
|
||||
return
|
||||
}
|
||||
br := bufio.NewReader(mitm)
|
||||
status, err := br.ReadString('\n')
|
||||
if err != nil {
|
||||
log.Printf("transparent CONNECT read: %v", err)
|
||||
return
|
||||
}
|
||||
if !strings.Contains(status, "200") {
|
||||
rest, _ := io.ReadAll(io.LimitReader(br, 512))
|
||||
log.Printf("transparent CONNECT rejected: %s%s", status, rest)
|
||||
return
|
||||
}
|
||||
// Drain remaining response headers.
|
||||
for {
|
||||
line, err := br.ReadString('\n')
|
||||
if err != nil || line == "\r\n" || line == "\n" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
_ = client.SetDeadline(time.Time{})
|
||||
_ = mitm.SetDeadline(time.Time{})
|
||||
|
||||
// Any buffered ClientHello bytes must go to the MITM first.
|
||||
var once sync.Once
|
||||
left := bc.buffered()
|
||||
if len(left) > 0 {
|
||||
if _, err := mitm.Write(left); err != nil {
|
||||
log.Printf("transparent hello write: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
errc := make(chan error, 2)
|
||||
go func() {
|
||||
_, err := io.Copy(mitm, bc)
|
||||
errc <- err
|
||||
once.Do(func() {
|
||||
if tc, ok := mitm.(*net.TCPConn); ok {
|
||||
_ = tc.CloseWrite()
|
||||
}
|
||||
})
|
||||
}()
|
||||
go func() {
|
||||
_, err := io.Copy(client, br)
|
||||
errc <- err
|
||||
if tc, ok := client.(*net.TCPConn); ok {
|
||||
_ = tc.CloseWrite()
|
||||
}
|
||||
}()
|
||||
<-errc
|
||||
}
|
||||
|
||||
// prefixConn emits prefix once, then reads from Conn.
|
||||
type prefixConn struct {
|
||||
net.Conn
|
||||
prefix []byte
|
||||
i int
|
||||
}
|
||||
|
||||
func (p *prefixConn) Read(b []byte) (int, error) {
|
||||
if p.i < len(p.prefix) {
|
||||
n := copy(b, p.prefix[p.i:])
|
||||
p.i += n
|
||||
return n, nil
|
||||
}
|
||||
return p.Conn.Read(b)
|
||||
}
|
||||
|
||||
type bufConn struct {
|
||||
net.Conn
|
||||
r *bufio.Reader
|
||||
}
|
||||
|
||||
func newBufConn(c net.Conn) *bufConn {
|
||||
return &bufConn{Conn: c, r: bufio.NewReaderSize(c, 4096)}
|
||||
}
|
||||
|
||||
func (b *bufConn) Read(p []byte) (int, error) { return b.r.Read(p) }
|
||||
|
||||
func (b *bufConn) buffered() []byte {
|
||||
n := b.r.Buffered()
|
||||
if n == 0 {
|
||||
return nil
|
||||
}
|
||||
buf, _ := b.r.Peek(n)
|
||||
out := make([]byte, len(buf))
|
||||
copy(out, buf)
|
||||
// Consume so later Read does not duplicate.
|
||||
_, _ = b.r.Discard(n)
|
||||
return out
|
||||
}
|
||||
|
||||
// peekSNI reads a TLS ClientHello (via Peek) and returns the SNI hostname.
|
||||
func peekSNI(bc *bufConn) (string, error) {
|
||||
hdr, err := bc.r.Peek(5)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if hdr[0] != 0x16 { // handshake
|
||||
return "", fmt.Errorf("not TLS handshake (type=%d)", hdr[0])
|
||||
}
|
||||
recLen := int(hdr[3])<<8 | int(hdr[4])
|
||||
need := 5 + recLen
|
||||
if need > 16*1024 {
|
||||
return "", fmt.Errorf("ClientHello too large (%d)", need)
|
||||
}
|
||||
// Wait until full record is buffered.
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for bc.r.Buffered() < need && time.Now().Before(deadline) {
|
||||
_ = bc.Conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
|
||||
_, _ = bc.r.Peek(need)
|
||||
}
|
||||
_ = bc.Conn.SetReadDeadline(time.Time{})
|
||||
if bc.r.Buffered() < need {
|
||||
need = bc.r.Buffered()
|
||||
}
|
||||
data, err := bc.r.Peek(need)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parseClientHelloSNI(data)
|
||||
}
|
||||
|
||||
func parseClientHelloSNI(rec []byte) (string, error) {
|
||||
if len(rec) < 5+4 {
|
||||
return "", fmt.Errorf("short record")
|
||||
}
|
||||
if rec[0] != 0x16 || rec[5] != 0x01 { // handshake + client_hello
|
||||
return "", fmt.Errorf("not ClientHello")
|
||||
}
|
||||
// Skip: record hdr(5) + hs type(1) + hs len(3) + client version(2) + random(32)
|
||||
i := 5 + 1 + 3 + 2 + 32
|
||||
if i >= len(rec) {
|
||||
return "", fmt.Errorf("truncated ClientHello")
|
||||
}
|
||||
// session id
|
||||
sidLen := int(rec[i])
|
||||
i += 1 + sidLen
|
||||
if i+2 > len(rec) {
|
||||
return "", fmt.Errorf("truncate cipher suites")
|
||||
}
|
||||
csLen := int(rec[i])<<8 | int(rec[i+1])
|
||||
i += 2 + csLen
|
||||
if i+1 > len(rec) {
|
||||
return "", fmt.Errorf("truncate compression")
|
||||
}
|
||||
compLen := int(rec[i])
|
||||
i += 1 + compLen
|
||||
if i+2 > len(rec) {
|
||||
return "", nil // no extensions
|
||||
}
|
||||
extLen := int(rec[i])<<8 | int(rec[i+1])
|
||||
i += 2
|
||||
end := i + extLen
|
||||
if end > len(rec) {
|
||||
end = len(rec)
|
||||
}
|
||||
for i+4 <= end {
|
||||
typ := int(rec[i])<<8 | int(rec[i+1])
|
||||
l := int(rec[i+2])<<8 | int(rec[i+3])
|
||||
i += 4
|
||||
if i+l > end {
|
||||
break
|
||||
}
|
||||
if typ == 0 { // server_name
|
||||
return parseSNIExtension(rec[i : i+l])
|
||||
}
|
||||
i += l
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func parseSNIExtension(b []byte) (string, error) {
|
||||
if len(b) < 2 {
|
||||
return "", nil
|
||||
}
|
||||
listLen := int(b[0])<<8 | int(b[1])
|
||||
i := 2
|
||||
end := 2 + listLen
|
||||
if end > len(b) {
|
||||
end = len(b)
|
||||
}
|
||||
for i+3 <= end {
|
||||
nameType := b[i]
|
||||
nameLen := int(b[i+1])<<8 | int(b[i+2])
|
||||
i += 3
|
||||
if i+nameLen > end {
|
||||
break
|
||||
}
|
||||
if nameType == 0 {
|
||||
return string(b[i : i+nameLen]), nil
|
||||
}
|
||||
i += nameLen
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func originalDst(conn net.Conn) (host string, port int, err error) {
|
||||
tcp, ok := conn.(*net.TCPConn)
|
||||
if !ok {
|
||||
return "", 0, fmt.Errorf("not TCP")
|
||||
}
|
||||
rc, err := tcp.SyscallConn()
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
var (
|
||||
ip net.IP
|
||||
prt int
|
||||
err2 error
|
||||
)
|
||||
cerr := rc.Control(func(fd uintptr) {
|
||||
ip, prt, err2 = getOrigDstFD(int(fd))
|
||||
})
|
||||
if cerr != nil {
|
||||
return "", 0, cerr
|
||||
}
|
||||
if err2 != nil {
|
||||
return "", 0, err2
|
||||
}
|
||||
return ip.String(), prt, nil
|
||||
}
|
||||
|
||||
func getOrigDstFD(fd int) (net.IP, int, error) {
|
||||
// Try IPv6 structure first (works for IPv4-mapped too on many kernels).
|
||||
if ip, port, err := getOrigDstIPv6(fd); err == nil {
|
||||
return ip, port, nil
|
||||
}
|
||||
return getOrigDstIPv4(fd)
|
||||
}
|
||||
|
||||
func getOrigDstIPv4(fd int) (net.IP, int, error) {
|
||||
const soOriginalDst = 80
|
||||
var addr unix.RawSockaddrInet4
|
||||
sz := uint32(unsafe.Sizeof(addr))
|
||||
_, _, errno := unix.Syscall6(
|
||||
unix.SYS_GETSOCKOPT,
|
||||
uintptr(fd),
|
||||
uintptr(unix.IPPROTO_IP),
|
||||
uintptr(soOriginalDst),
|
||||
uintptr(unsafe.Pointer(&addr)),
|
||||
uintptr(unsafe.Pointer(&sz)),
|
||||
0,
|
||||
)
|
||||
if errno != 0 {
|
||||
return nil, 0, errno
|
||||
}
|
||||
ip := net.IPv4(addr.Addr[0], addr.Addr[1], addr.Addr[2], addr.Addr[3])
|
||||
port := int(binary.BigEndian.Uint16((*[2]byte)(unsafe.Pointer(&addr.Port))[:]))
|
||||
return ip, port, nil
|
||||
}
|
||||
|
||||
func getOrigDstIPv6(fd int) (net.IP, int, error) {
|
||||
const soOriginalDst = 80
|
||||
var addr unix.RawSockaddrInet6
|
||||
sz := uint32(unsafe.Sizeof(addr))
|
||||
_, _, errno := unix.Syscall6(
|
||||
unix.SYS_GETSOCKOPT,
|
||||
uintptr(fd),
|
||||
uintptr(unix.IPPROTO_IPV6),
|
||||
uintptr(soOriginalDst),
|
||||
uintptr(unsafe.Pointer(&addr)),
|
||||
uintptr(unsafe.Pointer(&sz)),
|
||||
0,
|
||||
)
|
||||
if errno != 0 {
|
||||
return nil, 0, errno
|
||||
}
|
||||
ip := make(net.IP, 16)
|
||||
copy(ip, addr.Addr[:])
|
||||
port := int(binary.BigEndian.Uint16((*[2]byte)(unsafe.Pointer(&addr.Port))[:]))
|
||||
return ip, port, nil
|
||||
}
|
||||
|
||||
77
apps/proxy/device/utls_dial.go
Normal file
77
apps/proxy/device/utls_dial.go
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
utls "github.com/refraction-networking/utls"
|
||||
)
|
||||
|
||||
// dialTLSChrome dials addr with a Chrome-like ClientHello so CDNs (Fastly)
|
||||
// are less likely to HTTP 403 Go's default TLS fingerprint.
|
||||
// Handshakes as HTTP/1.1 only so net/http can use the returned conn.
|
||||
func dialTLSChrome(ctx context.Context, dialCtx func(context.Context, string, string) (net.Conn, error), network, addr string) (net.Conn, error) {
|
||||
host := serverNameFromAddr(addr)
|
||||
raw, err := dialCtx(ctx, network, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg := &utls.Config{
|
||||
ServerName: host,
|
||||
InsecureSkipVerify: true,
|
||||
NextProtos: []string{"http/1.1"},
|
||||
}
|
||||
spec, err := utls.UTLSIdToSpec(utls.HelloChrome_120)
|
||||
if err != nil {
|
||||
_ = raw.Close()
|
||||
return dialTLSStd(ctx, dialCtx, network, addr)
|
||||
}
|
||||
for i := range spec.Extensions {
|
||||
if alpn, ok := spec.Extensions[i].(*utls.ALPNExtension); ok {
|
||||
alpn.AlpnProtocols = []string{"http/1.1"}
|
||||
}
|
||||
}
|
||||
uConn := utls.UClient(raw, cfg, utls.HelloCustom)
|
||||
if err := uConn.ApplyPreset(&spec); err != nil {
|
||||
_ = raw.Close()
|
||||
return nil, fmt.Errorf("utls preset %s: %w", host, err)
|
||||
}
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
deadline = time.Now().Add(15 * time.Second)
|
||||
}
|
||||
_ = raw.SetDeadline(deadline)
|
||||
if err := uConn.Handshake(); err != nil {
|
||||
_ = raw.Close()
|
||||
return nil, fmt.Errorf("utls handshake %s: %w", host, err)
|
||||
}
|
||||
_ = raw.SetDeadline(time.Time{})
|
||||
return uConn, nil
|
||||
}
|
||||
|
||||
func dialTLSStd(ctx context.Context, dialCtx func(context.Context, string, string) (net.Conn, error), network, addr string) (net.Conn, error) {
|
||||
host := serverNameFromAddr(addr)
|
||||
raw, err := dialCtx(ctx, network, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg := &tls.Config{ServerName: host, InsecureSkipVerify: true, NextProtos: []string{"http/1.1"}}
|
||||
c := tls.Client(raw, cfg)
|
||||
if err := c.HandshakeContext(ctx); err != nil {
|
||||
_ = raw.Close()
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func serverNameFromAddr(addr string) string {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return strings.TrimSpace(addr)
|
||||
}
|
||||
return host
|
||||
}
|
||||
7
apps/proxy/go.mod
Normal file
7
apps/proxy/go.mod
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
module drmdecryption/apps/proxy
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require drmdecryption v0.0.0
|
||||
|
||||
replace drmdecryption => ../pkg
|
||||
468
apps/proxy/proxyctlcmd/proxyctlcmd.go
Normal file
468
apps/proxy/proxyctlcmd/proxyctlcmd.go
Normal file
|
|
@ -0,0 +1,468 @@
|
|||
// proxyctl — host-side CLI for the on-device HTTPS MITM (appproxy).
|
||||
//
|
||||
// Build the android binary, push it, install/reinject the CA, start/stop the
|
||||
// proxy, run discover mode, and pull captures into outputs/discover/<stamp>.
|
||||
// Package proxyctlcmd is the on-device MITM host CLI, exposed as a library so the
|
||||
// single drm binary can host it as a subcommand.
|
||||
package proxyctlcmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"drmdecryption/adb"
|
||||
"drmdecryption/proxy"
|
||||
"drmdecryption/repo"
|
||||
)
|
||||
|
||||
// Run dispatches a proxy subcommand. args[0] is the subcommand name.
|
||||
func Run(args []string) error {
|
||||
if len(args) < 1 {
|
||||
Usage()
|
||||
return fmt.Errorf("proxy: subcommand required")
|
||||
}
|
||||
sub, rest := args[0], args[1:]
|
||||
switch sub {
|
||||
case "build":
|
||||
buildCmd(rest)
|
||||
case "push":
|
||||
pushCmd(rest)
|
||||
case "install-ca":
|
||||
installCACmd(rest)
|
||||
case "reinject-ca":
|
||||
reinjectCACmd(rest)
|
||||
case "start":
|
||||
startCmd(rest)
|
||||
case "stop":
|
||||
stopCmd(rest)
|
||||
case "discover":
|
||||
discoverCmd(rest)
|
||||
case "pull":
|
||||
pullCmd(rest)
|
||||
case "clear-proxy":
|
||||
clearProxyCmd(rest)
|
||||
case "transparent", "tproxy":
|
||||
transparentCmd(rest)
|
||||
case "help", "-h", "--help":
|
||||
Usage()
|
||||
default:
|
||||
Usage()
|
||||
return fmt.Errorf("proxy: unknown subcommand %q", sub)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Usage prints the proxy subcommand help.
|
||||
func Usage() {
|
||||
fmt.Fprintf(os.Stderr, `proxyctl — on-device MITM (appproxy) host control
|
||||
|
||||
Usage:
|
||||
proxyctl build cross-compile linux/arm64 → bin/ + apps/proxy/
|
||||
proxyctl push [--serial S] push binary to /data/local/tmp/appproxy
|
||||
proxyctl install-ca [--serial S] [--ca PATH] [--reinject]
|
||||
push CA + HASH.0; optional Magisk reinject
|
||||
proxyctl reinject-ca [--serial S] [--hash HASH]
|
||||
Magisk conscrypt bind only (CA already on device)
|
||||
proxyctl start [--serial S] [--bin PATH] [--install-ca] [--reinject]
|
||||
stop old → push → start → set http_proxy
|
||||
proxyctl stop [--serial S] kill the on-device proxy + clear http_proxy
|
||||
proxyctl discover [--serial S] [--out DIR] [--skip-build] [--install-ca] [--reinject]
|
||||
-log-all session; Ctrl+C → outputs/discover/<stamp>
|
||||
proxyctl pull [--serial S] [--out DIR]
|
||||
pull traffic/cap/log into outputs/discover/<stamp>
|
||||
proxyctl clear-proxy [--serial S] clear global http_proxy (+ stop mitm)
|
||||
proxyctl transparent --package PKG [--serial S] [--out DIR] [--reinject]
|
||||
iptables REDIRECT capture (UK VPN OK; no Wi‑Fi proxy)
|
||||
writes /data/local/tmp/capture/<pkg>/ ; adb-pulled to --out
|
||||
|
||||
Artifacts land under outputs/discover/<timestamp>/ by default.
|
||||
Transparent captures pull into outputs/transparent/<stamp>/ by default.
|
||||
`)
|
||||
}
|
||||
|
||||
func clientFrom(fs *flag.FlagSet, args []string) *adb.Client {
|
||||
serial := fs.String("serial", "", "adb device serial")
|
||||
_ = fs.Parse(args)
|
||||
c := adb.New()
|
||||
if *serial != "" {
|
||||
c = c.WithSerial(*serial)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func buildCmd(args []string) {
|
||||
fs := flag.NewFlagSet("build", flag.ExitOnError)
|
||||
_ = fs.Parse(args)
|
||||
root := repo.Root()
|
||||
deviceDir := filepath.Join(root, "apps", "proxy", "device")
|
||||
outLocal := filepath.Join(root, "apps", "proxy", "proxy-android-arm64")
|
||||
outBin := filepath.Join(root, "bin", "proxy-android-arm64")
|
||||
|
||||
fmt.Println("[*] Building linux/arm64 appproxy...")
|
||||
cmd := exec.Command("go", "build", "-ldflags=-s -w", "-o", outLocal, ".")
|
||||
cmd.Dir = deviceDir
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GOOS=linux",
|
||||
"GOARCH=arm64",
|
||||
"CGO_ENABLED=0",
|
||||
)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "build failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
_ = os.MkdirAll(filepath.Join(root, "bin"), 0o755)
|
||||
data, err := os.ReadFile(outLocal)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "read binary: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := os.WriteFile(outBin, data, 0o755); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "copy to bin/: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
// Compat name next to the canonical one.
|
||||
// Legacy copy so hosts that still look for the old name keep working.
|
||||
_ = os.WriteFile(filepath.Join(root, "apps", "proxy", "rteproxy-android-arm64"), data, 0o755)
|
||||
fmt.Println("[+] apps/proxy/proxy-android-arm64")
|
||||
fmt.Println("[+] bin/proxy-android-arm64")
|
||||
}
|
||||
|
||||
func pushCmd(args []string) {
|
||||
fs := flag.NewFlagSet("push", flag.ExitOnError)
|
||||
bin := fs.String("bin", "", "local proxy binary (default: auto)")
|
||||
c := clientFrom(fs, args)
|
||||
if err := c.EnsureDevice(); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
local := *bin
|
||||
if local == "" {
|
||||
local = proxy.FindLocalBin("")
|
||||
}
|
||||
if local == "" {
|
||||
fatal(fmt.Errorf("no proxy binary — run: proxyctl build"))
|
||||
}
|
||||
fmt.Printf("[*] Pushing %s → %s\n", local, proxy.RemoteBin)
|
||||
if err := c.Push(local, proxy.RemoteBin); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
_, _ = c.Shell("chmod", "755", proxy.RemoteBin)
|
||||
fmt.Println("[+] pushed")
|
||||
}
|
||||
|
||||
func installCACmd(args []string) {
|
||||
fs := flag.NewFlagSet("install-ca", flag.ExitOnError)
|
||||
ca := fs.String("ca", "", "local CA PEM (default: the CA pulled from the device)")
|
||||
reinject := fs.Bool("reinject", false, "Magisk-reinject into conscrypt after push")
|
||||
c := clientFrom(fs, args)
|
||||
if err := c.EnsureDevice(); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
hash, err := proxy.InstallCA(c, *ca, *reinject)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
fmt.Printf("[+] CA hash %s installed on device\n", hash)
|
||||
}
|
||||
|
||||
func reinjectCACmd(args []string) {
|
||||
fs := flag.NewFlagSet("reinject-ca", flag.ExitOnError)
|
||||
hash := fs.String("hash", proxy.DefaultCAHash, "Android CA subject_hash_old")
|
||||
c := clientFrom(fs, args)
|
||||
if err := c.EnsureDevice(); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
proxy.ReinjectCA(c, *hash)
|
||||
}
|
||||
|
||||
func startCmd(args []string) {
|
||||
fs := flag.NewFlagSet("start", flag.ExitOnError)
|
||||
bin := fs.String("bin", "", "local proxy binary (default: auto)")
|
||||
installCA := fs.Bool("install-ca", false, "push CA + HASH.0 before start")
|
||||
reinject := fs.Bool("reinject", false, "Magisk-reinject CA (implies -install-ca)")
|
||||
c := clientFrom(fs, args)
|
||||
if err := c.EnsureDevice(); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
if *reinject {
|
||||
*installCA = true
|
||||
}
|
||||
if *installCA {
|
||||
if _, err := proxy.InstallCA(c, "", *reinject); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
}
|
||||
local := *bin
|
||||
if local == "" {
|
||||
local = proxy.FindLocalBin("")
|
||||
}
|
||||
if local == "" {
|
||||
fatal(fmt.Errorf("no proxy binary — run: proxyctl build"))
|
||||
}
|
||||
if err := proxy.PushAndStart(c, local); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
if err := proxy.EnsureHTTPProxy(c, ""); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func stopCmd(args []string) {
|
||||
fs := flag.NewFlagSet("stop", flag.ExitOnError)
|
||||
c := clientFrom(fs, args)
|
||||
proxy.Stop(c)
|
||||
proxy.ClearHTTPProxy(c)
|
||||
}
|
||||
|
||||
func clearProxyCmd(args []string) {
|
||||
fs := flag.NewFlagSet("clear-proxy", flag.ExitOnError)
|
||||
c := clientFrom(fs, args)
|
||||
proxy.ClearHTTPProxy(c)
|
||||
}
|
||||
|
||||
func transparentCmd(args []string) {
|
||||
fs := flag.NewFlagSet("transparent", flag.ExitOnError)
|
||||
pkg := fs.String("package", "", "app package to redirect (e.g. bbc.iplayer.android)")
|
||||
out := fs.String("out", "", "host pull dir (default: outputs/transparent/<stamp>)")
|
||||
bin := fs.String("bin", "", "local proxy binary (default: auto)")
|
||||
reinject := fs.Bool("reinject", false, "Magisk-reinject CA before start (slow; CA usually already mounted)")
|
||||
port := fs.String("port", "8080", "transparent listen port on device")
|
||||
noTail := fs.Bool("no-tail", false, "start only; do not wait / pull on Ctrl+C")
|
||||
c := clientFrom(fs, args)
|
||||
if err := c.EnsureDevice(); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
if strings.TrimSpace(*pkg) == "" {
|
||||
fatal(fmt.Errorf("--package is required (e.g. --package bbc.iplayer.android)"))
|
||||
}
|
||||
local := *bin
|
||||
if local == "" {
|
||||
local = proxy.FindLocalBin("")
|
||||
}
|
||||
if local == "" {
|
||||
fatal(fmt.Errorf("no proxy binary — run: proxyctl build"))
|
||||
}
|
||||
|
||||
// Never leave a stale Wi‑Fi proxy when using transparent mode.
|
||||
proxy.ClearHTTPProxy(c)
|
||||
|
||||
remoteDir := "/data/local/tmp/capture/" + *pkg
|
||||
fmt.Printf("[*] Transparent capture for %s → %s\n", *pkg, remoteDir)
|
||||
if err := proxy.StartTransparent(c, local, *pkg, *port, remoteDir, *reinject); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
fmt.Println("[+] running. Leave UK VPN ON. Open the app and play.")
|
||||
fmt.Println(" Ctrl+C → stop iptables + pull captures")
|
||||
if *noTail {
|
||||
return
|
||||
}
|
||||
|
||||
dest := *out
|
||||
if dest == "" {
|
||||
dest = filepath.Join(repo.Root(), "outputs", "transparent", time.Now().Format("20060102-150405"))
|
||||
}
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, os.Interrupt)
|
||||
<-sig
|
||||
fmt.Println("\n[*] Stopping transparent capture...")
|
||||
proxy.StopTransparent(c)
|
||||
_ = os.MkdirAll(dest, 0o755)
|
||||
if err := proxy.PullDir(c, remoteDir, dest); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "pull: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("[+] pulled into", dest)
|
||||
}
|
||||
}
|
||||
|
||||
func pullCmd(args []string) {
|
||||
fs := flag.NewFlagSet("pull", flag.ExitOnError)
|
||||
out := fs.String("out", "", "destination dir (default: outputs/discover/<stamp>)")
|
||||
c := clientFrom(fs, args)
|
||||
if err := c.EnsureDevice(); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
dest := *out
|
||||
if dest == "" {
|
||||
dest = proxy.DiscoverOutDir("", "")
|
||||
}
|
||||
if err := proxy.PullCaptures(c, dest); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
fmt.Println("[+] pulled into", dest)
|
||||
}
|
||||
|
||||
func discoverCmd(args []string) {
|
||||
fs := flag.NewFlagSet("discover", flag.ExitOnError)
|
||||
out := fs.String("out", "", "destination dir (default: outputs/discover/<stamp>)")
|
||||
skipBuild := fs.Bool("skip-build", false, "do not rebuild the android binary")
|
||||
installCA := fs.Bool("install-ca", true, "push CA + HASH.0 before discover")
|
||||
reinject := fs.Bool("reinject", true, "Magisk-reinject CA (default on for discover)")
|
||||
noTail := fs.Bool("no-tail", false, "start only; do not tail / wait for Ctrl+C")
|
||||
listen := fs.String("listen", ":8080", "proxy listen address on device")
|
||||
pkgForce := fs.String("force-stop", "", "package to force-stop after CA reinject (so it inherits the new mount)")
|
||||
c := clientFrom(fs, args)
|
||||
|
||||
root := repo.Root()
|
||||
dest := *out
|
||||
if dest == "" {
|
||||
dest = proxy.DiscoverOutDir(root, "")
|
||||
}
|
||||
_ = os.MkdirAll(dest, 0o755)
|
||||
|
||||
if !*skipBuild {
|
||||
buildCmd(nil)
|
||||
}
|
||||
|
||||
if err := c.EnsureDevice(); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
fmt.Println("[*] Device:", c.Out("get-serialno"))
|
||||
|
||||
local := proxy.FindLocalBin(root)
|
||||
if local == "" {
|
||||
fatal(fmt.Errorf("no proxy binary — run: proxyctl build"))
|
||||
}
|
||||
|
||||
if *installCA || *reinject {
|
||||
if _, err := proxy.InstallCA(c, "", *reinject); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[!] install-ca: %v\n", err)
|
||||
}
|
||||
}
|
||||
if *pkgForce != "" {
|
||||
c.ForceStop(*pkgForce)
|
||||
fmt.Printf("[*] Force-stopped %s\n", *pkgForce)
|
||||
}
|
||||
|
||||
proxy.Stop(c)
|
||||
fmt.Printf("[*] Pushing %s → %s (discover / -log-all)\n", local, proxy.RemoteBin)
|
||||
if err := c.Push(local, proxy.RemoteBin); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
_, _ = c.Shell("chmod", "755", proxy.RemoteBin)
|
||||
_, _ = c.Shell("rm", "-f", proxy.RemoteLog, proxy.RemoteCap, proxy.RemoteTraffic)
|
||||
|
||||
starter := "#!/system/bin/sh\n" +
|
||||
"exec " + proxy.RemoteBin +
|
||||
" -listen " + *listen +
|
||||
" -out " + proxy.RemoteCap +
|
||||
" -ca-dir /data/local/tmp" +
|
||||
" -dns 1.1.1.1,1.0.0.1,8.8.8.8,192.168.1.1" +
|
||||
" -log-all -traffic " + proxy.RemoteTraffic +
|
||||
" -v >>" + proxy.RemoteLog + " 2>&1\n"
|
||||
tmp := filepath.Join(os.TempDir(), "start_appproxy_discover.sh")
|
||||
if err := os.WriteFile(tmp, []byte(starter), 0o755); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
defer os.Remove(tmp)
|
||||
if err := c.Push(tmp, "/data/local/tmp/start_appproxy.sh"); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
_, _ = c.Shell("chmod", "755", "/data/local/tmp/start_appproxy.sh")
|
||||
_, _, _ = c.Run("shell", "setsid /data/local/tmp/start_appproxy.sh </dev/null >/dev/null 2>&1 &")
|
||||
|
||||
ok := false
|
||||
var pid, head string
|
||||
for i := 0; i < 12; i++ {
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
pid = c.Out("shell", "pidof", "appproxy")
|
||||
if pid == "" {
|
||||
// Legacy process name.
|
||||
pid = c.Out("shell", "pidof", "rteproxy")
|
||||
}
|
||||
head = c.Out("shell", "head", "-20", proxy.RemoteLog)
|
||||
if pid != "" && containsListening(head) {
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if head != "" {
|
||||
fmt.Println(head)
|
||||
}
|
||||
if !ok {
|
||||
fmt.Fprintln(os.Stderr, c.Out("shell", "cat", proxy.RemoteLog))
|
||||
fatal(fmt.Errorf("appproxy failed to start"))
|
||||
}
|
||||
fmt.Printf("[+] appproxy pid=%s\n", pid)
|
||||
|
||||
if err := proxy.EnsureHTTPProxy(c, ""); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("=== Discover mode ready ===")
|
||||
fmt.Println("1. Unlock the phone and open the target app.")
|
||||
fmt.Println("2. Start playback so DRM + manifest traffic flows.")
|
||||
fmt.Println("3. Ctrl+C stops the tail and pulls captures.")
|
||||
fmt.Println()
|
||||
fmt.Println("Local folder:", dest)
|
||||
fmt.Println()
|
||||
|
||||
if *noTail {
|
||||
fmt.Println("Started without tail (-no-tail). Pull later with: proxyctl pull")
|
||||
return
|
||||
}
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
tailDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(tailDone)
|
||||
cmd := exec.Command(c.Bin, append(serialArgs(c), "shell", "tail", "-f", proxy.RemoteLog)...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
_ = cmd.Run()
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-sig:
|
||||
fmt.Println()
|
||||
fmt.Println("[*] Stopping tail...")
|
||||
case <-tailDone:
|
||||
}
|
||||
|
||||
fmt.Println("[*] Pulling captures into", dest)
|
||||
_ = proxy.PullCaptures(c, dest)
|
||||
proxy.ClearHTTPProxy(c)
|
||||
fmt.Println("[+] Done. Inspect:")
|
||||
entries, _ := os.ReadDir(dest)
|
||||
for _, e := range entries {
|
||||
info, _ := e.Info()
|
||||
size := int64(0)
|
||||
if info != nil {
|
||||
size = info.Size()
|
||||
}
|
||||
fmt.Printf(" %s (%d bytes)\n", e.Name(), size)
|
||||
}
|
||||
tip := filepath.Join(dest, "appproxy_traffic.jsonl")
|
||||
if runtime.GOOS == "windows" {
|
||||
fmt.Printf("Tip: Select-String -Path '%s' -Pattern 'mpd|license|widevine|manifest'\n", tip)
|
||||
} else {
|
||||
fmt.Printf("Tip: grep -E 'mpd|license|widevine|manifest' %s\n", tip)
|
||||
}
|
||||
}
|
||||
|
||||
func serialArgs(c *adb.Client) []string {
|
||||
if c.Serial == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{"-s", c.Serial}
|
||||
}
|
||||
|
||||
func containsListening(s string) bool {
|
||||
return strings.Contains(strings.ToLower(s), "listening")
|
||||
}
|
||||
|
||||
func fatal(err error) {
|
||||
fmt.Fprintf(os.Stderr, "proxyctl: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
20
apps/proxy/rteproxy-ca.crt
Normal file
20
apps/proxy/rteproxy-ca.crt
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
-----BEGIN CERTIFICATE-----
|
||||
MIIDSjCCAjKgAwIBAgIQYdaH1QnfE8PmjHy1H12hXDANBgkqhkiG9w0BAQsFADAu
|
||||
MRkwFwYDVQQKExBydGVwcm94eSBNSVRNIENBMREwDwYDVQQDEwhydGVwcm94eTAe
|
||||
Fw0yNjEwMDExNTU1MDhaFw0zNjA5MjgxNjU1MDhaMC4xGTAXBgNVBAoTEHJ0ZXBy
|
||||
b3h5IE1JVE0gQ0ExETAPBgNVBAMTCHJ0ZXByb3h5MIIBIjANBgkqhkiG9w0BAQEF
|
||||
AAOCAQ8AMIIBCgKCAQEA2WS523uzgge/trRY6YXvtSa8JfTizzdKF0OLps8Dhnq8
|
||||
9mQMVuCTdgiQyxRk75bJ6k0e/3NePEE8V2/GAeYtFtiqAC5p82d42Bt6wuXADgsH
|
||||
+tzBuuIr04w5KkUlWV6sI7BVBNE4lD8He56xkfRHlZMYb2anbAC51AQEmTR0Mu3u
|
||||
ep37UUo0xcrDI+oBh+mLWF5uVgt2XnxmIQE2r95dwKqomWVgzGsc1EkZoPQyhcJX
|
||||
xE0f71Dtb2zM7GcWaHzDjVZTasqtsY6ISy2v6m063GO+QDpW3GGWqlPOxTyawWox
|
||||
j4NS4DyBFnpRtFTTMJKq731dQHh5nyVqD1hBtLyViQIDAQABo2QwYjAOBgNVHQ8B
|
||||
Af8EBAMCAYYwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMBIGA1UdEwEB
|
||||
/wQIMAYBAf8CAQEwHQYDVR0OBBYEFPqzqtGYBgxFzTT4O6roQjvof7xnMA0GCSqG
|
||||
SIb3DQEBCwUAA4IBAQCuoLG8GRdCIc4HeM6MyEMQU+P2PiMi6K1jJOZo4ArT4bAB
|
||||
5OlOphvduzRHMsszl+V+XliwfGkK20L50ykcmj9KR3TseMMelJWOqHKzV7H5yyKj
|
||||
Hsz2jXGHKQIMs4p0thSxsbwcewIoIjwoiFFs5Ji0l3U9kc1CzEtuixgxGovJHTN9
|
||||
W9LoU4mjFHUWC08+n7jtAazXvzhOOII37P4y2v6AFhVC0yiNUu407p3AjRP2Eyeq
|
||||
p8YOTnncsnpdpjv11s4mZRbF4Jpp6jvNhePgdNqMZAcov+c+L1KIWui0VBzF4XQI
|
||||
AaENO1ApSPIXlM19FCVNPNufMEPAfbIq/eMZyg7J
|
||||
-----END CERTIFICATE-----
|
||||
Loading…
Add table
Add a link
Reference in a new issue