Twitter header: part2

Intro

This is part 2 to a series where I'm showing the process of reverse engineering the X-Client-Transaction-Id header on Twitter. If you want to get caught up, you can read part 1 here but it's not required to understand part 2.

In this post, I'll be mainly doing reverse engineering. Figuring out what exactly goes into the header and showing how I went about verifying my assumptions and figuring out how Twitter may actually use this header themselves.

Manual Cleaning

When we left off in part 1, the script was still pretty dirty. There's artifacts from the dead code removal in there that I'm going to manually remove:

Fr = () => {
    const e = {};
    e["KPXsc"] = "div";
    const d = e;
    {
        const n = hr["createElement"]("div");
        return hr["body"]["append"](n), [n, () => Hr([n])];
    }
    var a, k, m, C;
}

In this case, we just want this function to be:

Fr = () => {
    const n = hr["createElement"]("div");
    return hr["body"]["append"](n), [n, () => Hr([n])];
}

Thankfully, this script is very small, this makes doing this manually very easy. Now the next thing I'm going to do is cleanup this stuff:

const [hr, Sr] = [document, window],
    [qr, Pr, vr, Rr, lr, Or, Qr, Gr, br, Jr, yr, pr, Ir] = [Sr["Number"], Sr["TextEncoder"], Sr["Uint8Array"], n => hr["querySelectorAll"](n), Sr["Date"], Sr["Uint32Array"], Sr["crypto"]["subtle"], Sr["Array"]["from"], Sr["Math"], Sr["RTCPeerConnection"], Sr["Promise"], Sr[uo(1011, 960, "jgCb", 1e3, 1030) + "ion"], Sr["getComputedStyle"]],
    [Mr, xr, Tr, Er, Zr] = [n => br["round"](n), n => br["floor"](n), () => br["random"](), n => n["slice"](0, 16), () => 0],
    [Xr, Ar, Ur] = [1, 1682924400, 2 ** (4 * 3)]

You may notice that in the second array of values, there's uo(1011, 960, "jgCb", 1e3, 1030). I'm unsure why our deobfuscation didn't handle this, however, it shouldn't matter since you may also notice that the variable Sr[uo(1011, 960, "jgCb", 1e3, 1030) + "ion"] belongs to is actually never referenced. Saved us a headache there.

Now the final thing I'm going to do is remove all the webpack "fluff", so it's smaller and easier to read, for the sake of keeping everyone on pace, I'll put the entire cleaned script here, however, I won't do that later as it would just make the blog harder to read. Here's the cleaned up code:

let zr;
const Kr = n => btoa(Array.from(n)["map"](n => String["fromCharCode"](n))["join"](""))["replace"](/=/g, ""),
  jr = () => {
    return n = gr(document.querySelectorAll("[name^=tw]")[0], "content"), new Uint8Array(atob(n)["split"]("")["map"](n => n["charCodeAt"](0)));
  },
  Lr = (n, W) => zr = zr || gr(Hr(document.querySelectorAll(n))[W[5] % 4]["childNodes"][0]["childNodes"][1], "d")["substring"](9)["split"]("C")["map"](n => n["replace"](/[^\d]+/g, " ")["trim"]()["split"](" ")["map"](Number)),
  gr = (n, W) => n && n["getAttribute"](W) || "",
  wr = n => typeof n == "string" ? new TextEncoder()["encode"](n) : n,
  Nr = n => crypto.subtle["digest"]("sha-256", wr(n)),
  Br = n => (n < 16 ? "0" : "") + n["toString"](16),
  Vr = (n, W) => Number["parseInt"](n, W),
  Hr = n => Array.from(n)["map"](n => {
    var W;
    return null != (W = n["parentElement"]) && W["removeChild"](n), n;
  }),
  Fr = () => {
    const n = document["createElement"]("div");
    return document["body"]["append"](n), [n, () => Hr([n])];
  },
  Yr = (n, W, t) => W ? n ^ t[0] : n,
  $r = (n, W, t) => {
      if (!n["animate"]) return;
      const r = n["animate"](no(W), 4096);
      r["pause"](), r["currentTime"] = Math.round(t / 10) * 10;
  },
  _r = (n, W, t, r) => {
    const o = n * (t - W) / 255 + W;
    return r ? Math.floor(o) : o["toFixed"](2);
  },
  no = n => ({
    color: ["#" + Br(n[0]) + Br(n[1]) + Br(n[2]), "#" + Br(n[3]) + Br(n[4]) + Br(n[5])],
    transform: ["rotate(0deg)", "rotate(" + _r(n[6], 60, 360, !0) + "deg)"],
    easing: "cubic-bezier(" + Array.from(n["slice"](7))["map"]((n, W) => _r(n, W % 2 ? -1 : 0, 1))["join"]() + ")"
  });
