Ibiyemi Abiodun

« main page

Reverse Engineering TikTok's VM Obfuscation

Investigating the hidden functions of TikTok's web app

January 2023 to June 2023

javascripttypescriptreverse-engineering

One day, I stumbled across an interesting blog post penned by someone called Veritas, where they laid out the first steps towards reverse-engineering the code that is deployed on TikTok’s website. This post picks up where they left off.

I replicated Veritas’s work in decrypting the VMs using Babel scripts, and formatted the resulting file using Prettier. After running some simple Babel transforms on webmssdk.js from TikTok’s website, I was left with a file that looked like this:

!(function (_0x5c2c98, _0x2cd9f0) {})(this, function (_0x2a8ff7) {
  'use strict';
  function _0x14c284(_0x108694) {}
  function _0x14e389(_0xec17cf, _0x3ecc59) {}
  function _0x2d7234(_0x2ec4c3, _0x160011, _0x5f50ca, _0x2907ae) {}
  function _0x45536a(_0x445bf1, _0x53ea83) {}
  function _0x2ed536(_0x2cb834, _0x3fe249) {}
  // ... hundreds more of these
  Object.defineProperty(_0x2a8ff7, '__esModule', { value: true });
});

The most interesting bits contained within this file were the function calls that looked like this:

_0x4c03ae(
  // string abridged for brevity
  '484e4f4a403f524300252a08db14af68000004f...',
  {
    get 0x0() {
      return window;
    },
    // abridged
    set 0xd(_0x487dd5) {
      _0xc2c82d = _0x487dd5;
    },
  },
  undefined,
);

Each function call that looks like this is actually calling into a virtual machine, where the string is encoded bytecode for the VM to run. The virtual machines themselves are defined like this, with the string decoder described in Veritas’ post at the beginning.

function _0x4c03ae(_0x3892f1, _0x1f3ab1, _0x456cff) {
  // string decoder goes here

  return (function _0x7f5f97(_0x3178c9, _0x175acd, _0x5641a0, _0x40ea75, _0x22acae) {
    var _0xb14b3d,
      _0x532d20,
      _0x5db4f3,
      _0x1f012b,
      _0x4db293,
      _0x53c743 = -1,
      _0x4f176d = [],
      _0x15b1fb = [0, null],
      _0x49a4f9 = null,
      _0xc5d448 = [_0x175acd];
    for (
      _0x532d20 = Math.min(_0x175acd.length, _0x5641a0), _0x5db4f3 = 0;
      _0x5db4f3 < _0x532d20;
      ++_0x5db4f3
    )
      _0xc5d448.push(_0x175acd[_0x5db4f3]);
    _0xc5d448.p = _0x40ea75;
    for (var _0x28b3ac = []; ; ) {
      // we'll look at what's in here in a minute
    }
  })(_0x8eb8ab, [], 0, _0x1f3ab1, _0x456cff);
}

There are dozens of them across the file. I wrote a Babel script that located all of the instances of functions that looked like this, extracted them to separate files, and renamed the variables and parameters according to their order. This allowed me to see that the string decoder is the same for every VM. That decoder retrieves the key from the beginning of the string and uses it to extract a set of strings and a list of instructions.

Once this is done, the VM is launched with a few parameters:

At the beginning of each VM, some variables are set up, and then we find ourselves in this loop:

for (var _0x28b3ac = []; ; )
  try {
    var _0xd2004 = _0x146ed2[_0x3178c9++];
    if (_0xd2004 < 37) {
      if (_0xd2004 < 18) {
        if (_0xd2004 < 7) {
          if (_0xd2004 < 3) {
            _0x4f176d[++_0x53c743] = _0xd2004 === 0 || null;
          } else {
            if (_0xd2004 < 4) {
              _0xb14b3d = _0x146ed2[_0x3178c9++];
              _0x4f176d[++_0x53c743] = (_0xb14b3d << 24) >> 24;
            } else {
              if (_0xd2004 === 4) {
                _0xb14b3d = (_0x146ed2[_0x3178c9] << 8) + _0x146ed2[_0x3178c9 + 1];
                _0x3178c9 += 2;
                _0x4f176d[++_0x53c743] = (_0xb14b3d << 16) >> 16;
              } else {
                // omitted
              }
            }
          }
        } else {
          // you get the idea
        }
      } else {
        // more if-else statements
      }
    }
  } catch (_0x9843fc) {
    // excluded for brevity
  }

