I fired up the Oqee app the other day to watch some TV. Naturally, as soon as the app loaded, my brain immediately went to: “What are the developers hiding from us in production?” I wanted to see if I could activate any dev-only features or debug menus to mess with the player and bypass some restrictions.

I decrypted the app on my jailbroken iPhone using ipadecrypt, pulled the .ipa, and dumped the main binary into IDA.

Reconnaissance and string hunting

I always start by looking for useful strings. I searched the strings window for keywords like “dev”, “debug”, or “test”. Almost immediately, I got a hit on a cluster of interesting strings:

  • devPlayOnlyFirstAdBreakAd
  • displayPlayerIdleNotificationFaster
  • enforcesCanalplusChannelMobilityRights
  • displayTestChannels
  • devDisplayPlayerDebugView
  • displayLiveTimeshiftModal

Following the cross-references, I found several key functions touching these strings. The first one, sub_10053E830, basically acts as a hashing or lookup helper for these keys.

Swift::Int __fastcall sub_10053E830(unsigned __int8 a1)
{
  const char *v2; // x8
  char *v3; // x9
  unsigned __int64 v4; // x11
  const char *v5; // x12
  unsigned __int64 v6; // x13
  char *v7; // x12
  unsigned __int64 v8; // x1
  unsigned __int64 v9; // x19
  _QWORD v11[9]; // [xsp+8h] [xbp-58h] BYREF

  Hasher.init(_seed:)(v11, 0);
  v2 = "devDisplayPlayerDebugView";
  v3 = "sChannelMobilityRights";
  v4 = 0xD000000000000023LL;
  v5 = "displayTestChannels";
  v6 = 0xD000000000000026LL;
  if ( a1 != 4 )
  {
    v6 = 0xD000000000000013LL;
    v5 = "interval";
  }
  if ( a1 != 3 )
  {
    v4 = v6;
    v3 = (char *)v5;
  }
  v7 = "displayLiveTimeshiftModal";
  if ( a1 != 1 )
    v7 = "eNotificationFaster";
  if ( a1 )
    v2 = v7;
  if ( a1 <= 2u )
    v8 = 0xD000000000000019LL;
  else
    v8 = v4;
  if ( a1 <= 2u )
    v9 = (unsigned __int64)v2;
  else
    v9 = (unsigned __int64)v3;
  String.hash(into:)(v11, v8, v9 | 0x8000000000000000LL);
  swift_bridgeObjectRelease(v9 | 0x8000000000000000LL);
  return Hasher._finalize()();
}

The most interesting function was sub_1005DE750. This function is responsible for wiping dev settings from NSUserDefaults when developer mode is toggled off.