let Wo,
  to,
  ro = [];
const co = n => {
  if (!Wo) {
    const [W, L] = [n[2] % 16, n[12] % 16 * (n[14] % 16) * (n[7] % 16)],
    g = Lr(".r-32hy0", n);
    new Promise(() => {
      const t = new RTCPeerConnection(),
        o = Math.random()["toString"](36);
      to = t["createDataChannel"](o), t["createOffer"]()["then"](u => {
        try {
          const W = u["sdp"] || o;
          ro = Array.from(wr([W[n[5] % 8] || "4", W[n[8] % 8]])), t["close"]();
        } catch {}
      })["catch"](0);
    })["catch"](0);
    const [w, N] = Fr();
    $r(w, g[W], L);
    const B = getComputedStyle(w);
    Wo = Array.from(("" + B["color"] + B["transform"])["matchAll"](/([\d.-]+)/g))["map"](n => Number(Number(n[0])["toFixed"](2))["toString"](16))["join"]("")["replace"](/[.-]/g, ""), N();
  }
  return Wo;
};
return async (n, W) => {
  const r = Math.floor((Date["now"]() - 1682924400 * 1e3) / 1e3),
    o = new Uint8Array(new Uint32Array([r])["buffer"]),
    u = jr(),
    c = co(u);
  return Kr(new Uint8Array([Math.random() * 256]["concat"](Array.from(u), Array.from(o), (Array.from(new Uint8Array(await Nr([W, n, r]["join"]("!") + "bird" + c)))["concat"](ro)).slice(0, 16), [1]))["map"](Yr));
};

Reverse Engineering

Now that we have the script a lot easier to read, we can start to try and reverse engineer it! I'm going to start at the entry point, where we see return async (n, W) => {.

I'll be renaming n and W to be path and method respectively. This will make it easier to remember what's going on later. So now we can go line by line. The first line looks to be just making a timestamp, likewise, the second line seems to be making the timestamp into a uint8 array. Now I personally didn't know what this code would've done so I went ahead and tested it myself:


So yea, it looks like it's just making the int32 into an array of int8's, so far so good. I'll rename those 2 variables to time and timeBuffer respectively.

Next we have this function call:

jr = () => {
    n = gr(document.querySelectorAll("[name^=tw]")[0], "content")
    return new Uint8Array(atob(n)["split"]("")["map"](n => n["charCodeAt"](0)));
}
gr = (n, W) => n && n["getAttribute"](W) || ""

We're not immediately sure what this [name^=tw] is, however, it seems clear what this is doing generally. So we get the content of whatever that element is, we base64 decode it, then we get the character codes. The last thing to figure out is what that element actually is. It appears like it isn't removing the element after getting the content so we should be fine to just run our own getAttribute:


If we ctrl + f in the network tab for this string, we will see this: <meta name="twitter-site-verification" content="mentUHYU4+1yPz30fM6/IcNS+stghA1baFhBkGzE7075BPd15lUcDqC/RaF4jR+b"/>

The way I like to think of this is, it's basically the only thing that makes this "challenge" into a "dynamic challenge". I'll be renaming this function into getKey and the variable into KEY.