We can see that this is a while loop, and that our variable _0xd2004 is used as an instruction that controls what the VM does during this cycle. This suggests that _0x146ed2 (which is defined in the decoder), is our list of instructions, and that _0x3178c9 (the first parameter passed into the VM) is our instruction pointer.

So to figure out what each VM does, we need to figure out the meaning of each instruction. However, there were a couple of roadblocks here.

The first one was a formatting issue: the set of if-else statements which determines what to do with the instruction was highly nested to reduce the number of comparisons, but this also made it very hard to scan. I solved this with a Babel plugin that flattens nested if-else statements if they are testing the same variable.

The second one was missing information: the structure of each VM is slightly different. It seems like this is because most of the VMs don’t use every possible instruction, and the ones that are not used are optimized away.

For example, in a VM where instructions 0 and 1 are not used, but instructions 2 and 3 are used, the VM will only test if (instruction < 3) to determine what to do. This means that I could not guess the meaning of every instruction by looking at only one of the VMs, but there were too many of them for me to try to cross-reference all of them at the same time.

The solution was (you guessed it) another Babel script! This one does its best to fuse all of the VMs into one giant pseudo-VM. The motivating logic is this: if one VM tests if (instruction < 3) and another tests if (instruction < 2), then this means that the code for the former is likely only ever used if the instruction is exactly 2, and not if it is 0 or 1. So this script took the range of numbers covered by every if statement in every VM and intersected them, and then dumped the corresponding code out with a comment telling me which VM each piece of code came from.

if (instruction < 2) {
  //_0x1929c0
  {
    stack[++stackPtr] = true;
  }
  //_0x317505
  {
    stack[++stackPtr] = instruction === 0;
  }
  // it keeps going ...
} else if (instruction < 3) {
  //_0x1178dd
  {
    // omitted
  }
  //_0x1929c0
  {
    // omitted
  }
  //_0x252449
  {
    // omitted
  }
  // it keeps going ...
} else if (instruction < 4) {
} else if (instruction < 5) {
} else if (instruction < 6) {
  // it keeps going ...
} else if (instruction < 76) {
}

Using this, I was able to guess what each instruction does, and I was also able to infer the meanings of most of the parameters and local variables in the VM. Let’s take a closer look at those local variables:

var _0xb14b3d,
  _0x532d20,
  _0x5db4f3,
  _0x1f012b,
  _0x4db293,
  _0x53c743 = -1,
  _0x4f176d = [],
  _0x15b1fb = [0, null],
  _0x49a4f9 = null,
  _0xc5d448 = [_0x175acd];

Inside this VM, we see that a lot of instructions work like this:

if (_0xd2004 < 3) {
  _0x4f176d[++_0x53c743] = _0xd2004 === 0 || null;
}
if (_0xd2004 < 4) {
  _0xb14b3d = _0x146ed2[_0x3178c9++];
  _0x4f176d[++_0x53c743] = (_0xb14b3d << 24) >> 24;
}
if (_0xd2004 === 4) {
  _0xb14b3d = (_0x146ed2[_0x3178c9] << 8) + _0x146ed2[_0x3178c9 + 1];
  _0x3178c9 += 2;
  _0x4f176d[++_0x53c743] = (_0xb14b3d << 16) >> 16;
}

The usage of _0x4f176d smells like a stack to me, which would make _0x53c743 a stack pointer. The first several uninitialized variables are used as registers (I renamed them R0-R4; not every VM uses R4). The instructions do things like:

As an aside, the object passed into the VM is another interesting part of this setup.