void __fastcall sub_1005DE750(char isDevModeEnabled)
{
  Swift::String key_str;
  Swift::String full_key_str;
  id notification_center;
  id user_defaults;
  void *notification_name;
  
  sub_1004E2BF0(&isDevModeEnabled, 0x4565646F4D766564LL, 0xEE0064656C62616ELL);
  sub_100007C24(&isDevModeEnabled, &unk_100ED28A0, &unk_100A94390);

  notification_center = objc_retainAutoreleasedReturnValue(
                    objc_msgSend(objc_opt_self(&OBJC_CLASS___NSNotificationCenter), 
                                 "defaultCenter"));
  
  swift_once(&qword_100EE4E40, sub_1005DE108);
  notification_name = qword_100F01380;
  objc_msgSend(notification_center, "postNotificationName:object:", notification_name, self);
  objc_release(notification_center);

  if ( (isDevModeEnabled & 1) == 0 )
  {
    user_defaults = objc_retainAutoreleasedReturnValue(
                   objc_msgSend(objc_opt_self(&OBJC_CLASS___NSUserDefaults), 
                                "standardUserDefaults"));

    // This loop iterates through a known list of developer settings and removes them.
    for ( int i = 0; i < 16; i++ )
    {
      const char *key_suffix;
      
      // A jump table (based on byte from unk_100EE4E58) determines which key to process.
      switch ( jump_table_lookup(i) )
      {
        case 0: key_suffix = "devSimulatedErrorCode"; break;
        case 1: key_suffix = "devForcedStartupMessages"; break;
        case 2: key_suffix = "devMinimumBackgroundTimeToReloadApp"; break;
        case 3: key_suffix = "enforcedCanalplusError"; break;
        case 4: key_suffix = "enforcedCanalplusParentalRatingRestrict"; break;
        case 5: key_suffix = "devAnalyticsDebugActive"; break;
        case 6: key_suffix = "devAnalyticsDebugScheduled"; break;
        case 7: key_suffix = "devLicenseProxyPort"; break;
        case 8: key_suffix = "devLicenseProxyDomain"; break;
        case 9: key_suffix = "devLicenseUrlBasePath"; break;
        // Additional cases construct keys from register constants:
        // e.g. "devApiBasePath", "devProxy", "forcedFeature" etc.
        default:
           key_suffix = get_key_from_register_constants();
           break;
      }

      // Construct the full key string: ".tv.oqee." + key_suffix
      full_key_str = ".tv.oqee.";
      String__append(&full_key_str, key_suffix);

      id ns_key = String__bridgeToObjectiveC(&full_key_str);

      objc_msgSend(user_defaults, "setURL:forKey:", 0, ns_key);

      // Cleanup
      swift_bridgeObjectRelease(&full_key_str);
      objc_release(ns_key);
    }

    for ( int j = 0; j < 7; j++ )
    {
      const char *key_suffix;
      
      // Lookup key based on unk_100EE4E90
      switch ( j )
      {
        case 0: key_suffix = "devPlayOnlyFirstAdBreakAd"; break;
        case 1: key_suffix = "displayPlayerIdleNotificationFaster"; break;
        case 2: key_suffix = "enforcesCanalplusChannelMobilityRights"; break;
        case 3: key_suffix = "displayTestChannels"; break;
        case 4: key_suffix = "devDisplayPlayerDebugView"; break;
        case 5: key_suffix = "displayLiveTimeshiftModal"; break;
        // ... (Logic handles mapping based on table values)
      }

      full_key_str = ".tv.oqee.";
      String__append(&full_key_str, key_suffix);
      id ns_key = String__bridgeToObjectiveC(&full_key_str);
      
      objc_msgSend(user_defaults, "setURL:forKey:", 0, ns_key);
      
      swift_bridgeObjectRelease(&full_key_str);
      objc_release(ns_key);
    }
    
    objc_release(user_defaults);
  }
}

Decoding the state flag

At the very top of sub_1005DE750, I noticed a call to a helper function sub_1004E2BF0 that takes our boolean isDevModeEnabled alongside two hardcoded hex values: 0x4565646F4D766564LL and 0xEE0064656C62616ELL.

IDA failed to parse these as ASCII because they are short Swift strings packed into integers (a common Swift optimization for strings under 15 characters). We can write a quick Python script to decode them as little-endian bytes:

s1 = "0x4565646F4D766564LL"
s2 = "0xEE0064656C62616ELL"