We can see this key is passed into the next function which happens to be where we're going, lets rename that parameter to KEY as well.

In this function you will see why I said that previous statement about the key, look at this:

 const [W, L] = [KEY[2] % 16, KEY[12] % 16 * (KEY[14] % 16) * (KEY[7] % 16)],
    g = Lr(".r-32hy0", KEY);
// ...
const W = u["sdp"] || o;
ro = Array.from(wr([W[KEY[5] % 8] || "4", W[KEY[8] % 8]]))
//...
$r(w, g[W], L);

So the key is being used to create values for functions to use and to get specific values. Anyways, lets skip over this [W, L] declaration and look into this function call Lr(".r-32hy0", KEY):

Lr = (n, W) => zr = zr || gr(Hr(document.querySelectorAll(n))[W[5] % 4]["childNodes"][0]["childNodes"][1], "d")["substring"](9)["split"]("C")["map"](n => n["replace"](/[^\d]+/g, " ")["trim"]()["split"](" ")["map"](Number))

Ah... that's pretty awful to read. Let's go ahead and rewrite this and see execute it ourselves to see what's going on here:

let zr;
function Lr(name, KEY) {
    if(zr) {
       return zr 
    }
    let nodes = getNodes(document.querySelectorAll(name))
    let node = nodes[KEY[5] % 4]["childNodes"][0]["childNodes"][1]
    let attribute = getAttribute(node, "d")
    let arr = attribute["substring"](9)["split"]("C")
    return arr["map"](n => n["replace"](/[^\d]+/g, " ")["trim"]()["split"](" ")["map"](Number))
}

function getNodes(n) {
    return Array.from(n).map(n => {
        var W;
        return null != (W = n.parentElement) && W.removeChild(n), n
    })
}

function getAttribute(e, att) {
    return e && e.getAttribute(att) || ""
}

For now, lets just try to see what exactly it is these nodes are and where they are in the HTML. Instead of doing nodes[KEY[5] % 4], I'll just be doing nodes[0] as anything between 0 and 3 should be valid.

So I'll run the function Lr(".r-32hy0") and we should see what's going on!

Well, that's weird. I'll be honest, it probably took me longer than it should have to find what was going on. If you notice, the getNodes function is doing W.removeChild(n), I'd imagine this is why we're unable to just run this function ourselves and see what's going on. Let's try this again, except this time, let's use chrome's block URL feature to block their script from running so we can see what's actually there.

I'm gonna be honest, while writing this post I was actually super confused because it still wasn't working, then I realized they just updated the name of the element. To see the new name, I'll just deobfuscate the new script. We will cover a more optimal way to do this in the future though. I just wanted to say this incase anyone following along hit this as well and got confused.

Now we have the new value and can see what we need:

This is great, if we look in the html (after refreshing the page since it has removed the elements) we can see exactly what it's grabbing:

Great, so this function basically just returns a specific one of these elements but formatted as a 2d array of numbers. In the previous screenshot we can see the function returned a 2d array, 16 in length where each array was 11 long. At this point, we already know that this value does change but it doesn't change every request, it just changes every time the script updates. We're not covering it right now but in the future we will go over that more.

I'll be renaming this Lr function to get2DArray and the global zr variable to _2DArray. We can also rename g = get2DArray(".r-32hy0", KEY) to arr.

THe next thing I'm going to do is skip this new Promise thing. Why? Well, In all honesty, I have no clue what RTCPeerConnection does exactly so I'd much rather skip past that and hope I don't have to do it. You can tell that maybe you won't have to because the "channel" is Math.random().toString(36). Who knows, we may or may not come back to it later.

Alright, lets go onto the Fr function. Now it does appear this function just creates a div for them to do something with then returns a function that will delete it. Let's go ahead and rename the returned values, we can make them newDiv and deleteDiv.

Now we're onto this final function call $r(newDiv, arr[W], L);:

$r = (n, W, t) => {
    if (!n["animate"]) return;
    const r = n["animate"](no(W), 4096);
    r["pause"](), 
    r["currentTime"] = Math.round(t / 10) * 10;
}
no = n => ({
    color: ["#" + Br(n[0]) + Br(n[1]) + Br(n[2]), "#" + Br(n[3]) + Br(n[4]) + Br(n[5])],
    transform: ["rotate(0deg)", "rotate(" + _r(n[6], 60, 360, !0) + "deg)"],
    easing: "cubic-bezier(" + Array.from(n["slice"](7))["map"]((n, W) => _r(n, W % 2 ? -1 : 0, 1))["join"]() + ")"
})
Br = n => (n < 16 ? "0" : "") + n["toString"](16)
_r = (n, W, t, r) => {
    const o = n * (t - W) / 255 + W;
    return r ? Math.floor(o) : o["toFixed"](2);
}

Quite a lot to go through here. Let's just start by renaming everything we can. I'll spoil the surprise and say Br is just converting a number to a hex string. I'll also just completely remove this no function, it is just returning an object so I don't see a reason to have it there. Now we're looking at this:

$r = (newDiv, numArr, KEY_NUMBER) => {
    if (!newDiv["animate"]) return;
    const r = newDiv["animate"]({
        color: ["#" + toHex(numArr[0]) + toHex(numArr[1]) + toHex(numArr[2]), "#" + toHex(numArr[3]) + toHex(numArr[4]) + toHex(numArr[5])],
        transform: ["rotate(0deg)", "rotate(" + _r(numArr[6], 60, 360, !0) + "deg)"],
        easing: "cubic-bezier(" + Array.from(numArr["slice"](7))["map"]((n, W) => _r(n, W % 2 ? -1 : 0, 1))["join"]() + ")"
    }, 4096);
    r["pause"]()
    r["currentTime"] = Math.round(KEY_NUMBER / 10) * 10;
}
toHex = n => (n < 16 ? "0" : "") + n["toString"](16)
_r = (n, W, t, r) => {
    const o = n * (t - W) / 255 + W;
    return r ? Math.floor(o) : o["toFixed"](2);
}

Alright, so we're still not 100% sure what's going on here but it's far more readable. Let's go and try to execute this ourselves to see what happens. We can use the 2d array we got before and just choose a random one of those arrays for the numArr input. For KEY_NUMBER, we should just be able to use any number from 0 to 4096, according to the docs on the animate function, the second input to the function is the length. Here's the code I made:

function toHex(n) { return (n < 16 ? "0" : "") + n["toString"](16) }
function _r(n, W, t, r){
    const o = n * (t - W) / 255 + W;
    return r ? Math.floor(o) : o["toFixed"](2);
}

let numArr = [67,224,150,100,101,56,240,84,140,73,126]
let newDiv = document["createElement"]("div")
document["body"]["append"](newDiv)
if (!newDiv["animate"]) {
    aaaaaaah
}
let r = newDiv["animate"]({
    color: ["#" + toHex(numArr[0]) + toHex(numArr[1]) + toHex(numArr[2]), "#" + toHex(numArr[3]) + toHex(numArr[4]) + toHex(numArr[5])],
    transform: ["rotate(0deg)", "rotate(" + _r(numArr[6], 60, 360, !0) + "deg)"],
    easing: "cubic-bezier(" + Array.from(numArr["slice"](7))["map"]((n, W) => _r(n, W % 2 ? -1 : 0, 1))["join"]() + ")"
}, 4096);
r["pause"]()
r["currentTime"] = Math.round(2000 / 10) * 10;
let style = getComputedStyle(newDiv)

Now we can see the responses we expect:

So this function animates the div and that gives us these color and transform values that are used to get this Wo value. In this very last line where it's setting Wo, aka the variable it returns from this co function, there's quite a lot going on here. For now, let's just rename the function and move on. When we go to make a header generator for this header, we can come back to this and understand it better. I'll be renaming co to getAnimationStr and c = getAnimationStr(KEY) to animationStr, since this does appear to return a string. If we're wrong, we can always just change it later.