{
  get 0x0() {
    return window;
  },
  get 0x1() {
    return URL;
  },
  get 0x2() {
    return XMLHttpRequest;
  },
  get 0x3() {
    return setTimeout;
  },
  get 0x4() {
    return document;
  },
  0x5: JSON,
  0x6: Array,
  0x7: Object,
  get 0x8() {
    return _0x4f2925;
  },
  get 0x9() {
    return _0x4a096d;
  },
  get 0xa() {
    return _0x553701;
  },
  get 0xb() {
    return _0x2c0b5c;
  },
  get 0xc() {
    return _0x1aafcc;
  },
  set 0xc(_0x2f09a3) {
    _0x1aafcc = _0x2f09a3;
  },
  get 0xd() {
    return _0xc2c82d;
  },
  set 0xd(_0x487dd5) {
    _0xc2c82d = _0x487dd5;
  },
},

It’s used to allow the VM to access external APIs and communicate with other VMs. For example, the VM would retrieve property 0 of this object if it wanted a reference to window. _0x4f2925 turns out to be a global variable that is controlled by a different VM. Using these accessors, the VMs become able to do anything that JS can do.

The last three variables declared at the beginning of the VM are used only in the more advanced instructions, and it’s still not entirely clear to me what they do yet. Because their purpose is unknown, I named them jack, mack1, and quack. I’ve published a list of all of the instructions I’ve identified here.

Armed with an approximate description for each instruction, I got to work making a disassembler. With a little bit of debugging to make sure that its behaviour matched the real VMs, I was able to dump the instructions in an ASM-like format:

 off  hex dec instr
0000 0x11  17 deref.get        0x01 (1)   0x01 (1)
0003 0x14  20 deref.set        0x00 (0)   0x03 (3)
0006 0x11  17 deref.get        0x02 (2)   0x00 (0)
0009 0x12  18 get.imm          0x0002 (2) "fromCharCode"
0012 0x4a  74 copy
0013 0x12  18 get.imm          0x0003 (3) "apply"
0016 0x02   2 push.null
0017 0x02   2 push.null
0018 0x11  17 deref.get        0x02 (2)   0x05 (5)
0021 0x11  17 deref.get        0x00 (0)   0x01 (1)
0024 0x43  67 call.s           0x01 (1)
0026 0x43  67 call.s           0x02 (2)
0028 0x14  20 deref.set        0x00 (0)   0x04 (4)
0031 0x11  17 deref.get        0x00 (0)   0x04 (4)
0034 0x12  18 get.imm          0x0004 (4) "length"
0037 0x11  17 deref.get        0x00 (0)   0x01 (1)
0040 0x12  18 get.imm          0x0004 (4) "length"
0043 0x27  39 popc.lt
0044 0x47  71 popcjump         0x000c (12)
0047 0x11  17 deref.get        0x01 (1)   0x02 (2)
0050 0x14  20 deref.set        0x00 (0)   0x03 (3)
0053 0x11  17 deref.get        0x00 (0)   0x04 (4)
0056 0x14  20 deref.set        0x00 (0)   0x01 (1)
0059 0x11  17 deref.get        0x02 (2)   0x00 (0)
0062 0x4a  74 copy
0063 0x12  18 get.imm          0x0002 (2) "fromCharCode"
0066 0x11  17 deref.get        0x00 (0)   0x02 (2)
0069 0x03   3 load.byte        0x06 (6)
0071 0x2b  43 pop.lshift
0072 0x11  17 deref.get        0x00 (0)   0x03 (3)
0075 0x2f  47 pop.or
0076 0x04   4 load.word        0x00ff (255)
0079 0x2e  46 pop.and
0080 0x43  67 call.s           0x01 (1)
0082 0x14  20 deref.set        0x00 (0)   0x05 (5)
0085 0x11  17 deref.get        0x02 (2)   0x00 (0)
0088 0x4a  74 copy
0089 0x12  18 get.imm          0x0002 (2) "fromCharCode"
0092 0x11  17 deref.get        0x02 (2)   0x01 (1)
0095 0x4a  74 copy
0096 0x12  18 get.imm          0x0005 (5) "floor"
0099 0x11  17 deref.get        0x02 (2)   0x01 (1)

Since these aren’t real assembly instructions, if I want to decompile this any further, I will need to write my own decompiler. Maybe I’ll write a part 3 after I do that.

If you’re interested in learning more about how I produced these results, email me at ibiyemi at intulon dot com.

1 I later renamed mack to context because it looks like this variable is used when the VM is calling into another VM.