On Fridays I enjoy eating pizza and watching TV, replays on TF1, but my programs are always interrupted by ads. There is a paid subscription (5.99€/month or 59.99€/year) to remove them, but I didn’t want to pay for that.. subscription.png It is worth noting the fine print:

*Sans pub : À l’exception des chaînes TV en direct et de certains programmes Translation: *Without ads: Except for live TV channels and some programs

So even paying subscribers still see ads on live channels and some content. They also offer a per-program ad-free purchase at 0.99€ (or 0.67€ depending on the show), but it’s only valid for 48 hours! buy.png note.png On the web version, blocking ads with a browser extension is straightforward. On the Apple TV app, it’s a different story. I didn’t want to set up a network-level adblocker like Pi-hole just for a single app. So I started reverse engineering the TF1+ app.

Setup

First I jailbroke my Apple TV 4 (see this article for details), then dumped the decrypted IPA with TrollDecryptor.

% unzip TF1+_11.36.0_decrypted.ipa # ipa files are just zip files
% tree Payload
Payload/
└── mytf1.app
    ├── mytf1                    # Main native binary (Mach-O)
    ├── main.jsbundle            # React Native app logic 
    ├── Info.plist
    ├── GoogleService-Info.plist # Firebase config
    ├── Frameworks/
    │   ├── AdManager.framework/
    │   │   └── AdManager        # FreeWheel ad SDK
    │   ├── SmartLib_tvOS.framework/
    │   │   ├── SmartLib_tvOS
    │   │   └── smartlib.js      # JS bridge for player / DRM
    │   ├── SmartLibAVPlayer_tvOS.framework/
    │   │   └── SmartLibAVPlayer_tvOS   # Video playback core
    │   ├── MuxCore.framework/
    │   │   └── MuxCore          # Streaming analytics (Mux)
    │   ├── OpenTelemetryApi.framework/
    │   │   └── OpenTelemetryApi # Observability / tracing
    │   ├── hermes.framework/
    │   │   └── hermes           # JS engine (Hermes)
    │   └── Didomi.framework/
    │       ├── Didomi           # Consent / GDPR management
    │       ├── dcs-encoder.js
    │       ├── gpp.js
    │       └── privacy-signals.js
    ├── PlugIns/
    │   └── mytf1-TopShelf.appex/
    │       └── mytf1-TopShelf   # tvOS Top Shelf extension
    ├── Player_PlayerNetworking.bundle/
    │   └── defaultConfig.json   # Bundled player config
    └── SC_Info/
        └── Manifest.plist

The app is built with React Native for UI and navigation (main.jsbundle running on the Hermes engine), Swift/Objective-C native modules for the video player, and FreeWheel AdManager (AdManager.framework) as the exclusive video ad engine.

Searching for FreeWheel online, I noticed that uBlock Origin already has a filter for it: freewheel.png

From FreeWheel’s own documentation:

FreeWheel provides libraries called AdManager to make it easy to integrate your player or mobile application with FreeWheel MRM ad server. AdManager can send ad request, parse ad response, and use an extensive rendering framework to render ads, including rich-media formats like VPAID or MRAID. Source: FreeWheel AdManager documentation

Exploring the JS bundle

Before touching the native binary, I looked at main.jsbundle. Searching for ad-related strings, I found an advertisingParams dictionary assembled at playback time:

advertisingParams: Object.assign({}, consentAdParams, {
  synchro: getSynchroPlayerParam()   // "1" if multi-profile co-watching, else "0"
})

Where consentAdParams is:

async function fetchConsentAdParams() {
  const consentParam   = await getPlayerConfigFromConsent(); // reads Didomi CMP
  const trackingStatus = await TrackingTransparencyModule.getTrackingStatus(); // iOS ATT

  return {
    gdprConsent:   consentParam.gdprConsentString || undefined, // TCF v2 consent string
    attAccepted:   trackingStatus === 'authorized' ? '1' : '0', // ATT opt-in flag
    synchro:       '0',                                         // default, overwritten above
    advertisingId: useAdvertisingId()                           // IDFA
  };
}