def decode_string(s):
    value = int(s[:-2], 16)
    b = value.to_bytes((value.bit_length() + 7) // 8, "little")
    return b.split(b"\x00")[0].decode("ascii", errors="ignore")

print(decode_string(s1) + decode_string(s2))
# Output: devModeEnabled

If we look at sub_1004E2BF0, we see another set of encoded hex values (0x2E6565716F2E7674LL and 0xE800000000000000LL) being appended. Decoding those yields tv.oqee..

Click to expand sub_1004E2BF0
void __fastcall sub_1004E2BF0(__int64 a1, __int64 a2, void *a3)
{
  void *v6; // x19
  __int64 v7; // x20
  __int64 v8; // x0
  __int64 v9; // x26
  char *v10; // x24
  __int64 v11; // x23
  Swift::String v12; // x0
  unsigned __int64 v13; // x20
  NSString v14; // x21
  __int64 v15; // [xsp+0h] [xbp-60h] BYREF
  unsigned __int64 v16; // [xsp+8h] [xbp-58h]
  __int64 v17; // [xsp+18h] [xbp-48h]

  v6 = objc_retainAutoreleasedReturnValue(objc_msgSend((id)objc_opt_self(&OBJC_CLASS___NSUserDefaults), "standardUserDefaults"));
  sub_100488800(a1, &v15);
  v7 = v17;
  if ( v17 )
  {
    v8 = sub_100007AF8(&v15, v17);
    v9 = *(_QWORD *)(v7 - 8);
    v10 = (char *)&v15 - ((*(_QWORD *)(v9 + 64) + 15LL) & 0xFFFFFFFFFFFFFFF0LL);
    (*(void (__fastcall **)(char *, __int64, __int64))(v9 + 16))(v10, v8, v7);
    v11 = _bridgeAnythingToObjectiveC<A>(_:)(v10, v7);
    (*(void (__fastcall **)(char *, __int64))(v9 + 8))(v10, v7);
    _s5OQKit16OQSelectionEntryVwxx_0(&v15);
  }
  else
  {
    v11 = 0;
  }
  v15 = 0x2E6565716F2E7674LL;
  v16 = 0xE800000000000000LL;
  v12._countAndFlagsBits = a2;
  v12._object = a3;
  String.append(_:)(v12);
  v13 = v16;
  v14 = String._bridgeToObjectiveC()();
  swift_bridgeObjectRelease(v13);
  objc_msgSend(v6, "setValue:forKey:", v11, v14);
  objc_release(v6);
  swift_unknownObjectRelease(v11);
  objc_release(v14);
}
print(decode_string("0x2E6565716F2E7674LL") + decode_string("0xE800000000000000LL"))
# Output: tv.oqee.

The app concatenates these to form the NSUserDefaults key tv.oqee.devModeEnabled. I later verified this by finding sub_1005DEC70, which explicitly reads this exact key on launch to set the app’s internal state.

void (__fastcall *__fastcall sub_1005DEC70(__int64 a1))(__int64 a1)
{
  __int64 v1; // x20
  void *v3; // x21
  Swift::String v4; // x0
  NSString v5; // x22
  unsigned __int8 v6; // w20

  *(_QWORD *)a1 = v1;
  v3 = objc_retainAutoreleasedReturnValue(objc_msgSend((id)objc_opt_self(&OBJC_CLASS___NSUserDefaults), "standardUserDefaults"));
  v4._countAndFlagsBits = 0x4565646F4D766564LL; // "devModeEnabled"
  v4._object = (void *)0xEE0064656C62616ELL;
  String.append(_:)(v4);
  v5 = String._bridgeToObjectiveC()();
  swift_bridgeObjectRelease(0xE800000000000000LL);
  v6 = (unsigned __int8)objc_msgSend(v3, "boolForKey:", v5, 0x2E6565716F2E7674LL); // "tv.oqee."
  objc_release(v5);
  objc_release(v3);
  *(_BYTE *)(a1 + 8) = v6;
  return sub_1005DED44;
}

The quick bypass

Now that we know the app relies on a simple UserDefaults key, bypassing this is trivial. If you have a jailbroken device or a Mac terminal over SSH, you can literally just inject it:

% defaults write tv.oqee devModeEnabled -bool true

Or, if you want it injected persistently on launch, a quick Theos tweak gets the job done:

#import <Foundation/Foundation.h>

%ctor {
    NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
    [defaults setBool:YES forKey:@"tv.oqee.devModeEnabled"];
}

Booting up the app with this key forced to true gives us a beautiful new “Développement” section in the app settings.

Oqee Dev Settings Button Oqee Dev Settings Menu

Inside, we have complete access to the features we saw referenced in the dump:

NSUserDefaults KeyDescription
tv.oqee.devDisplayPlayerDebugViewDebugOverlay in player
tv.oqee.displayLiveTimeshiftModalLive timeshift UI modal
tv.oqee.displayPlayerIdleNotificationFasterIdle notification fires faster
tv.oqee.enforcesCanalplusChannelMobilityRightsEnforces Canal+ mobility rights
tv.oqee.displayTestChannelsShows hidden test channels
tv.oqee.devPlayOnlyFirstAdBreakAd only plays first ad break

Uncovering the Konami code

While the UserDefaults hack works, I wasn’t entirely satisfied. The developers clearly didn’t connect their devices to a Mac every time they wanted to debug the UI in production. There had to be an in-app trigger.

A bit more digging in IDA revealed two glorious strings: OQKonamiCode and KonamiKey.

I found a class handling a gesture recognizer natively linked to this Konami code object:

void __cdecl -[OQKonamiCode handleGesture:](_TtC4Oqee12OQKonamiCode *self, SEL a2, id a3)
{
  id v4; // x19

  v4 = objc_retain(a3);
  swift_retain(self);
  sub_1002D55EC(v4);
  objc_release(v4);
  swift_release(self);
}
Click to expand sub_1002D5240
void __fastcall sub_1002D5240(__int64 a1)
{
  __int64 v1; // x20
  __int64 v2; // x19
  _QWORD *v3; // x0
  __int64 v4; // x20
  __int64 (__fastcall *v5)(__int64); // x21
  __int64 v6; // x0
  __int64 v7; // x21
  __int64 v8; // x1
  __int64 v9; // x22
  __int64 ObjectType; // x0
  __int64 Strong; // x0
  void *v12; // x22
  __int64 v13; // x0
  __int64 inited; // x23
  __int64 v15; // x25
  __int64 v16; // x20
  __int64 v17; // x20
  __int64 v18; // x20
  _QWORD *v19; // x0
  __int64 v20; // x28
  char *v21; // x26
  __int64 v22; // x25
  void *v23; // x20
  _QWORD v25[3]; // [xsp+8h] [xbp-C8h] BYREF
  __int64 v26; // [xsp+20h] [xbp-B0h]
  _QWORD v27[3]; // [xsp+28h] [xbp-A8h] BYREF
  __int64 v28; // [xsp+40h] [xbp-90h]
  _BYTE v29[56]; // [xsp+48h] [xbp-88h] BYREF

  v2 = v1;
  v3 = (_QWORD *)sub_10047B5DC(a1);
  v4 = *v3;
  v5 = *(__int64 (__fastcall **)(__int64))(*(_QWORD *)*v3 + 576LL);
  v6 = swift_retain(*v3);
  v7 = v5(v6);
  v9 = v8;
  swift_release(v4);
  if ( v7 )
  {
    ObjectType = swift_getObjectType(v7);
    if ( ((*(__int64 (__fastcall **)(__int64, __int64))(v9 + 8))(ObjectType, v9) & 1) != 0
      || (Strong = swift_unknownObjectWeakLoadStrong(v2 + 16)) == 0 )
    {
      swift_unknownObjectRelease(v7);
    }
    else
    {
      v12 = (void *)Strong;
      v13 = sub_10001A720();
      inited = swift_initStackObject(v13, v29);
      *(_OWORD *)(inited + 16) = xmmword_100A6B980;
      v15 = type metadata accessor for OQKonamiCode();
      v28 = v15;
      v27[0] = v2;
      swift_retain(v2);
      v16 = sub_1002CCDAC(v27, 1, "handleGesture:");
      sub_10014C384(v27);
      *(_QWORD *)(inited + 32) = v16;
      v28 = v15;
      v27[0] = v2;
      swift_retain(v2);
      v17 = sub_1002CCDAC(v27, 2, "handleGesture:");
      sub_10014C384(v27);
      *(_QWORD *)(inited + 40) = v17;
      v28 = v15;
      v27[0] = v2;
      sub_10004372C(v27, v25);
      v18 = v26;
      if ( v26 )
      {
        v19 = sub_100007AF8(v25, v26);
        v20 = *(_QWORD *)(v18 - 8);
        v21 = (char *)&v25[-1] - ((*(_QWORD *)(v20 + 64) + 15LL) & 0xFFFFFFFFFFFFFFF0LL);
        (*(void (__fastcall **)(char *, _QWORD *, __int64))(v20 + 16))(v21, v19, v18);
        swift_retain(v2);
        v22 = _bridgeAnythingToObjectiveC<A>(_:)(v21, v18);
        (*(void (__fastcall **)(char *, __int64))(v20 + 8))(v21, v18);
        _s5OQKit16OQSelectionEntryVwxx_0(v25);
      }
      else
      {
        swift_retain(v2);
        v22 = 0;
      }
      v23 = objc_msgSend(
              objc_allocWithZone((Class)&OBJC_CLASS___UILongPressGestureRecognizer),
              "initWithTarget:action:",
              v22,
              "handleGesture:");
      swift_unknownObjectRelease(v22);
      objc_msgSend(v12, "addGestureRecognizer:", v23);
      sub_10014C384(v27);
      *(_QWORD *)(inited + 48) = v23;
      swift_beginAccess(v2 + 40, v27, 33, 0);
      sub_100016874(inited);
      swift_endAccess(v27);
      swift_unknownObjectRelease(v7);
      objc_release(v12);
    }
  }
}

The core parsing happens in sub_1002D55EC. It looks at the incoming UIGestureRecognizer and translates it into an integer code using a bitshift lookup table: 0x3040401000404 >> (8 * index).

Click to expand the raw C++ gesture handler
__int64 __fastcall sub_1002D55EC(void *a1)
{
  __int64 v2; // x0
  __int64 v3; // x0
  void *v4; // x21
  id v5; // x19
  void *v6; // x22
  __int64 v7; // x0
  __int64 v8; // x21
  __int64 result; // x0
  id v10; // x0
  void *v11; // x22
  void *v12; // x21
  __int64 v13; // x0
  __int64 v14; // x0
  void *v15; // x0
  void *v16; // x0
  unsigned int v17; // w8
  __int64 v18; // x0
  __int64 v19; // x0

  v2 = objc_opt_self(&OBJC_CLASS___UITapGestureRecognizer);
  v3 = swift_dynamicCastObjCClass(a1, v2);
  if ( !v3 )
  {
    v14 = objc_opt_self(&OBJC_CLASS___UISwipeGestureRecognizer);
    v15 = (void *)swift_dynamicCastObjCClass(a1, v14);
    if ( v15 )
    {
      v16 = objc_msgSend(v15, "direction");
      if ( v16 == (void *)1 )
        v17 = 1;
      else
        v17 = 4;
      if ( v16 == (void *)2 )
        v13 = 0;
      else
        v13 = v17;
    }
    else
    {
      v18 = objc_opt_self(&OBJC_CLASS___UILongPressGestureRecognizer);
      if ( swift_dynamicCastObjCClass(a1, v18) )
        v13 = 2;
      else
        v13 = 4;
    }
    return sub_1002D57F0(v13);
  }
  v4 = (void *)v3;
  v5 = objc_retain(a1);
  v6 = objc_retainAutoreleasedReturnValue(objc_msgSend(v4, "allowedPressTypes"));
  v7 = sub_1002D5900(0);
  v8 = static Array._unconditionallyBridgeFromObjectiveC(_:)(v6, v7);
  objc_release(v6);
  if ( !((unsigned __int64)v8 >> 62) )
  {
    result = *(_QWORD *)((v8 & 0xFFFFFFFFFFFFFF8LL) + 0x10);
    if ( result )
      goto LABEL_4;
LABEL_24:
    objc_release(v5);
    swift_bridgeObjectRelease(v8);
    v13 = 4;
    return sub_1002D57F0(v13);
  }
  if ( v8 <= -1 )
    v19 = v8;
  else
    v19 = v8 & 0xFFFFFFFFFFFFFF8LL;
  result = _CocoaArrayWrapper.endIndex.getter(v19);
  if ( !result )
    goto LABEL_24;
LABEL_4:
  if ( (v8 & 0xC000000000000001LL) != 0 )
  {
    v10 = (id)sub_1001553EC(0, v8);
LABEL_7:
    v11 = v10;
    swift_bridgeObjectRelease(v8);
    v12 = objc_msgSend(v11, "integerValue");
    objc_release(v5);
    objc_release(v11);
    if ( (unsigned __int64)v12 >= 7 )
      v13 = 4;
    else
      v13 = (unsigned int)(0x3040401000404uLL >> (8 * (unsigned __int8)v12));
    return sub_1002D57F0(v13);
  }
  if ( *(_QWORD *)((v8 & 0xFFFFFFFFFFFFFF8LL) + 0x10) )
  {
    v10 = objc_retain(*(id *)(v8 + 32));
    goto LABEL_7;
  }
  __break(1u);
  return result;
}

To save you the headache of reading ARM assembly translated into C++, here is what it looks like rewritten cleanly in Swift:

func handleGesture(_ g: UIGestureRecognizer) {
    if let tap = g as? UITapGestureRecognizer {
        guard let type = tap.allowedPressTypes.first?.intValue else {
            return trigger(4)
        }
        switch type {
            case 2:  trigger(0)
            case 3:  trigger(1)
            case 6:  trigger(3)
            default: trigger(4)
        }
    } else if let swipe = g as? UISwipeGestureRecognizer {
        switch swipe.direction {
            case .left:  trigger(0)
            case .right: trigger(1)
            default:     trigger(4)
        }
    } else if g is UILongPressGestureRecognizer {
        trigger(2)
    } else {
        trigger(4)
    }
}

So our gesture map looks like this:

  • 0 = Swipe Left
  • 1 = Swipe Right
  • 2 = Long Press
  • 4 = Invalid

But what is the correct sequence? I traced the object initialization logic back to sub_10025F098. The app grabs the target array right from the binary data section at unk_100EB31E8 using swift_initStaticObject. Let’s take a look at the memory dump in IDA at that address:

__data:0000000100EB31E8 unk_100EB31E8|DCB    0... (padding/isa)__data:0000000100EB31F8|DCB    5   <- Count = 5...__data:0000000100EB3200|DCB  0xA   <- Capacity = 10...__data:0000000100EB3208|DCB    1   <- Element [0]__data:0000000100EB3209|DCB    1   <- Element [1]__data:0000000100EB320A|DCB    0   <- Element [2]__data:0000000100EB320B|DCB    0   <- Element [3]__data:0000000100EB320C|DCB    2   <- Element [4]

Swift arrays backed by static memory layout their buffer plainly: the first 16 bytes are the class isa pointer and refcounts (set dynamically at runtime). At offset +16 we have the count (0x05), at +24 we have the capacity (0x0A), and right at +32 the elements begin.

Reading those contiguous bytes gives us 1, 1, 0, 0, 2.

Applying our gesture map from earlier, the official Oqee Konami Code is: Right, Right, Left, Left, Long Press.

Performing this precise gesture sequence anywhere in the app advances the internal konamiIndex counter. Upon hitting 5 correct inputs, the OQSettingDevModeEnablingLinkCellPresenter is instantly triggered.

Check it out in action:

Triggering the dev mode natively via the Konami Code
The developer parameters available in dev mode

And that’s it! We bypassed the restrictions dynamically, analyzed the gesture parser, and revealed the actual developer trigger left in the production build. Maybe I’ll dig more into the app now that I have dev mode access :)