Introduction

Before start looking into the Salesforce Chat SDK binary, I want to provide a little context for why I decided to reverse engineer the framework.
I was integrating the chat in one of the projects I’ve been involved into. After adding Salesforce SDK to the Xcode project, I’ve tried to open the chat inside the application I was developing. Surprisingly, the chat from the SDK was not working properly - namely, it didn’t show localized strings, but rather their corresponding keys.

alt text

At first, I didn’t know why such a situation happens, I’ve tried several things to make sure that everything is done correctly, such as:

After that, I knew that something was wrong either with my main Xcode project setup or with the Salesforce Chat framework itself. Since I never give up that easy, I wanted to find out what is going on here 🤓. I’ve decided to use heavier artillery 💣.

Hopper to the rescue

Unfortunately, Salesforce Chat SDK is not open source, which means that we do not have access to the source code - we have only the framework’s binary. That’s why we can use Hopper disassembler to look into the binary itself, hoping we’ll find something interesting inside. Since we have two frameworks: SalesforceChat.framework & ServiceCore.framework, I’ll start analyzing the first one.
Hopper enables us to search for symbols / strings that are inside the binary. According to the image above, I’ve tried to search for a string Chat.Status.Failed and it worked! I’ve managed to find a place where this string is used and it is inside the SCSChatMinimizedContentFailure view’s method. Below you can see the steps I’ve made to find the view.

alt text

Hopper can produce pseudo-code from the CPU instructions found in the binary - this makes the code more readable to the user. We can have a quick look at the generated pseudo-code for the method where the searched string is used.

/* @class SCSChatMinimizedContentFailure */
-(void *)initWithFrame:(struct CGRect)arg2 {
    var_38 = self;
    *(&var_38 + 0x8) = *0x8f6c0;
    rax = [[&var_38 super] initWithFrame:rdx];
    rsp = (rsp - 0x20) + 0x20;
    r12 = rax;
    if (rax != 0x0) {
            r14 = [[NSString overridableLocalizedStringForKey:@"Chat.Status.Failed" table:@"ChatUI" bundleIdentifier:@"com.salesforce.ServiceSDK.Chat"] retain];
            rax = [r12 footerTextLabel];
            rax = [rax retain];
            [rax setText:r14];
            [rax release];
            [r14 release];
            rax = [r12 footerTextLabel];
            rax = [rax retain];
            [rax setAccessibilityIdentifier:@"ServiceCloud.Chat.Minimized.FailedLabel"];
            [rax release];
            rax = [r12 iconImageView];
            rax = [rax retain];
            [rax setAccessibilityIdentifier:@"ServiceCloud.Chat.Minimized.FailedImage"];
            [rax release];
    }
    rax = r12;
    return rax;
}

We can deduce that the method uses another method on NSString with a selector overridableLocalizedStringForKey:table:bundleIdentifier:. This is probably a method, which Salesforce uses for localization. Unfortunately, I can not find a code for this method in the binary. This is because it is implemented in the second framework - ServiceCore.framework. Before looking to the second binary let’s try to exchange the implementation of this method in runtime to see if it solves our issue. Yes, we’ll use method swizzling for that! 🔀

Swizzling private methods

Let’s try to exchange implementation of a method with a selector overridableLocalizedStringForKey:table:bundleIdentifier:. To do this we’ll first create a new implementation of the swizzled method.

extension NSString {
    @objc class func swizzledLocalizedString(forKey key: NSString,
                                             table: NSString,
                                             bundleIdentifier: NSString) -> NSString {

        let bundle = Bundle(identifier: bundleIdentifier as String)!
        let localizedString = bundle.localizedString(forKey: key as String,
                                                     value: nil,
                                                     table: table as String)

        return NSString(string: localizedString)
    }
}

The new method simply takes a key, finds a bundle with the provided identifier and localizes the string using a provided table that should be inside the bundle. We can then swizzle private selector using the method below.

private func swizzleLocalizedStringsSelectors() {
        let originalSelector = NSSelectorFromString("overridableLocalizedStringForKey:table:bundleIdentifier:")
        let swizzledSeelctor = #selector(NSString.swizzledLocalizedString(forKey:table:bundleIdentifier:))

        do {
            try NSString.swizzleClass(selector: originalSelector, newSelector: swizzledSeelctor)
        } catch {
            print(error)
        }
}

