diff --git a/.github/workflows/build-for-x86_64.yml b/.github/workflows/build-for-x86_64.yml index 67dd2a8..f60c056 100644 --- a/.github/workflows/build-for-x86_64.yml +++ b/.github/workflows/build-for-x86_64.yml @@ -26,6 +26,9 @@ jobs: curl -fLO https://dl.google.com/android/repository/android-ndk-r23b-linux.zip unzip -qd . android-ndk-r23b-linux.zip + - name: Wrapper drift check + run: python3 scripts/check-drift.py + - name: Build run: | mkdir build diff --git a/Dockerfile.build b/Dockerfile.build new file mode 100644 index 0000000..b0f8b03 --- /dev/null +++ b/Dockerfile.build @@ -0,0 +1,36 @@ +FROM debian:13.2 + +ARG TARGET_ARCH=amd64 +ARG NDK_VERSION=23 + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + cmake \ + unzip \ + git \ + ca-certificates \ + aria2 \ + && rm -rf /var/lib/apt/lists/* + +# NOTE: No system LLVM install needed — the Android NDK bundles its own +# clang at android-ndk-rb/toolchains/llvm/prebuilt/linux-x86_64/bin/ + +# Download Android NDK +RUN aria2c -x 16 -o ndk.zip \ + https://dl.google.com/android/repository/android-ndk-r${NDK_VERSION}b-linux.zip \ + && unzip -q -d /app ndk.zip \ + && rm ndk.zip + +# Copy source (no glob — explicit) +COPY cmdline.c cmdline.h cmdline.ggo ./ +COPY main.c main.cpp ./ +COPY wrapper.c wrapper-rootless.c ./ +COPY import.h ./ +COPY CMakeLists.txt ./ +COPY rootfs ./rootfs + +RUN mkdir -p build \ + && cmake -S /app -B /app/build -DTARGET_ARCH=${TARGET_ARCH} \ + && cmake --build /app/build -j$(nproc) diff --git a/main.c b/main.c index fbc7931..0699066 100644 --- a/main.c +++ b/main.c @@ -34,6 +34,26 @@ static char *g_storefront_id = NULL; static char *g_dev_token = NULL; static char *g_music_token = NULL; +/* Write a single-word state token to base_dir/drm-state. + * The Go engine reads this file via inotify to track wrapper lifecycle. + * States: STARTING LOGIN WAITING_2FA INITIALIZING_FAIRPLAY RUNNING + * RECOVERY FAILED STOPPED + */ +static void write_drm_state(const char *state) { + if (!args_info.base_dir_arg) return; + char path[512]; + snprintf(path, sizeof(path), "%s/drm-state", args_info.base_dir_arg); + FILE *fp = fopen(path, "w"); + if (!fp) return; + fprintf(fp, "%s\n", state); + fclose(fp); +} + +/* Protects preshareCtx against concurrent access from the main decrypt + * thread (handle/getKdContext) and the recovery worker (refresh_decrypt_ctx). + * Using PTHREAD_MUTEX_INITIALIZER avoids the need for an explicit init call. */ +static pthread_mutex_t g_ctx_mutex = PTHREAD_MUTEX_INITIALIZER; + #ifndef MyRelease static int (*orig_debug_log_enabled)(void); static int (*orig_android_log_print)(int prio, const char *tag, const char *fmt, ...); @@ -201,9 +221,10 @@ static void credentialHandler(struct shared_ptr *credReqHandler, int passLen = strlen(amPassword); if (need2FA) { + write_drm_state("WAITING_2FA"); if (args_info.code_from_file_flag) { fprintf(stderr, "[!] Enter your 2FA code into rootfs/%s/2fa.txt\n", args_info.base_dir_arg); - fprintf(stderr, "[!] Example command: echo -n 114514 > rootfs/%s/2fa.txt\n", args_info.base_dir_arg); + fprintf(stderr, "[!] Example command: echo -n 123456 > rootfs/%s/2fa.txt\n", args_info.base_dir_arg); fprintf(stderr, "[!] Waiting for input...\n"); int count = 0; while (1) @@ -265,7 +286,7 @@ static inline void init() { setenv("all_proxy", args_info.proxy_arg, 1); } - static const char *resolvers[2] = {"223.5.5.5", "223.6.6.6"}; + static const char *resolvers[2] = {"1.1.1.1", "8.8.8.8"}; _resolv_set_nameservers_for_net(0, resolvers, 2, "."); // static char android_id[16]; @@ -363,6 +384,10 @@ static inline struct shared_ptr init_ctx() { extern void *endLeaseCallback; extern void *pbErrCallback; +extern void start_recovery_thread(void); +extern int is_recovery_active(void); +/* Returns current RecoveryState as int: 0=Running 1=Scheduled 2=Refreshing 3=Failed */ +extern int get_recovery_state(void); inline static uint8_t login(struct shared_ptr reqCtx) { fprintf(stderr, "[+] logging in...\n"); @@ -439,9 +464,19 @@ static void *preshareCtx = NULL; inline static void *getKdContext(const char *const adam, const char *const uri) { uint8_t isPreshare = (strcmp("0", adam) == 0); - if (isPreshare && preshareCtx != NULL) { - return preshareCtx; + + /* Fast-path: return cached preshare context if available. + * Lock only long enough to read the pointer — the long FairPlay + * network operations below must NOT be performed under this lock, + * or the recovery worker would block all decryption during reacquisition. */ + if (isPreshare) { + pthread_mutex_lock(&g_ctx_mutex); + void *cached = preshareCtx; + pthread_mutex_unlock(&g_ctx_mutex); + if (cached != NULL) + return cached; } + fprintf(stderr, "[.] adamId: %s, uri: %s\n", adam, uri); union std_string defaultId = new_std_string(adam); @@ -471,22 +506,61 @@ inline static void *getKdContext(const char *const adam, void *kdContext = *_ZNK18SVFootHillPContext9kdContextEv(SVFootHillPContext.obj); - if (kdContext != NULL && isPreshare) + + /* Store result under lock so the recovery worker sees a consistent value + * if it concurrently resets preshareCtx to NULL. */ + if (kdContext != NULL && isPreshare) { + pthread_mutex_lock(&g_ctx_mutex); preshareCtx = kdContext; + pthread_mutex_unlock(&g_ctx_mutex); + } + return kdContext; } -void refresh_decrypt_ctx() { +void refresh_decrypt_ctx(void) { uint8_t autom = 1; + + /* Request a new playback lease from Apple. */ _ZN22SVPlaybackLeaseManager12requestLeaseERKb(leaseMgr, &autom); + + /* Tear down all cached FairPlay key contexts so fresh keys are derived. */ _ZN21SVFootHillSessionCtrl16resetAllContextsEv(FHinstance); + + /* Invalidate the preshare cache under lock before rebuilding it. + * Any concurrent getKdContext() call will miss the cache and fall through + * to a full key derivation — correct behaviour during recovery. */ + pthread_mutex_lock(&g_ctx_mutex); preshareCtx = NULL; - preshareCtx = getKdContext("0", "skd://itunes.apple.com/P000000000/s1/e1"); + pthread_mutex_unlock(&g_ctx_mutex); + + /* Rebuild the preshare context. getKdContext() will write the new pointer + * under g_ctx_mutex internally. */ + getKdContext("0", "skd://itunes.apple.com/P000000000/s1/e1"); + fprintf(stderr, "[!] refreshed context\n"); } +/* Called by the recovery worker to determine whether reacquisition produced + * a usable decrypt context. Reads preshareCtx under g_ctx_mutex. */ +int is_preshare_ctx_ready(void) { + pthread_mutex_lock(&g_ctx_mutex); + int ready = (preshareCtx != NULL); + pthread_mutex_unlock(&g_ctx_mutex); + return ready; +} + void handle(const int connfd) { while (1) { + /* Fail fast during lease recovery: avoid queuing FairPlay key- + * delivery requests to Apple's servers while the recovery worker + * is already performing a refresh cycle. The client receives an + * EOF/broken-pipe and should retry after a brief pause. */ + if (is_recovery_active()) { + fprintf(stderr, "[.] decrypt request refused: lease recovery in progress\n"); + return; + } + uint8_t adamSize; if (!readfull(connfd, &adamSize, sizeof(uint8_t))) return; @@ -692,6 +766,17 @@ void handle_m3u8(const int connfd) { char *ptr; unsigned long adamID = strtoul(adam, &ptr, 10); const char *m3u8; + + /* During lease recovery the decrypt context is being rebuilt. + * Return an empty line (same as a failed asset request) so the + * client can detect the condition and retry rather than waiting + * on a network call that will fail anyway. */ + if (is_recovery_active()) { + fprintf(stderr, "[.] m3u8 request refused: lease recovery in progress\n"); + writefull(connfd, "\n", 1); + continue; + } + if (offlineFlag) { m3u8 = get_m3u8_method_download(reqCtx, adamID); } else { @@ -1056,12 +1141,14 @@ int main(int argc, char *argv[]) { init(); reqCtx = init_ctx(); + write_drm_state("STARTING"); if (args_info.login_given) { amUsername = strtok(args_info.login_arg, ":"); amPassword = strtok(NULL, ":"); } if (args_info.login_given && !login(reqCtx)) { fprintf(stderr, "[!] login failed\n"); + write_drm_state("FAILED"); return EXIT_FAILURE; } _ZN22SVPlaybackLeaseManagerC2ERKNSt6__ndk18functionIFvRKiEEERKNS1_IFvRKNS0_10shared_ptrIN17storeservicescore19StoreErrorConditionEEEEEE( @@ -1071,6 +1158,12 @@ int main(int argc, char *argv[]) { _ZN22SVPlaybackLeaseManager12requestLeaseERKb(leaseMgr, &autom); FHinstance = _ZN21SVFootHillSessionCtrl8instanceEv(); + /* Start the async recovery thread. Must be started after leaseMgr and + * FHinstance are initialised so that refresh_decrypt_ctx() is safe to call + * from the worker at any point after this. */ + start_recovery_thread(); + write_drm_state("INITIALIZING_FAIRPLAY"); + offlineFlag = offline_available(); if (offlineFlag) { fprintf(stderr, "[+] This account supports offline channel\n"); @@ -1080,22 +1173,26 @@ int main(int argc, char *argv[]) { g_storefront_id = get_account_storefront_id(reqCtx); if (g_storefront_id == NULL) { fprintf(stderr, "[!] failed to get storefront ID\n"); + write_drm_state("FAILED"); return EXIT_FAILURE; } g_dev_token = get_dev_token(reqCtx); if (g_dev_token == NULL) { fprintf(stderr, "[!] failed to get dev token\n"); + write_drm_state("FAILED"); return EXIT_FAILURE; } g_music_token = get_music_user_token(get_guid(), g_dev_token, reqCtx); if (g_music_token == NULL) { fprintf(stderr, "[!] failed to get music token\n"); + write_drm_state("FAILED"); return EXIT_FAILURE; } fprintf(stderr, "[+] account info cached successfully\n"); write_storefront_id(); write_music_token(); + write_drm_state("RUNNING"); pthread_t m3u8_thread; pthread_create(&m3u8_thread, NULL, &new_socket_m3u8, NULL); diff --git a/main.cpp b/main.cpp index fec64f4..c38235b 100644 --- a/main.cpp +++ b/main.cpp @@ -1,10 +1,242 @@ +/** + * main.cpp — C++ glue: lease/playback callbacks and recovery subsystem. + * + * Recovery state machine + * ───────────────────── + * + * ┌──────────────────────────────────────────────────────┐ + * │ Running │◄──────────────┐ + * └──────────────────────────────┬───────────────────────┘ │ + * │ Lease end / Playback error │ + * ▼ │ + * ┌──────────────────────────────────────────────────────┐ │ + * │ Scheduled │ │ + * │ (event queued; worker waking; backoff pending) │ │ + * └──────────────────────────────┬───────────────────────┘ │ + * │ backoff elapsed │ + * ▼ │ + * ┌──────────────────────────────────────────────────────┐ │ + * │ Refreshing │ │ + * │ (refresh_decrypt_ctx() in progress; requests │ │ + * │ gated at handle() / handle_m3u8()) │ │ + * └───────────────┬──────────────────────────────┬───────┘ │ + * │ preshareCtx non-null │ preshareCtx NULL │ + * ▼ ▼ │ + * ┌───────────────────────────┐ ┌───────────────────────────┐ │ + * │ Running │ │ Failed │ │ + * │ consec_fails = 0 │ │ consec_fails++ │ │ + * │ normal service resumes │ │ auto-schedules retry │──────────┘ + * └───────────────────────────┘ └───────────────────────────┘ + * + * Auto-retry on failure + * ───────────────────── + * When refresh_decrypt_ctx() succeeds but preshareCtx remains NULL (e.g. + * transient Apple server error, network interruption), no further Apple event + * arrives, so the worker would stall waiting on the condition variable. + * To avoid this, the worker self-schedules a retry via schedule_recovery() + * before looping back. The retry goes through the same queue and backoff + * path, ensuring exponential spacing even for self-triggered retries. + * + * Coalescing + * ────────── + * The queue is drained completely on each wake. A burst of events + * (3084 → 3084 → PLAYBACK_ERR) results in one refresh cycle, not three. + * + * RecoveryState visibility + * ──────────────────────── + * get_recovery_state() is exported as extern "C" and returns RecoveryState + * as a plain int so main.c (and a future HTTP status endpoint or Electron IPC + * handler) can expose the daemon's current condition without C++ knowledge. + * Values map directly to the RecoveryState enum below. + */ + +#include +#include #include #include #include +#include +#include +#include +#include /* sleep() */ +#include +extern "C" void refresh_decrypt_ctx(void); +extern "C" int is_preshare_ctx_ready(void); extern "C" void handle(int fd); -extern "C" uint8_t handle_cpp(int fd) { +// --------------------------------------------------------------------------- +// RecoveryState — the daemon's lifecycle state machine +// --------------------------------------------------------------------------- + +enum class RecoveryState : int { + Running = 0, // Normal operation; all requests served + Scheduled = 1, // Recovery event queued; worker waking; backoff pending + Refreshing = 2, // refresh_decrypt_ctx() in flight; requests gated + Failed = 3, // Last cycle failed; auto-retry queued +}; + +static std::atomic g_recovery_state{RecoveryState::Running}; + +// Returns the current RecoveryState as a plain int (C-visible). +// 0 = Running, 1 = Scheduled, 2 = Refreshing, 3 = Failed. +extern "C" int get_recovery_state(void) +{ + return static_cast(g_recovery_state.load()); +} + +// Convenience predicate used by handle() / handle_m3u8() to gate requests. +// Returns 1 when the daemon is not in its normal Running state. +extern "C" int is_recovery_active(void) +{ + return (g_recovery_state.load() != RecoveryState::Running) ? 1 : 0; +} + +// --------------------------------------------------------------------------- +// Recovery queue +// --------------------------------------------------------------------------- + +// Event codes pushed into the recovery queue. +static const int kPlaybackErrSentinel = -1; // playback error from pbErrCb +static const int kRetryInternal = -2; // self-scheduled retry on failure + +// Exponential backoff: 1 → 2 → 5 → 10 → 30 seconds. +// kBackoffMaxIdx is reused indefinitely so retries never stop. +static const int kBackoffSecs[] = {1, 2, 5, 10, 30}; +static const int kBackoffMaxIdx = 4; + +static std::mutex g_recovery_mtx; +static std::condition_variable g_recovery_cv; +static std::queue g_recovery_q; + +static void schedule_recovery(int code) +{ + { + std::lock_guard lk(g_recovery_mtx); + g_recovery_q.push(code); + } + g_recovery_cv.notify_one(); +} + +// --------------------------------------------------------------------------- +// Recovery worker — the single owner of retry timing and reacquisition +// --------------------------------------------------------------------------- + +static void recovery_worker() +{ + int consec_fails = 0; + + while (true) { + + // ── Wait for at least one event ───────────────────────────────────── + std::vector burst; + { + std::unique_lock lk(g_recovery_mtx); + g_recovery_cv.wait(lk, []{ + return !g_recovery_q.empty(); + }); + // Drain the entire queue (coalescing). + while (!g_recovery_q.empty()) { + burst.push_back(g_recovery_q.front()); + g_recovery_q.pop(); + } + } + + g_recovery_state.store(RecoveryState::Scheduled); + + // ── Log every code in the burst ───────────────────────────────────── + fprintf(stderr, "[recovery] STATE=Scheduled — %zu event(s) coalesced:\n", + burst.size()); + for (int c : burst) { + switch (c) { + case kPlaybackErrSentinel: + fprintf(stderr, "[recovery] PLAYBACK_ERROR\n"); + break; + case kRetryInternal: + fprintf(stderr, "[recovery] RETRY_INTERNAL " + "(self-scheduled after failed refresh)\n"); + break; + case 3084: + fprintf(stderr, "[recovery] LEASE_END code=3084 " + "(server revoke — simultaneous playback " + "or natural expiry)\n"); + break; + default: + fprintf(stderr, "[recovery] LEASE_END code=%d " + "(unknown — logging for future mapping)\n", c); + break; + } + } + + // ── Exponential backoff ───────────────────────────────────────────── + int idx = (consec_fails <= kBackoffMaxIdx) ? consec_fails + : kBackoffMaxIdx; + int delay = kBackoffSecs[idx]; + + if (consec_fails == 0) { + fprintf(stderr, + "[recovery] RECOVERY_SCHEDULED attempt=1 backoff=%ds\n", + delay); + } else { + fprintf(stderr, + "[recovery] RECOVERY_SCHEDULED attempt=%d backoff=%ds " + "(Apple may still be rejecting — retrying indefinitely)\n", + consec_fails + 1, delay); + } + sleep(delay); + + // ── Enter Refreshing state — gate incoming client requests ────────── + g_recovery_state.store(RecoveryState::Refreshing); + fprintf(stderr, "[recovery] STATE=Refreshing — calling refresh_decrypt_ctx()\n"); + + // Single-threaded by design: the worker is the only caller of + // refresh_decrypt_ctx(). No other code path calls it directly. + refresh_decrypt_ctx(); + + // ── Evaluate success ──────────────────────────────────────────────── + // is_preshare_ctx_ready() reads preshareCtx under g_ctx_mutex (main.c). + // A non-null context means Apple accepted the new lease and FairPlay + // key derivation succeeded — requests can resume. + if (is_preshare_ctx_ready()) { + consec_fails = 0; + g_recovery_state.store(RecoveryState::Running); + fprintf(stderr, + "[recovery] STATE=Running — REFRESH_SUCCESS, " + "decrypt context ready, HTTP servers resuming normal operation\n"); + } else { + consec_fails++; + g_recovery_state.store(RecoveryState::Failed); + + int next_idx = (consec_fails <= kBackoffMaxIdx) ? consec_fails + : kBackoffMaxIdx; + int next_delay = kBackoffSecs[next_idx]; + fprintf(stderr, + "[recovery] STATE=Failed — REFRESH_FAILED " + "ctx_ready=false consecutive_fails=%d retrying_in=%ds\n", + consec_fails, next_delay); + + // Self-schedule a retry so the daemon keeps trying even when + // Apple sends no further lease-end event (e.g. transient network + // failure during the refresh). The retry goes through the normal + // queue + backoff path, so exponential spacing is preserved. + schedule_recovery(kRetryInternal); + } + } +} + +// Called from main() in main.c, after leaseMgr and FHinstance are ready. +extern "C" void start_recovery_thread(void) +{ + std::thread(recovery_worker).detach(); + fprintf(stderr, "[+] recovery thread started\n"); +} + +// --------------------------------------------------------------------------- +// FairPlay decrypt exception shim +// --------------------------------------------------------------------------- + +extern "C" uint8_t handle_cpp(int fd) +{ try { handle(fd); return 1; @@ -14,15 +246,26 @@ extern "C" uint8_t handle_cpp(int fd) { } } -static void endLeaseCb(int const &c) { - fprintf(stderr, "[.] end lease code %d\n", c); - exit(1); +// --------------------------------------------------------------------------- +// SVPlaybackLeaseManager callbacks +// +// CONTRACT: return as fast as possible. Do NOT call any library function, +// perform I/O, or acquire any lock that the lease manager might also hold. +// --------------------------------------------------------------------------- + +static void endLeaseCb(int const &c) +{ + fprintf(stderr, "[.] LEASE_END code=%d — scheduling async recovery\n", c); + schedule_recovery(c); + // Returns immediately. recovery_worker handles reacquisition. } -static void pbErrCb(void *) { - fprintf(stderr, "[.] playback error\n"); - exit(1); +static void pbErrCb(void *) +{ + fprintf(stderr, "[.] PLAYBACK_ERROR — scheduling async recovery\n"); + schedule_recovery(kPlaybackErrSentinel); + // Returns immediately. recovery_worker handles context refresh. } extern "C" std::function endLeaseCallback(endLeaseCb); -extern "C" std::function pbErrCallback(pbErrCb); \ No newline at end of file +extern "C" std::function pbErrCallback(pbErrCb); \ No newline at end of file diff --git a/scripts/check-drift.py b/scripts/check-drift.py new file mode 100644 index 0000000..f8a21dd --- /dev/null +++ b/scripts/check-drift.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +""" +Two-phase drift guard for wrapper.c / wrapper-rootless.c. + +Phase 1 — Skeleton comparison + Strip each file's implementation-specific regions, normalize whitespace, + then diff the skeletons. Any remaining difference is unexpected drift. + +Phase 2 — Allow-list verification + Verify that every region actually stripped matches a named entry in the + allow-list, and that each entry was matched the expected number of times. + A new region added to either file without updating the allow-list fails here + even if Phase 1 would have passed after a new strip rule was also added. + +Usage: + python3 scripts/check-drift.py (run from repo root) + +Exit codes: + 0 both phases pass + 1 unexpected drift or allow-list violation +""" + +import os +import re +import subprocess +import sys +import tempfile + + +# --------------------------------------------------------------------------- +# Allow-list +# +# Each entry names a region that is permitted to differ between the two files. +# 'file' which file the region lives in +# 'type' single_line | end | end_brace (see strip_regions) +# 'start' regex that identifies the first line of the region +# 'end' regex for the closing line (type "end" only) +# 'expect' how many times this region must appear (default 1) +# --------------------------------------------------------------------------- + +ALLOW_LIST = [ + # ── wrapper.c privileged-only regions ──────────────────────────────── + { + "name": "stdlib.h include", + "file": "wrapper.c", + "type": "single_line", + "start": re.compile(r"#include\s+"), + }, + { + "name": "CAP_SYS_ADMIN defines", + "file": "wrapper.c", + "type": "end", + "start": re.compile(r"#define CAP_SYS_ADMIN_IDX"), + "end": re.compile(r"#define CAP_SYS_ADMIN_BIT"), + }, + { + "name": "has_cap_sys_admin function", + "file": "wrapper.c", + "type": "end_brace", + "start": re.compile(r"^int has_cap_sys_admin\(\)"), + }, + { + "name": "conditional CLONE_NEWPID unshare", + "file": "wrapper.c", + "type": "end_brace", + "start": re.compile(r"if \(has_cap_sys_admin\(\)\)"), + }, + + # ── wrapper-rootless.c rootless-only regions ────────────────────────── + { + "name": "write_file comment", + "file": "wrapper-rootless.c", + "type": "single_line", + "start": re.compile(r"/\* rootless-only: write"), + }, + { + "name": "write_file function", + "file": "wrapper-rootless.c", + "type": "end_brace", + "start": re.compile(r"^static int write_file\("), + }, + { + "name": "setup_user_namespace comment", + "file": "wrapper-rootless.c", + "type": "single_line", + "start": re.compile(r"/\* rootless-only: create"), + }, + { + "name": "setup_user_namespace function", + "file": "wrapper-rootless.c", + "type": "end_brace", + "start": re.compile(r"^static int setup_user_namespace\(\)"), + }, + { + "name": "setup_user_namespace call-site comment", + "file": "wrapper-rootless.c", + "type": "single_line", + "start": re.compile(r"/\* rootless-only: establish"), + }, + { + "name": "setup_user_namespace call", + "file": "wrapper-rootless.c", + "type": "end_brace", + "start": re.compile(r"if \(setup_user_namespace\(\)"), + }, + { + "name": "unconditional CLONE_NEWPID comment", + "file": "wrapper-rootless.c", + "type": "single_line", + "start": re.compile(r"/\* rootless-only: user namespace"), + }, + { + "name": "unconditional CLONE_NEWPID unshare", + "file": "wrapper-rootless.c", + "type": "end_brace", + "start": re.compile(r"if \(unshare\(CLONE_NEWPID\)"), + }, +] + + +# --------------------------------------------------------------------------- +# Stripping +# --------------------------------------------------------------------------- + +def strip_end_brace(lines, start_idx): + """Return the index after the closing brace that matches the opening + brace on lines[start_idx].""" + depth, seen_open = 0, False + i = start_idx + while i < len(lines): + depth += lines[i].count("{") - lines[i].count("}") + i += 1 + if depth > 0: + seen_open = True + if seen_open and depth <= 0: + break + return i + + +def strip_regions(lines, rules): + """ + Remove every region matched by rules. + Returns (stripped_lines, hits) where hits is a dict mapping rule index + to the number of times that rule was matched. + """ + hits = {i: 0 for i in range(len(rules))} + result = [] + i = 0 + while i < len(lines): + line = lines[i] + matched_idx = next( + (idx for idx, r in enumerate(rules) if r["start"].search(line)), + None, + ) + + if matched_idx is None: + result.append(line) + i += 1 + continue + + hits[matched_idx] += 1 + rule = rules[matched_idx] + + if rule["type"] == "single_line": + i += 1 + continue + + if rule["type"] == "end_brace": + i = strip_end_brace(lines, i) + continue + + # type == "end": inclusive range + end_re = rule["end"] + i += 1 + while i < len(lines): + matched = end_re.search(lines[i]) + i += 1 + if matched: + break + + return result, hits + + +def normalize(lines): + out = [] + for line in lines: + s = line.rstrip() + if not out and "canonical implementation" in s: + continue + if s == "" and out and out[-1] == "": + continue + out.append(s) + return out + + +def skeleton(path, rules): + with open(path) as f: + raw = [l.rstrip("\n") for l in f] + stripped, hits = strip_regions(raw, rules) + return normalize(stripped), hits + + +# --------------------------------------------------------------------------- +# Phase 1 — skeleton comparison +# --------------------------------------------------------------------------- + +def phase1(canon_skel, rootless_skel): + with tempfile.NamedTemporaryFile("w", suffix=".canon", delete=False) as a: + a.write("\n".join(canon_skel) + "\n"); a_path = a.name + with tempfile.NamedTemporaryFile("w", suffix=".rootless", delete=False) as b: + b.write("\n".join(rootless_skel) + "\n"); b_path = b.name + try: + result = subprocess.run( + ["diff", "-u", + "--label", "wrapper.c (skeleton)", + "--label", "wrapper-rootless.c (skeleton)", + a_path, b_path], + capture_output=True, text=True, + ) + finally: + os.unlink(a_path) + os.unlink(b_path) + return result + + +# --------------------------------------------------------------------------- +# Phase 2 — allow-list verification +# --------------------------------------------------------------------------- + +def phase2(canon_hits, rootless_hits, canon_rules, rootless_rules): + failures = [] + + def check(hits, rules, label): + for idx, rule in enumerate(rules): + expected = rule.get("expect", 1) + actual = hits[idx] + if actual != expected: + failures.append( + f" [{label}] '{rule['name']}': " + f"expected {expected} match(es), got {actual}" + ) + + check(canon_hits, canon_rules, "wrapper.c") + check(rootless_hits, rootless_rules, "wrapper-rootless.c") + return failures + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main(): + canon_rules = [r for r in ALLOW_LIST if r["file"] == "wrapper.c"] + rootless_rules = [r for r in ALLOW_LIST if r["file"] == "wrapper-rootless.c"] + + canon_skel, canon_hits = skeleton("wrapper.c", canon_rules) + rootless_skel, rootless_hits = skeleton("wrapper-rootless.c", rootless_rules) + + p1 = phase1(canon_skel, rootless_skel) + p2 = phase2(canon_hits, rootless_hits, canon_rules, rootless_rules) + + ok = True + + if p1.returncode != 0: + ok = False + print("PHASE 1 FAIL: unexpected skeleton drift.") + print(" Add a strip rule to ALLOW_LIST for any new approved difference.") + print() + print(p1.stdout) + + if p2: + ok = False + print("PHASE 2 FAIL: allow-list region count mismatch.") + print(" Update ALLOW_LIST if a region was intentionally added or removed.") + print() + for line in p2: + print(line) + print() + + if ok: + canon_count = sum(canon_hits.values()) + rootless_count = sum(rootless_hits.values()) + print( + f"OK: {canon_count} privileged-only region(s) in wrapper.c, " + f"{rootless_count} rootless-only region(s) in wrapper-rootless.c. " + f"No unexpected drift." + ) + return 0 + + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/wrapper-rootless.c b/wrapper-rootless.c index fa3564d..cee82b4 100644 --- a/wrapper-rootless.c +++ b/wrapper-rootless.c @@ -1,16 +1,17 @@ +/* canonical implementation: wrapper.c — this file may only differ where rootless operation requires it */ #define _GNU_SOURCE + #include +#include #include #include -#include #include #include #include #include -#include #include #include -#include +#include #include "cmdline.h" @@ -23,6 +24,7 @@ static void intHan(int signum) { } } +/* rootless-only: write a single line to a /proc file */ static int write_file(const char *path, const char *line) { int fd = open(path, O_WRONLY); if (fd < 0) return -1; @@ -32,13 +34,23 @@ static int write_file(const char *path, const char *line) { return (ret == len) ? 0 : -1; } -static int setup_unprivileged_namespaces() { +/* rootless-only: create user+mount namespace and map current uid/gid to root */ +static int setup_user_namespace() { uid_t uid = getuid(); gid_t gid = getgid(); char buf[128]; - if (unshare(CLONE_NEWUSER | CLONE_NEWNS | CLONE_NEWPID) == -1) { - perror("unshare"); + if (unshare(CLONE_NEWUSER | CLONE_NEWNS) == -1) { + if (errno == EPERM) { + fprintf(stderr, + "error: unprivileged user namespaces are not permitted on this system.\n" + " options:\n" + " 1. enable: sudo sysctl -w kernel.unprivileged_userns_clone=1\n" + " 2. use the privileged wrapper instead\n" + " 3. grant capabilities: sudo setcap cap_sys_admin+ep ./wrapper-rootless\n"); + } else { + perror("unshare"); + } return -1; } @@ -69,21 +81,11 @@ int main(int argc, char *argv[], char *envp[]) { return 1; } - if (setup_unprivileged_namespaces() != 0) { - return 1; - } - - child_proc = fork(); - if (child_proc == -1) { - perror("fork"); + /* rootless-only: establish user namespace before any privileged operations */ + if (setup_user_namespace() != 0) { return 1; } - if (child_proc > 0) { - wait(NULL); - return 0; - } - if (mkdir("./rootfs/dev", 0755) != 0 && errno != EEXIST) { perror("mkdir ./rootfs/dev failed"); return 1; @@ -101,41 +103,57 @@ int main(int argc, char *argv[], char *envp[]) { return 1; } - if (mkdir("./rootfs/proc", 0755) != 0 && errno != EEXIST) { - perror("mkdir ./rootfs/proc failed"); + if (chdir("./rootfs") != 0) { + perror("chdir"); + return 1; + } + if (chroot("./") != 0) { + perror("chroot"); return 1; } - if (mount("proc", "./rootfs/proc", "proc", 0, NULL) != 0) { - perror("mount proc failed"); + if (mkdir("/proc", 0755) != 0 && errno != EEXIST) { + perror("mkdir /proc failed"); return 1; } - // 5. 切换目录并 chroot - if (chdir("./rootfs") != 0) { - perror("chdir ./rootfs failed"); + chmod("/system/bin/linker64", 0755); + chmod("/system/bin/main", 0755); + + /* rootless-only: user namespace guarantees CLONE_NEWPID is always available */ + if (unshare(CLONE_NEWPID)) { + perror("unshare"); return 1; } - if (chroot(".") != 0) { - perror("chroot . failed"); + + child_proc = fork(); + if (child_proc == -1) { + perror("fork"); return 1; } - chmod("/system/bin/linker64", 0755); - chmod("/system/bin/main", 0755); + if (child_proc > 0) { + wait(NULL); + return 0; + } + + if (mount("proc", "/proc", "proc", 0, NULL) != 0) { + perror("mount proc failed"); + return 1; + } if (mkdir(args_info.base_dir_arg, 0777) != 0 && errno != EEXIST) { perror("mkdir base_dir_arg failed"); - } - - char db_path[512]; - snprintf(db_path, sizeof(db_path), "%s/mpl_db", args_info.base_dir_arg); - if (mkdir(db_path, 0777) != 0 && errno != EEXIST) { + } + + char db_dir[1024]; + snprintf(db_dir, sizeof(db_dir), "%s/mpl_db", args_info.base_dir_arg); + if (mkdir(db_dir, 0777) != 0 && errno != EEXIST) { perror("mkdir mpl_db failed"); } execve("/system/bin/main", argv, envp); - + perror("execve"); return 1; -} \ No newline at end of file +} diff --git a/wrapper.c b/wrapper.c index 4e57f4f..7604bdd 100644 --- a/wrapper.c +++ b/wrapper.c @@ -1,3 +1,4 @@ +/* canonical implementation — any change here must be reviewed against wrapper-rootless.c */ #define _GNU_SOURCE #include @@ -129,7 +130,7 @@ int main(int argc, char *argv[], char *envp[]) { if (mkdir(args_info.base_dir_arg, 0777) != 0 && errno != EEXIST) { perror("mkdir base_dir_arg failed"); } - + char db_dir[1024]; snprintf(db_dir, sizeof(db_dir), "%s/mpl_db", args_info.base_dir_arg); if (mkdir(db_dir, 0777) != 0 && errno != EEXIST) { @@ -137,7 +138,7 @@ int main(int argc, char *argv[], char *envp[]) { } execve("/system/bin/main", argv, envp); - + perror("execve"); return 1; -} \ No newline at end of file +}