The advertisingId is fetched via the native AdvertisingIdModule, which calls Apple’s ASIdentifierManager.

Use the AdSupport framework to obtain an advertising identifier. The advertisingIdentifier is an alphanumeric string that’s unique to each device, and which you only use for advertising. On devices running iOS 14.5 and later and iPadOS 14.5 and later, your app must support App Tracking Transparency and define the purpose string NSUserTrackingUsageDescription before it can get the advertisingIdentifier property. Source : Apple Developer Documentation

If App Tracking Transparency is not authorized, it returns the all-zeros UUID and is silently dropped.

This dict crosses the React Native bridge to the native PlayerReactNativeView as an NSDictionary. The JS layer has no knowledge of the FreeWheel server URL, network ID, or any ad targeting parameters, it only contributes consent and identity signals..

I also found this static config in the bundle, confirming that both preroll and midroll ads are enabled in production:

var playerConfig = {
  player: {
    env: { mode: 'prod' },
    advertFeature: {
      enabled: true,
      preroll: true,
      midroll: true
    }
  }
};

Interestingly, the JS layer also contains a second, entirely separate ad system for display and banner ads (pause ads, cover banners), which hits a different endpoint:

// Apple TV platform config
var adContentUrl = 'https://adproxy.tf1.fr/ctv-apple-tv-tf1/display';

This REST endpoint is called from JS (authenticated with a Bearer token) and is completely independent of FreeWheel. The FreeWheel hook I’ll describe below doesn’t affect these display ads.

Native binary: the ad architecture

I loaded mytf1 into IDA and mapped out the ad system architecture. It is organized as a layered plugin system:

PlayerCore (Swift)
  └── AdvertPluginFactory       (protocol)
        └── FreewheelPluginFactory
              └── FreewheelPluginClass
                    └── FreewheelService  ←  talks directly to AdManager.framework

FreewheelPluginClass is the bridge between the player and the FreeWheel SDK. Every time a new video starts, FreewheelPluginClass.update(advertConfig:mediaInfo:) (sub_1000B17A4) is called with a fully populated AdvertConfig struct and triggers the creation of a new FreewheelService.

Where does the ad server URL come from?

The FreewheelConfig struct is not hardcoded in the binary and not in the JS bundle. It is fetched at app launch from a remote “hot config” endpoint:

void __fastcall sub_1000B17A4(__int64 a1)
{
  __int64 self; 
  char *v_config; 
  __int128 v_media[11];
  __int64 v_service; 
  __int64 v_obj;
  __int64 v_ctx;
  __int64 v_factory;

  self = a1;

  // Copy AdvertConfig from self to local stack
  v_config = (char *)&v_config - ((*(AdvertConfig_meta + 64) + 15) & 0xFFFFFFFFFFFFFFF0);
  memcpy(v_config, (const void *)(self + AdvertConfig_meta[9]), 0x28);
  swift_bridgeObjectRetain(*(__int64 *)(v_config + 32));

  memcpy(v_media, (const void *)(self + MediaInfo_meta[36]), 0xA0);

  if ( sub_1000B3528(v_media) != 1 )
  {
    if ( (v_media[1] & 1) || !v_media[2] )
    {
      swift_beginAccess(self + 24, &v_config, 0, 0);
      v_obj = swift_unknownObjectWeakLoadStrong(self + 24);
      if ( v_obj )
      {
        __int64 v_data = sub_1000B354C(v_media, &v_config);
        __int64 v_arr = sub_100041798(v_data);
        if ( !v_arr )
          v_arr = &_swiftEmptyArrayStorage;
        
        (*(void (**)(void *, __int64, __int64))(*(__int64 *)(self + 32) + 80))(
          v_arr, 
          swift_getObjectType(v_obj), 
          *(__int64 *)(self + 32));
        
        swift_bridgeObjectRelease(v_arr);
        swift_unknownObjectRelease(v_obj);
      }
    }
    else
    {
      // Path B: Initialize FreewheelService
      sub_10001F8B4(a1, v_config, AdvertConfig_meta);
      
      v_factory = swift_allocObject(FWRequestConfigurationFactory_meta, 16, 7);
      sub_10001F8B4(v_config, v_config, AdvertConfig_meta);
      
      // Create and Init Service
      __int64 v_alloc = objc_allocWithZone(FreewheelService_meta);
      __int64 v_req = sub_10002FD8C(&v_factory, FWRequestConfigurationFactory_meta);
      __int64 v_new = sub_1000B2D98(v_media, v_req, v_config, v_alloc);
      
      sub_100009580(v_config, AdvertConfig_meta);
      _s13PlayerUISnoop13SnoopPlaylistV17SnoopPlaylistItemVwxx_0(&v_factory);
      
      v_service = *(__int64 *)(self + 16);
      *(__int64 *)(self + 16) = v_new;
      objc_retain(v_new);
      objc_release(v_service);

      if ( v_new )
      {
        // Set Delegate
        *(__int64 *)(v_new + 0x28) = &off_1010F9D30;
        swift_unknownObjectWeakAssign();
        
        // Configure AdContext
        v_ctx = *(__int64 *)(v_new + 0x20);
        objc_msgSend(v_ctx, "setVideoDisplayBase:", *(__int64 *)(self + AdvertConfig_meta[8]));
        
        v_ctx = *(__int64 *)(v_new + 0x20);
        objc_msgSend(v_ctx, "setAdVolume:", *(float *)(self + AdvertConfig_meta[7]));
      }
    }
  }
}