This is all really starting to come together, great to see. I don't like seeing all of this jumbled mess of code though, spamming Array.from and Uint8Array, a bunch of nested function calls. This is harder to read than it needs to be. Let's deconstruct this into variables:

let randomValue = [Math.random() * 256]
let a = await Nr([method, path, time]["join"]("!") + "bird" + animationKey)
let b = Array.from(new Uint8Array(a))["concat"](ro)
return Kr(new Uint8Array(randomValue["concat"](Array.from(KEY), Array.from(timeBuffer), b.slice(0, 16), [1]))["map"](Yr));

Much better, Now we can get started on this mess. As expected, let's see what Nr is. This looks simple, it appears it can be replaced with this:

function getTextEncoder(text) {
    return typeof text == "string" ? new TextEncoder()["encode"](text) : text
}
function sha256(textEncoder) {
    return crypto.subtle["digest"]("sha-256", textEncoder)
}
let sha256Hash = await sha256(getTextEncoder([method, path, time]["join"]("!") + "bird" + animationKey))

I love when these scripts have easter eggs in them, in this case, "bird" referring to the twitter bird lol. Anyways, this does just appear to be a sha256 hash. Now we need to figure out the ["concat"](ro) thing it does with this hash after it converts it to an array.

Oh hey, it appears that ro is an array that's set by that weird RTCPeerConnection usage we skipped earlier. Well, let's think about this. When we use this b variable, it does b.slice(0, 16). It does this AFTER adding that ro array onto the end of the sha256 hash bytes. So since sha256 is 32bytes and it's being cut off to be only 0-16, that RTCPeerConnection stuff doesn't matter! I cannot express how happy this made me, I really did not want to figure out how that worked on top of everything else. Anyways, lets name this variable to shaBytes and move on.

Next we are doing ["map"](Yr) so let's see what Yr is:

Yr = (n, W, t) => W ? n ^ t[0] : n

Ok so, this is pretty obvious xor, however, what is that W ? ... : n doing? Wait, there's 3 inputs here? I thought .map only returned one value, not 3? Alright, there's gotta be something here I don't know since I'm not really a JavaScript developer (thankfully). I'll write some mock code to see what's going on here:


Now I'm not joking when I said I didn't know what this was doing at first, I really don't code in JavaScript much but that's what this is all about, learning. So I guess you can have an extra 2 optional variables there, one being index and the second being the full ORIGINAL value you're mapping.

Now that we got this out of the way, we still don't really know why they're doing it. So we know W is the index, but how exactly are we doing a boolean check on the index? Well I did actually know that, 0 would return false and everything else would return true. At first I did actually think only 1 would return true but I guess any number that's not 0 returns true. So to sum this up, it basically just does an XOR over every byte EXCEPT the first byte. If you look closer, that first byte is infact the XOR number itself so this does make sense. So now we know our very first byte is going to be the XOR byte, this means we can rename randomValue to XOR_BYTE. We can also rename this Yr function to XOR.

Now we just have one thing left, this Kr function:

Kr = n => btoa(Array.from(n)["map"](n => String["fromCharCode"](n))["join"](""))["replace"](/=/g, "")

Well that's made super easy, let's just rename this to encode. It's pretty much just base64 encoding the bytes, since btoa needs to take in a string, it just converts the bytes to characters though.

Alright and now I'm happy to say, we have a much better looking script. I'm going to do a bit more cleaning up, just for simplicity later on. Here's my final result:

let _2DArray;
function encode(n) {
    return btoa(Array.from(n)["map"](n => String["fromCharCode"](n))["join"](""))["replace"](/=/g, "")
}
function getKey() {
    // <meta name="twitter-site-verification" content="mentUHYU4+1yPz30fM6/IcNS+stghA1baFhBkGzE7075BPd15lUcDqC/RaF4jR+b"/>
    return n = document.querySelectorAll("[name^=tw]")[0].getAttribute("content"), new Uint8Array(atob(n)["split"]("")["map"](n => n["charCodeAt"](0)))
}
function get2DArray(name, KEY) {
    // loading-x-anim-0, loading-x-anim-1, etc. to 3
    return _2DArray = _2DArray || getElements(document.querySelectorAll(name))[KEY[5] % 4]["childNodes"][0]["childNodes"][1].getAttribute("d")["substring"](9)["split"]("C")["map"](n => n["replace"](/[^\d]+/g, " ")["trim"]()["split"](" ")["map"](Number))
}
function toHex(n) {
    return (n < 16 ? "0" : "") + n["toString"](16)
}
function getElements(n) {
    return Array.from(n)["map"](n => {
        var W;
        return null != (W = n["parentElement"]) && W["removeChild"](n), n;
    })
}
function createDiv() {
    const n = document["createElement"]("div");
    return document["body"]["append"](n), [n, () => getElements([n])];
}
function doAnimation(newDiv, numArr, frameTime) {
    if (!newDiv["animate"]) return;
    const r = newDiv["animate"]({
        color: ["#" + toHex(numArr[0]) + toHex(numArr[1]) + toHex(numArr[2]), "#" + toHex(numArr[3]) + toHex(numArr[4]) + toHex(numArr[5])],
        transform: ["rotate(0deg)", "rotate(" + _r(numArr[6], 60, 360, !0) + "deg)"],
        easing: "cubic-bezier(" + Array.from(numArr["slice"](7))["map"]((n, W) => _r(n, W % 2 ? -1 : 0, 1))["join"]() + ")"
    }, 4096);
    r["pause"]()
    r["currentTime"] = Math.round(frameTime / 10) * 10;
}
const XOR = (n, W, t) => W ? n ^ t[0] : n,
    _r = (n, W, t, r) => {
        const o = n * (t - W) / 255 + W;
        return r ? Math.floor(o) : o["toFixed"](2);
    }
let animationStr;
const setAnimationStr = KEY => {
    const [index, frameTime] = [KEY[2] % 16, KEY[12] % 16 * (KEY[14] % 16) * (KEY[7] % 16)],
        arr = get2DArray(".r-32hy0", KEY);
    const [newDiv, deleteDiv] = createDiv();
    doAnimation(newDiv, arr[index], frameTime);
    const style = getComputedStyle(newDiv);
    animationStr = Array.from(("" + style["color"] + style["transform"])["matchAll"](/([\d.-]+)/g))["map"](n => Number(Number(n[0])["toFixed"](2))["toString"](16))["join"]("")["replace"](/[.-]/g, "")
    deleteDiv();
};
function getTextEncoder(text) {
    return typeof text == "string" ? new TextEncoder()["encode"](text) : text
}
function sha256(textEncoder) {
    return crypto.subtle["digest"]("sha-256", textEncoder)
}
return async (path, method) => {
    const time = Math.floor((Date["now"]() - 1682924400 * 1e3) / 1e3),
        timeBuffer = new Uint8Array(new Uint32Array([time])["buffer"]),
        KEY = getKey()
    if(!animationStr) {
        setAnimationStr(KEY)
    }
    let XOR_BYTE = [Math.random() * 256]
    let sha256Hash = await sha256(getTextEncoder([method, path, time]["join"]("!") + "bird" + animationStr))
    let shaBytes = Array.from(new Uint8Array(sha256Hash))
    return encode(new Uint8Array(XOR_BYTE["concat"](Array.from(KEY), Array.from(timeBuffer), shaBytes.slice(0, 16), [1]))["map"](XOR));
};

Alright, very cool, we've got this looking quite a lot like source code at this point. Let's go over everything we know though.

How Twitter Works

So this is all nice and cool, knowing what data goes into it, but what is the point? What exactly is Twitter doing? That's a very loaded question but let's try to answer it using what we already know. Let's ask ourselves, how would twitter actually validate this header, what exactly are they even validating? What can they validate?