Turns out that after the invocation of this method, the strings are properly localized and everything works just fine! This proves that the method overridableLocalizedStringForKey:table:bundleIdentifier: has some logic inside that makes strings not being localized properly.
Despite having a working solution, I am not quite satisfied with it - we should try to avoid exchanging method implementations in runtime, as it may cause an external framework’s logic not to work properly. Let’s try if we can look inside the method’s pseudo-code using Hopper and check if we can understand the logic.

Understanding pseudo-code from the disassembler

We’ll look into the second binary inside ServiceCore.framework where the method we want to analyze is declared. Thanks to Hopper we can generate the method’s pseudo-code and see what logic is hidden inside.

/* @class NSString */
+(void *)overridableLocalizedStringForKey:(void *)arg2 table:(void *)arg3 bundleIdentifier:(void *)arg4 {
    var_38 = [arg2 retain];
    var_40 = [arg3 retain];
    r14 = [arg4 retain];
    r15 = *_objc_msgSend;
    var_48 = [[NSBundle mainBundle] retain];
    rbx = r15;
    r15 = [[NSBundle bundleWithIdentifier:r14] retain];
    [r14 release];

loc_15008c:
    var_30 = r15;
    goto loc_1500f0;

loc_1500f0:
    r14 = 0x0;
    goto loc_1500f3;

loc_1500f3:
    r13 = var_38;
    rax = (rbx)(@"ServiceCloud.", @selector(stringByAppendingString:), r13);
    rax = [rax retain];
    r15 = rax;
    var_50 = r14;
    rax = (rbx)(var_48, @selector(localizedStringForKey:value:table:), rax, 0x0, r14);
    rax = [rax retain];
    r14 = rax;
    if ((rbx)(rax, @selector(isEqualToString:), r15) != 0x0) {
            rdx = r13;
            r13 = var_30;
            r12 = var_40;
            rbx = [[r13 localizedStringForKey:rdx value:@"" table:r12] retain];
            [r14 release];
            r14 = rbx;
    }
    else {
            r12 = var_40;
            r13 = var_30;
    }
    [r15 release];
    [var_50 release];
    [r13 release];
    [var_48 release];
    [r12 release];
    [var_38 release];
    rax = [r14 autorelease];
    return rax;
}

After spending some time analyzing the pseudo-code, we can transform it to the ObjC code so we could better understand what’s going on inside. Turns out that the method has very simple logic. The SDK checks if we have a string declared in the main bundle (Salesforce enables us to override chat strings by declaring a string with a key containing “ServiceCloud.” prefix in the main bundle). If so it returns this string, if not it returns the string from the SDK’s bundle. Here is the ObjC code, which reflects the pseudo-code above.

- (NSString *)overridableLocalizedStringForKey:(NSString *)key
                                         table:(NSString *)table
                              bundleIdentifier:(NSString *)bundleIdentifier {
    NSBundle *mainBundle = [NSBundle mainBundle];
    NSBundle *bundle = [NSBundle bundleWithIdentifier:bundleIdentifier];
    NSString *serviceCloudKey = [@"ServiceCloud." stringByAppendingString:key];
    NSString *stringFromMainBundle = [mainBundle localizedStringForKey:serviceCloudKey
                                                                 value:nil
                                                                 table:nil];

    // check if stringFromMainBundle is the same as serviceCloudKey
    // this happens when the localized string can not be found in main bundle
    if ([stringFromMainBundle isEqualToString:serviceCloudKey]) {
        NSString *stringFromBundle = [bundle localizedStringForKey:key value:@"" table:table];
        return stringFromBundle;
    } else {
        return stringFromMainBundle;
    }
}

After a deep understanding of the logic it looks like, in our case, the method returns stringFromMainBundle, which does not exist. There should be something wrong with the string comparison. Let’s add a symbolic breakpoint on isEqualToString: method, to verify if strings are compared correctly.
Turns out that string comparison fails, because one of the strings is capitalized.

alt text

But why? After some investigation, it turns out that this is because of “Show non-localized strings” is enabled in Xcode’s scheme settings. There’s even a note about this in Apple’s documentation of localizedStringForKey method. Basically, if you set NSShowNonLocalizedStrings user default to YES (which happens if you enable “Show non-localized strings” in scheme settings), then when the method can’t find a localized string in the table, it logs a message to the console and capitalizes key before returning it.

Summary

Hurray! We were able to find the cause of the problem with string localizations. Salesforce SDK has not predicted the case when someone wants to use the “Show non-localized strings” debug option. The SDK should probably compare the lowercase values of the keys to get rid of the issue.
I hope you liked the article!

You can find a sample code used in this article here. I was using the Salesforce SDK version 224.0.