The next step was to find where the FreewheelConfig is created and populated and see if I could tamper with it.

The JSON response is validated and adapted in JS, then handed to the native layer. The freewheelConfig key inside it is decoded into a Swift FreewheelConfig struct whose fields map to these CodingKeys:

enum FreewheelConfig.CodingKeys : Int8 {
    case adManagerSwf   = 0  // (legacy SWF path, unused on tvOS)
    case networkID      = 1  // FreeWheel network identifier
    case videoAssetID   = 2  // FW video asset identifier
    case playerProfile  = 3  // FW player profile string
    case jingleOutPath  = 4  // (audio jingle, unused on tvOS)
    case serverURL      = 5  // ← FreeWheel ad server endpoint
    case channel        = 6  // Targeting: channel name
    case page           = 7  // Targeting: page/surface
    case siteSectionID  = 8  // FW site section identifier
    case duration       = 9  // Content duration hint
    case sfid           = 10 // Site/format ID
}

Once the FreewheelConfig is available, sub_1000B2D98 initialises the FreeWheel SDK session:

char *__fastcall sub_1000B2D98(__int64 a1, __int64 a2, __int64 a3, _BYTE *a4)
{
  objc_class *ObjectType;
  _QWORD v69[3]; // Factory args
  _OWORD v70[2]; // String buffer
  id v_adManager;
  id v_context;
  id v_requestConfig;
  id v_val;
  objc_super v68;
  __int64 v_src;
  int *v_meta;

  ObjectType = (objc_class *)swift_getObjectType(a4);

  // Initialize Instance Variables
  swift_unknownObjectWeakInit(&a4[OBJC_IVAR____TtC15FreewheelPlugin16FreewheelService_delegate], 0);
  a4[OBJC_IVAR____TtC15FreewheelPlugin16FreewheelService_isPlayingAd] = 0;
  *(_QWORD *)&a4[OBJC_IVAR____TtC15FreewheelPlugin16FreewheelService_cuePoints] = &_swiftEmptyArrayStorage;
  *(_QWORD *)&a4[OBJC_IVAR____TtC15FreewheelPlugin16FreewheelService_currentSlot] = 0;
  memset(&a4[OBJC_IVAR____TtC15FreewheelPlugin16FreewheelService_advertInfo], 0, 0x30);
  *(_QWORD *)&a4[OBJC_IVAR____TtC15FreewheelPlugin16FreewheelService_impressSpot] = 0;
  *(_QWORD *)&a4[OBJC_IVAR____TtC15FreewheelPlugin16FreewheelService_avQueuePlayer] = 0;
  a4[OBJC_IVAR____TtC15FreewheelPlugin16FreewheelService_isPause] = 2;
  *(_QWORD *)&a4[OBJC_IVAR____TtC15FreewheelPlugin16FreewheelService_progressTimer] = 0;
  *(_QWORD *)&a4[OBJC_IVAR____TtC15FreewheelPlugin16FreewheelService_promise] = 0;

  v69[3] = type metadata accessor for FWRequestConfigurationFactory(0);
  v69[4] = &off_1010F9EE0;
  v69[0] = a2;

  if ( (*(_BYTE *)(a1 + 24) & 1) || !*((_QWORD *)(a1 + 48) + 1) )
    goto CLEANUP_ERROR;

  // Copy config data
  v_meta = type metadata accessor for AdvertConfig(0);
  
  // Copy duration
  *(_QWORD *)&a4[OBJC_IVAR____TtC15FreewheelPlugin16FreewheelService_duration] = 
    *(_BYTE *)(a3 + 144) ? -1 : *(_QWORD *)(a3 + 136);

  // Copy TestAdPause
  a4[OBJC_IVAR____TtC15FreewheelPlugin16FreewheelService_testAdPause] = *(_BYTE *)(a3 + v_meta[5]);

  // Copy AVQueuePlayer
  *(_QWORD *)&a4[OBJC_IVAR____TtC15FreewheelPlugin16FreewheelService_avQueuePlayer] = *(void **)(a3 + v_meta[10]);

  // Copy AdvertRemote struct
  v_src = a3 + v_meta[9];
  memcpy(&a4[OBJC_IVAR____TtC15FreewheelPlugin16FreewheelService_advertRemote], (void *)v_src, 40);
  swift_bridgeObjectRetain(*(_QWORD *)(v_src + 32));

  sub_10001F970(v69, &a4[OBJC_IVAR____TtC15FreewheelPlugin16FreewheelService_adRequestConfigFactory]);
  sub_100008AE0(a1 + 48, v70, &unk_101288600);

  // Create AdManager
  v_adManager = (id)newAdManager(objc_retain(*(id *)&a4[OBJC_IVAR____TtC15FreewheelPlugin16FreewheelService_avQueuePlayer]));
  if ( !v_adManager )
    goto CLEANUP_ERROR;

  *(_QWORD *)&a4[OBJC_IVAR____TtC15FreewheelPlugin16FreewheelService_adManager] = v_adManager;
  objc_msgSend(v_adManager, "setNetworkId:", *(_QWORD *)(a1 + 16));

  // Create Context and set parameters
  v_context = (id)objc_msgSend((id)swift_unknownObjectRetain(v_adManager), "newContext");
  if ( !v_context )
    goto CLEANUP_ERROR;

  *(_QWORD *)&a4[OBJC_IVAR____TtC15FreewheelPlugin16FreewheelService_adContext] = v_context;
  
  // Set parameters helper
  #define SET_PARAM(ctx, param, val) \
    v_val = objc_retain(param); \
    objc_msgSend(ctx, "setParameter:withValue:forLevel:", v_val, val, 1); \
    objc_release(v_val)

  SET_PARAM(v_context, FWParameterVideoAdRendererTimeout, Double._bridgeToObjectiveC()());
  SET_PARAM(v_context, FWParameterVPAIDRendererCreativeTimeoutDelay, String._bridgeToObjectiveC()());
  SET_PARAM(v_context, FWParameterOpenClickThroughInApp, String._bridgeToObjectiveC()());
  SET_PARAM(v_context, FWParameterEnablePauseAd, String._bridgeToObjectiveC()());
  SET_PARAM(v_context, FWParameterVideoAdRendererShowBufferIndicator, String._bridgeToObjectiveC()());

  // Create RequestConfiguration
  v_requestConfig = objc_msgSend(
    objc_allocWithZone(&OBJC_CLASS___FWRequestConfiguration), 
    "initWithServerURL:playerProfile:", 
    String._bridgeToObjectiveC()(), 
    String._bridgeToObjectiveC()());

  *(_QWORD *)&a4[OBJC_IVAR____TtC15FreewheelPlugin16FreewheelService_adRequestConfig] = v_requestConfig;
  v_requestConfig[3] = sub_1000B3C24(0);
  v_requestConfig[4] = &off_1010F9DE8;

  v68.receiver = a4;
  v68.super_class = ObjectType;
  a4 = (char *)objc_msgSendSuper2(&v68, "init");

  sub_1000B4380(a1, sub_1000B9DC8(a3), &a4[OBJC_IVAR____TtC15FreewheelPlugin16FreewheelService_adRequestConfig]);
  sub_1000B3B68(a1);
  sub_1000B4728(*(_QWORD *)&a4[OBJC_IVAR____TtC15FreewheelPlugin16FreewheelService_adContext]);

  objc_msgSend((id)swift_unknownObjectRetain(*(_QWORD *)&a4[OBJC_IVAR____TtC15FreewheelPlugin16FreewheelService_adContext]), "setVideoAdDelegate:", a4);

  _s13PlayerUISnoop13SnoopPlaylistV17SnoopPlaylistItemVwxx_0(v69);
  sub_100009580(a3, v_meta);
  return a4;

CLEANUP_ERROR:
  _s13PlayerUISnoop13SnoopPlaylistV17SnoopPlaylistItemVwxx_0(v69);
  sub_100009580(a3, v_meta);
  swift_deallocPartialClassInstance(a4, ObjectType, 312, 7);
  return 0;
}