Well my idea was to make a POC of what twitter's backend may look like when processing the request. To be clear, this is what we have:

1 0-256 random byte
48 bytes from the key
4 bytes from the time
16 bytes from the sha256 hash
1 literal number one byte

So let's make a script that tries to just get these values out of a given header. Now I'm not super experienced in this stuff, however, I did come up with a way to do this that does technically work:

const header = "1/+dpeUe1JOQrHPcWPad/KVM1V8miCo17v1N1vC/hSzBUEPBhSBXZ+4vvamCZ5MvGdMD99ZnXBdD2qaxuO24qoY6SDeZ1g"
let headerBytes = atob(header).split("").map(n => n.charCodeAt(0))
let XOR_BYTE = headerBytes[0]

let map = {}

for(var i = 0; i < 256; i++) {
    map[i^XOR_BYTE] = i
}

let outputBytes = [XOR_BYTE]
for(var i = 1; i < headerBytes.length; i++) {
    outputBytes.push(map[headerBytes[i]])
}
console.log(outputBytes)

We can see in this output that the final byte is 1 which is exactly what we expected, this seems to work! Alright, let's go ahead and try to validate.

So what exactly does validation look like? Well, there's a lot of possibilities here. This header is used on a lot of endpoints and to assume they ALL process it to same would be highly unlikely knowing everything I know about Twitter. In general, I'd imagine it works something like this though (only showing how it'd work logged into an account):

1.) You request to /home with an `auth_token` and `ct0` (aka csrf) cookie set
2.) Twitter would then likely tie a specific `twitter-site-verification` "key" to your ct0, because the ct0 is meant to be tied to your session, meaning there should be multiple per account depending on the platform. This would make it easier to control
3.) Tied to that `twitter-site-verification` would likely be an expected output for your animation. This is because this is the only thing that is in the sha hash that's also tied to the "key".
4.) Now when you submit a header, they lookup your ct0 and get the expected "animationStr" and "key". They would likely have a better way to reverse that XOR.
5.) To validate, they would sha256 the time you submitted in the bytes, the request path + method, and the expected "animationStr" to see if the first 16 sha bytes match.
6.) They would then probably check that your time looks realistic, like the payload wasn't generated too long ago

Having this in mind, let's just do some basic validation. We don't currently really understand how that animationStr works yet and having it tied to a specific key would be annoying right now. For now, we can just make it pull out the bytes we need, convert the time bytes to the actual time, make sure the key bytes line up, then we're good:

let timeCreated = 0
let keyBytes = []
let timeBytes = []
let shaBytes = []
for(var i = 1; i < outputBytes.length; i++) {
    if(i-1 < keyBytes.length && keyBytes[i-1] != outputBytes[i]) {
        break;
    }
    if(i == outputBytes.length && outputBytes[i] != 1) {
        break;
    }
    if(i < 49) {
        keyBytes.push(outputBytes[i])
        continue
    }
    if(i >= 49 && timeBytes.length != 4) {
        timeBytes.push(outputBytes[i])
        continue
    }
    if(i >= 53 && i != outputBytes.length-1) {
        shaBytes.push(outputBytes[i])
    }
}
console.log(keyBytes)
console.log(timeBytes)
timeCreated = timeBytes[0] + (timeBytes[1] << 8) + (timeBytes[2] << 16) + (timeBytes[3] << 24)
console.log(timeCreated)
console.log(shaBytes)

From this, we can see that our little proof of concept is correct. This will help us later on.

Conclusion

This header really has been fun, I hope you've enjoyed walking through this with me. If you'd like to see the final resources of part 2, you can see them at github.com/obfio/twitter-tid-script-cleaned.

In part 3, we will be going far more in-depth in how that animate function actually works in chrome and finishing off this series by making a header generator in GoLang. Overall actually making the generator in GoLang was very difficult, I hope you guys will enjoy part 3 when it's out :)

References

GitHub for this code: github.com/obfio/twitter-tid-script-cleaned