This is the central function. It:

  • Calls newAdManager() -> FWAdManager.setNetworkId(networkID)
  • Calls adManager.newContext() -> FWContext, then sets 5 SDK parameters on it (VideoAdRendererTimeout, VPAIDRendererCreativeTimeoutDelay, OpenClickThroughInApp, EnablePauseAd, VideoAdRendererShowBufferIndicator)
  • Bridges serverURL and playerProfile from Swift Strings to NSString via String._bridgeToObjectiveC()
  • Calls -[FWRequestConfiguration initWithServerURL:playerProfile:] at 0x1000b327c, this is the moment the ad session is configured
  • Stores the result as adRequestConfig on the FreewheelService instance
  • Populates slot configurations (FWSiteSectionConfiguration, FWVideoAssetConfiguration, FWTemporalSlotConfiguration for preroll)

So now we know that the serverURL is used to initialize the FWRequestConfiguration instance using [FWRequestConfiguration initWithServerURL:playerProfile:]. This function is implemented in the AdManager framework:

FWRequestConfiguration *__cdecl -[FWRequestConfiguration initWithServerURL:playerProfile:](
        FWRequestConfiguration *self,
        SEL a2,
        id a3,
        id a4)
{
  return -[FWRequestConfiguration initWithServerURL:playerProfile:playerDimensions:](
           self,
           "initWithServerURL:playerProfile:playerDimensions:",
           a3,
           a4,
           -1.0,
           -1.0);
}
FWRequestConfiguration *__cdecl -[FWRequestConfiguration initWithServerURL:playerProfile:playerDimensions:](
        FWRequestConfiguration *self,
        SEL a2,
        id a3,
        id a4,
        CGSize a5)
{
  double height; // d8
  double width; // d9
  id v9; // x19
  id v10; // x21
  FWRequestConfiguration *v11; // x20
  id v12; // x22

  height = a5.height;
  width = a5.width;
  v9 = objc_retain(a3);
  v10 = objc_retain(a4);
  v11 = -[FWRequestConfiguration init](self, "init");
  if ( v11 )
  {
    v12 = objc_retainAutoreleasedReturnValue(+[FWUtil normalizeServerUrlString:](&OBJC_CLASS___FWUtil, "normalizeServerUrlString:", v9));
    -[FWRequestConfiguration setServerURL:](v11, "setServerURL:", v12);
    objc_release(v12);
    -[FWRequestConfiguration setPlayerProfile:](v11, "setPlayerProfile:", v10);
    -[FWRequestConfiguration setPlayerDimensions:](v11, "setPlayerDimensions:", width, height);
  }
  objc_release(v10);
  objc_release(v9);
  return v11;
}

Inside AdManager.framework, initWithServerURL:playerProfile: delegates to the longer form and calls [FWUtil normalizeServerUrlString:] on the URL before storing it.

Hooking with Frida

With the target identified, I used Frida (running on the jailbroken Apple TV) specifically Interceptor.replace and Memory.scanSync to hook the method at runtime.

First I confirmed I could read the URL:

function readNsStr(p) {
    if (!p || p.isNull()) return "(null)";
    let res = p.toString();
    try { res += "\n" + hexdump(p, { length: 128 }); }
    catch (e) { res += " (hexdump err: " + e.message + ")"; }
    return res;
}

const res = new ApiResolver('objc');
res.enumerateMatches('-[FWRequestConfiguration initWithServerURL:playerProfile:]')
   .forEach(m => {
       console.log("[*] Hooking", m.name);
       Interceptor.attach(m.address, {
           onEnter(args) {
               console.log('serverURL     =', readNsStr(args[2]));
               console.log('playerProfile =', readNsStr(args[3]));
           }
       });
   });

I used readNsStr helper function to read the parameters and print it as a hexdump since I had issues reading those structs directly as strings.

% frida -U -n mytf1 -1 hook.js
     ____
    / _  |   Frida 17.9.1 - A world-class dynamic instrumentation toolkit
   | (_| |
    > _  |   Commands:
   /_/ |_|       help      -> Displays the help system
   . . . .       object?   -> Display information about 'object'
   . . . .       exit/quit -> Exit
   . . . .
   . . . .   More info at https://frida.re/docs/home/
   . . . .
   . . . .   Connected to iOS Device (id=9045f3838ad2f180a3b91f073f05e01328057bf
Attaching...
[*] Hooking -[FWRequestConfiguration initWithServerURL:playerProfile:] 
[iOS Device ::mytf1 ]-> |

Launching a video produced:

[FW] RequestConfiguration
  serverURL = 0x3027febe0

  3027febe0  d0 35 ab 09 02 00 00 00  03 00 00 00 12 00 00 00  .5..............
  3027febf0  40 00 00 00 00 00 00 00  26 00 00 00 00 00 00 f0  @.......&.......
  3027fec00  68 74 74 70 73 3a 2f 2f  61 61 61 61 61 61 61 2e  https://aaaaaaa.
  3027fec10  74 66 31 2e 66 72 2f 61  70 70 6c 65 2d 74 76 2f  tf1.fr/apple-tv/
  3027fec20  61 64 2f 70 2f 31 00 00  00 00 00 00 00 00 00 00  ad/p/1..........

  playerProfile = 0x3018a39c0
  ...
  3018a39e0  35 30 36 33 33 34 3a 74  66 31 5f 61 70 70 6c 65  506334:tf1_apple
  3018a39f0  5f 63 74 76 5f 6c 69 76  65 00 00 00 00 00 00 00  _ctv_live.......

The ad server URL https://adproxy.tf1.fr/apple-tv/ad/p/1 and player profile 506334:tf1_apple_ctv_live are both in cleartext in the NSString backing buffer on the heap.

Approach 1: memory-patching the URL

The simplest approach is to overwrite the URL bytes in the NSString buffer before initWithServerURL: reads them. The NSString layout on iOS is a standard __NSCFString object: a small header followed by the UTF-8 payload at a fixed offset. Scanning 256 bytes forward from args[2] is enough to find it.

Interceptor.attach(match.address, {
    onEnter(args) {
        const oldPattern = "68 74 74 70 73 3a 2f 2f 61 64 70 72 6f 78 79 2e"; // https://adproxy.
        const newBytes   = [
            0x68,0x74,0x74,0x70,0x73,0x3a,0x2f,0x2f,  // https://
            0x61,0x61,0x61,0x61,0x61,0x61,0x61,0x2e   // aaaaaaa.
        ];

        const hits = Memory.scanSync(args[2], 256, oldPattern);
        for (const hit of hits) {
            Memory.protect(hit.address, newBytes.length, 'rw-');
            hit.address.writeByteArray(newBytes);
            console.log("[*] Patched serverURL to https://aaaaaaa.[...]");
        }
    }
});

After the patch:

            0  1  2  3  4  5  6  7   8  9  A  B  C  D  E  F  0123456789ABCDEF
3027febe0  d0 35 ab 09 02 00 00 00  03 00 00 00 12 00 00 00  .5..............
3027febf0  40 00 00 00 00 00 00 00  26 00 00 00 00 00 00 f0  @.......&.......
3027fec00  68 74 74 70 73 3a 2f 2f  61 61 61 61 61 61 61 2e  https://aaaaaaa.
3027fec10  74 66 31 2e 66 72 2f 61  70 70 6с 65 2d 74 76 2f  tf1.fr/apple-tv/ 
3027fec20  61 64 2f 70 2f 31 00 00  00 00 00 00 00 00 00 00  ad/p/1..........
3027fec30  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  ................
3027fec40  32 9b el e4 76 1b 43 40  8f 9d 24 3e 99 74 01 72  2...v.C@..$>.t.r 
3027fec50  b8 8e aa 09 02 00 00 00  a8 8d aa 09 02 00 00 00  ................ 

Only the first 16 bytes are overwritten, so the rest of the URL path (tf1.fr/apple-tv/ad/p/1) remains but that doesn’t matter, because the host part is unreachable. The FreeWheel SDK fires its ad request, the DNS lookup fails, the request times out, and onRequestComplete: fires with FWInfoKeyError. FreewheelService rejects its internal promise and the player continues without ads!

Patching the ad server URL in memory

Note: In the recording the video appears to freeze after the ad fails, that’s because I’m capturing the screen and the video is DRM-protected. On the Apple TV itself, the video plays normally without any freeze.

Approach 2: replacing the initialiser

A cleaner approach is to replace initWithServerURL:playerProfile: entirely with a stub that returns the uninitialized self. This way adRequestConfig on FreewheelService holds a blank, unconfigured FWRequestConfiguration object, and no ad request is ever dispatched, without needing to know or match any URL.

const res = new ApiResolver('objc');
const matches = res.enumerateMatches('-[FWRequestConfiguration initWithServerURL:playerProfile:]');

if (matches.length > 0) {
    console.log("[*] Replacing", matches[0].name);
    Interceptor.replace(matches[0].address, new NativeCallback(
        function (self, cmd, url, profile) {
            console.log('[FW] Blocked initWithServerURL:playerProfile:');
            return self; // return uninitialized self — no crash, no config
        },
        'pointer', ['pointer', 'pointer', 'pointer', 'pointer']
    ));
}

This works because FreewheelService is still fully constructed (so no crash), but adRequestConfig is never populated with a valid server URL, network ID, or slot configuration. When the player later calls into the FreeWheel context to submit the ad request, there is nothing to submit.

Blocking FWRequestConfiguration initialization

Making it permanent with a Theos tweak

Frida is great for exploration, but running a script manually every time is inconvenient. Theos lets you package the hook as a persistent tweak that injects automatically at app launch.

The tweak is remarkably simple. Logos translates %hook/%end into the necessary ObjC runtime swizzling at load time:

// Tweak.x
#import <Foundation/Foundation.h>

%hook FWRequestConfiguration
- (id)initWithServerURL:(id)url playerProfile:(id)profile {
    return self;
}
%end

After compiling with make package and installing the .deb on the Apple TV via make install, ads are blocked permanently without needing Frida. The source code for the tweak is available on GitHub

Final result — ads blocked via Theos tweak

Now I can enjoy my pizza and TF1 replays in peace, without any ads!