李恒道 发表于 2026-8-13 00:03

小蓝鸟 x-client-transaction-id 追溯分析

本帖最后由 李恒道 于 2026-8-13 01:57 编辑

关键词找到 return e.isTrue("rweb_client_transaction_id_enabled") && (i.headers["x-client-transaction-id"] = await a(t.host, t.path, t.method)),

然后找到了 https://abs.twimg.com/responsive ... emand.s.cb13130a.js

经过 ast 解析得到了:

!function () {
try {
    var W = "u" > typeof window ? window : "u" > typeof global ? global : "u" > typeof globalThis ? globalThis : "u" > typeof self ? self : {};
    W.SENTRY_RELEASE = {
      id: "bd5de347106a02d9c8611420fdc1de31bf160b21"
    };
    var n = new W.Error().stack;
    n && (W._sentryDebugIds = W._sentryDebugIds || {}, W._sentryDebugIds = "569088b2-348c-4662-9029-d728fdea8e0b", W._sentryDebugIdIdentifier = "sentry-dbid-569088b2-348c-4662-9029-d728fdea8e0b");
} catch {}
}();
"use strict";
(globalThis.webpackChunk_twitter_responsive_web = globalThis.webpackChunk_twitter_responsive_web || []).push([, {
208932(W, n, t) {
    t.d(n, {
      default: () => e
    }), t(28792), t(506533);
    let e = () => {
      let W;
      const = ,
       = ,
      s = W => new O(atob(W).split("").map(W => W.charCodeAt(0))),
      y = W => btoa(Q(W).map(W => String.fromCharCode(W)).join("")).replace(/=/g, ""),
      b = () => s(L(C(""), "content")),
      K = (n, t) => W = W || L(D(C(n)) % 4].childNodes.childNodes, "d").substring(9).split("C").map(W => W.replace(/[^\d]+/g, " ").trim().split(" ").map(a)),
      L = (W, n) => W && W.getAttribute(n) || "",
      J = W => typeof W == "string" ? new l().encode(W) : W,
      h = W => S.digest("sha-256", J(W)),
      N = W => (W < 16 ? "0" : "") + W.toString(16),
      D = W => Q(W).map(W => (W.parentElement?.["removeChild"](W), W)),
      H = () => {
          {
            let n = i.createElement("div");
            return i.body.append(n), )];
          }
      },
       = ,
       = ,
      U = (W, n, t) => n ? W ^ t : W,
      z = (W, n, t) => {
          {
            if (!W.animate) return;
            let e = W.animate(v(n), E);
            e.pause(), e.currentTime = j(t / 10) * 10;
          }
      },
      T = (W, n, t, e) => {
          {
            let r = W * (t - n) / 255 + n;
            return e ? g(r) : r.toFixed(2);
          }
      },
      v = W => ({
          color: ["#" + N(W) + N(W) + N(W), "#" + N(W) + N(W) + N(W)],
          transform: ["rotate(0deg)", "rotate(" + T(W, 60, 360, true) + "deg)"],
          easing: "cubic-bezier(" + Q(W.slice(7)).map((W, n) => T(W, n % 2 ? -1 : 0, 1)).join() + ")"
      });
      let F,
      X = [],
      A = n => {
          if (!F) {
            let = % 16, n % 16 * (n % 16) * (n % 16)],
            C = K(".r-1nskx", n);
            new R(() => {
            {
                let t = new q(),
                  e = V().toString(36);
                t.createDataChannel(e), t.createOffer().then(o => {
                  try {
                  {
                      let W = o.sdp || e;
                      X = Q(J( % 8] || "4", W % 8]])), t.close();
                  }
                  } catch {}
                }).catch(x);
            }
            }).catch(x);
            let = H();
            z(p, C, l);
            let m = G(p);
            F = Q(("" + m.color + m.transform).matchAll(/([\d.-]+)/g)).map(W => a(a(W).toFixed(2)).toString(16)).join("").replace(/[.-]/g, ""), S();
          }
          return F;
      };
      return async (W, n) => {
      let u = g((P.now() - NaN) / 1e3),
          o = new O(new p().buffer),
          c = b(),
          d = A(c);
      return y(new O(.concat(Q(c), Q(o), M(Q(new O(await h(.join("!") + "obfiowerehiring" + d))).concat(X)), )).map(U));
      };
    };
    (Object.getOwnPropertyDescriptor(e, "name") || {}).writable || Object.defineProperty(e, "name", {
      value: "default",
      configurable: true
    });
}
}]);
第零个 V() * 256 是随机掩码。

第一个是 c s(L(querySelectorAll(""), "content")),属于标识符,找到了:

<meta name="twitter-site-verification" content="0VcdiIIH5QjcFDSUuGZ34fedDV+db1TZLPS6udWM1tZF/xxxxxxxx+x2ok/TC7">第二个 let u = g((P.now() - 1682924400000) / 1e3),时间戳。

第三个:

J = W => typeof W == "string" ? new l().encode(W) : W,
h = W => S.digest("sha-256", J(W)),把传入的两个参数和时间戳进行 sha-256,然后 .join("!") + "obfiowerehiring" + d,d 参数是:

K = (n, t) => W = W || L(D(querySelectorAll(n)) % 4].childNodes.childNodes, "d").substring(9).split("C").map(W => W.replace(/[^\d]+/g, " ").trim().split(" ").map(a)),

A = n => {
if (!F) {
    let = % 16, n % 16 * (n % 16) * (n % 16)],
      C = K(".r-1nskx", n);
    new R(() => {
      let t = new q(),
      e = V().toString(36);
      t.createDataChannel(e), t.createOffer().then(o => {
      try {
          let W = o.sdp || e;
          X = Q(J( % 8] || "4", W % 8]])), t.close();
      } catch {}
      }).catch(x);
    }).catch(x);
    let = H();
    z(p, C, l);
    let m = G(p);
    F = Q(("" + m.color + m.transform).matchAll(/([\d.-]+)/g)).map(W => a(a(W).toFixed(2)).toString(16)).join("").replace(/[.-]/g, ""), S();
}
return F;
};首先取之前的 meta 特定位置,求一些基础数据:

let = % 16, n % 16 * (n % 16) * (n % 16)],然后 C = K(".r-1nskx", n);

C(".r-1nskx") 找出页面上所有 .r-1nskx 元素。

t % 4 用种子字节的第 5 位决定选第几个元素。

取它的 childNodes.childNodes,读 d 属性。

substring(9) 去掉前 9 个字符。

按 "C" 切分。

每段里的数字提取出来,用 Number 转成数值数组。

用 RTCPeerConnection 创建一个连接,得到指纹信息,然后按种子信息取值:

let t = new q(),
e = V().toString(36);
t.createDataChannel(e), t.createOffer().then(o => {
try {
    let W = o.sdp || e;
    X = Q(J( % 8] || "4", W % 8]])), t.close();
} catch {}
}).catch(x);创建一个 dom 元素:

let = H();
z(p, C, l);

z = (W, n, t) => {
if (!W.animate) return;
let e = W.animate(v(n), E);
e.pause();
e.currentTime = j(t / 10) * 10;
}其中 v 是:

v = W => ({
color: [
    "#" + N(W) + N(W) + N(W),
    "#" + N(W) + N(W) + N(W)
],
transform: [
    "rotate(0deg)",
    "rotate(" + T(W, 60, 360, true) + "deg)"
],
easing: "cubic-bezier(" +
    Q(W.slice(7)).map((W, n) => T(W, n % 2 ? -1 : 0, 1)).join() +
    ")"
})就是让 div 按 v(n) 的关键帧播放 x 秒,但随后立即暂停并设置 currentTime,让浏览器停留在某个由种子数据决定的动画进度上,再读取计算后的样式作为指纹。

其中数值来源:

let = % 16, n % 16 * (n % 16) * (n % 16)],k 是取 SVG 的数据,判断取哪行的 index,l 是 frameTime。

let m = G(p);
F = Q(("" + m.color + m.transform).matchAll(/([\d.-]+)/g)).map(W => a(a(W).toFixed(2)).toString(16)).join("").replace(/[.-]/g, ""), S();获取到属性元素,提取数字,保留两位小数,再转数字,最后 String(16) 转十六进制,然后 .join("").replace(/[.-]/g, "")。

所以整体顺序是:

[随机掩码]
[页面种子 c]
[时间戳 4 字节]


[版本 3]然后 xor 再 base64。所以随机掩码可以伪造,页面种子不可以,时间戳随便算,WEBRTC 无法确定,版本 3 固定。那 SHA-256 前 16 字节很难被检测,我们可以跳过很多计算细节,直接得到简易算法。

这里推荐测试 d 用搜索的 SearchTimeline 接口,因为有 cf 盾,最好用 fetch 直接在浏览器测试。

那么问题来了。

1. 为什么 CSS 在不同浏览器上精度不同,有细微差异,依然可以通过?

我们可以看到 e.currentTime = j(t / 10) * 10、r.toFixed(2):

F = Q(("" + m.color + m.transform).matchAll(/([\d.-]+)/g)).map(W => a(a(W).toFixed(2)).toString(16)).join("").replace(/[.-]/g, ""), S();等代码,在处理之前都会调用 toFixed 来进行抹除精度,保证后端跟本地计算一致来通过,如果通不过就算用户该死了。

2. 为什么用 CSS 不用 canvas?

canvas 在用户浏览器实现多种多样,可能出现巨大差异,被浏览器/插件进行扰动或直接屏蔽放追踪。相比之下 CSS 兼容性更好,同时实现算法更加困难。

目前市面上求动画无非两类:

1. 直接丢浏览器里执行

2. 每一个都直接硬生生通过算出来

这两种方案 github 开源都有。第一种只能相对较为容易,所以我们要采用第二种方案实现。

让我们再读一下结构:

[随机掩码]
[页面种子 c]
[时间戳 4 字节]


[版本 3]随机掩码 = Math.random() * 256

页面种子 = new Uint8Array(atob(content).split("").map(ch => ch.charCodeAt(0)));

时间戳 4 字节:

const timeSeconds = Math.floor((Date.now() - 1682924400000) / 1000);
const timeBytes = Buffer.alloc(4);
timeBytes.writeUInt32LE(timeSeconds, 0)WebRTC = randomBytes(2)

版本 = 3

接下来着重分析 SHA-256 前 16 字节:

hashInput = method + "!" + path + "!" + timeSeconds + "obfiowerehiring" + d
digest = SHA-256(hashInput)
前16字节 = digest就可以得到例如 GET!/i/api/graphql/PusO6nN_nUSAsfJktZJd9w/SearchTimeline!103251094obfiowerehiring 然后接 d,所以着重在 d 值计算上。

首先第一个问题,k 和 l 的来源是怎么取的?



也是具有两条路线:

1. 正则匹配,直接根据正则 /\(\w\[(\d{1,2})\],\s*16\)/g 找出来

2. ast 解析,之前处理 of 用的该种方式

我们就采用第一种了,然后一路补计算就好了。

比较重要的就是关于 CSS 的计算规则,我们多关心这里。参考 https://blog.nest.moe/posts/twitter-header-part-4 中的精度问题部分,这里尽量多靠 AI 辅导去读浏览器源码。

1. cubic_bezier 缓动函数

给输入时间 x,解出来时间 t,然后得出进度 y。

参考 WebKit 的 UnitBezier.h(github.com/WebKit/WebKit):

double solve(double x, double epsilon)
{
    if (x < 0.0)
      return 0.0 + startGradient * x;
    if (x > 1.0)
      return 1.0 + endGradient * (x - 1.0);
    return sampleCurveY(solveCurveX(x, epsilon));
}

double solveCurveX(double x, double epsilon)
{
    double t0 = 0.0, t1 = 0.0, t2 = x;
    double x2 = 0.0, d2 = 0.0;
    int i = 0;

    // Linear interpolation of spline curve for initial guess.
    double deltaT = 1.0 / (CUBIC_BEZIER_SPLINE_SAMPLES - 1);
    for (i = 1; i < CUBIC_BEZIER_SPLINE_SAMPLES; i++) {
      if (x <= splineSamples) {
            t1 = deltaT * i;
            t0 = t1 - deltaT;
            t2 = t0 + (t1 - t0) * (x - splineSamples) / (splineSamples - splineSamples);
            break;
      }
    }

    // Perform a few iterations of Newton's method.
    double newtonEpsilon = std::min(kBezierEpsilon, epsilon);
    for (i = 0; i < kMaxNewtonIterations; i++) {
      x2 = sampleCurveX(t2) - x;
      if (std::abs(x2) < newtonEpsilon)
            return t2;
      d2 = sampleCurveDerivativeX(t2);
      if (std::abs(d2) < kBezierEpsilon)
            break;
      t2 = t2 - x2 / d2;
    }
    if (std::abs(x2) < epsilon)
      return t2;

    // Fall back to the bisection method for reliability.
    while (t0 < t1) {
      x2 = sampleCurveX(t2);
      if (std::abs(x2 - x) < epsilon)
            return t2;
      if (x > x2)
            t0 = t2;
      else
            t1 = t2;
      t2 = std::midpoint(t0, t1);
    }

    // Failure.
    return t2;
}

double sampleCurveY(double t)
{
    return ((ay * t + by) * t + cy) * t;
}这里简单来说先进行采样,然后通过斜率/误差逼近,失败再回退二分。市面实现只有二分,我们也只实现二分即可,但是需要注意精度遵守 1e-7:

function solve(value, minVal, maxVal, rounding) {
const result = (value * (maxVal - minVal)) / 255 + minVal;
return rounding ? Math.floor(result) : Number(result.toFixed(2));
}

function sampleCurveX(t, x1, x2) {
const cx = 3 * x1;
const bx = 3 * (x2 - x1) - cx;
const ax = 1 - cx - bx;
return ((ax * t + bx) * t + cx) * t;
}

function sampleCurveY(t, y1, y2) {
const cy = 3 * y1;
const by = 3 * (y2 - y1) - cy;
const ay = 1 - cy - by;
return ((ay * t + by) * t + cy) * t;
}

function cubicBezierYByBisection(xTarget, curves, epsilon = 1e-7) {
const = curves;
let lo = 0;
let hi = 1;

while (hi - lo > epsilon) {
    const mid = (lo + hi) / 2;
    const xMid = sampleCurveX(mid, x1, x2);
    if (Math.abs(xMid - xTarget) < epsilon) {
      return sampleCurveY(mid, y1, y2);
    }
    if (xMid < xTarget) lo = mid;
    else hi = mid;
}

return sampleCurveY((lo + hi) / 2, y1, y2);
}

const curves = frameRow.slice(7).map((value, index) =>
solve(value, index % 2 ? -1 : 0, 1, false),
);
const val = cubicBezierYByBisection(targetTime, curves);同理:

inline float interpolateComponentWithoutAccountingForNaN(float componentFromColor1, double color1Multiplier, float componentFromColor2, double color2Multiplier)
{
    return (componentFromColor1 * color1Multiplier) + (componentFromColor2 * color2Multiplier);
}按进度取开始颜色到结束颜色,得 color = from * color1Multiplier + to * color2Multiplier。

TransformationMatrix& TransformationMatrix::rotate(double angle, RotationSnapping snapping)
{
    if (!std::fmod(angle, 360))
      return *this;

    return rotateRadians(deg2rad(angle), snapping);
}

TransformationMatrix& TransformationMatrix::rotateRadians(double angle, RotationSnapping snapping)
{
    double sinZ = snapping == RotationSnapping::Snap90degRotations ? roundEpsilonToZero(sin(angle)) : sin(angle);
    double cosZ = snapping == RotationSnapping::Snap90degRotations ? roundEpsilonToZero(cos(angle)) : cos(angle);
    multiply({ cosZ, sinZ, -sinZ, cosZ, 0, 0 });
    return *this;
}这里是将开始角度和结束角度归一求出角度,然后算出矩阵,最后根据矩阵求 d,可以得出:

function generateD(frames, keyBytes) {
if (!frames || !keyBytes) {
    console.log({ frames, keyBytes });
    return "";
}

const frameD = frames % 4];
const frameRows = parseFrameRows(frameD);

const rowIndex = keyBytes] % 16;
const frameRow = frameRows;

let frameTime = 1;
for (const idx of ONDEMAND_INDICES.slice(1)) {
    frameTime *= keyBytes % 16;
}

frameTime = Math.round(frameTime / 10) * 10;
const targetTime = frameTime / TOTAL_TIME;
const curves = frameRow.slice(7).map((value, index) =>
    solve(value, index % 2 ? -1 : 0, 1, false),
);
const val = cubicBezierYByBisection(targetTime, curves);

// Color: interpolate from RGB to RGB, clamp, round, hex
const fromColor = frameRow.slice(0, 3).concat(1).map(Number);
const toColor = frameRow.slice(3, 6).concat(1).map(Number);
const color = interpolate(fromColor, toColor, val).map((value) =>
    Math.min(255, Math.max(0, value)),
);

// Rotation: 0deg -> target angle at progress val, then matrix
const toRotation = solve(frameRow, 60, 360, true);

const rotation = toRotation * val;
const rad = (rotation * Math.PI) / 180;
const matrix = ;

const strArr = color
    .slice(0, -1)
    .map((value) => Math.round(value).toString(16));

for (const value of matrix) {
    let rounded = Number(value.toFixed(2));
    if (rounded < 0) rounded = -rounded;
    const hexValue = floatToHex(rounded);
    strArr.push(
      hexValue.startsWith(".") ? ("0" + hexValue).toLowerCase() : hexValue || "0",
    );
}

strArr.push("0", "0");
const d = strArr.join("").replace(/[.-]/g, "");

console.log({
    rowIndex,
    frameTime,
    targetTime,
    curves,
    val,
    frameRow,
    d,
});

return d;
}得到结果,bypass。



vicivila 发表于 2026-8-13 13:28

小蓝鸟是啥{:1_885:}

jbxf110 发表于 2026-8-18 09:05

第一个是 c s(L(querySelectorAll(""), "content")),属于标识符,找不到

nzy8513 发表于 2026-8-13 11:55

大佬厉害啊

Sycamoreee 发表于 2026-8-13 12:24

真是大佬

liushilong08 发表于 2026-8-13 12:45

真是大佬!

李恒道 发表于 2026-8-13 14:10

vicivila 发表于 2026-8-13 13:28
小蓝鸟是啥

推-特啦,标题不敢写的太直白

狗哥哥 发表于 2026-8-13 14:13

大佬太强了

移动固话 发表于 2026-8-13 14:21

所以 在说什么??看不懂

Huanxian 发表于 2026-8-14 08:16

没有看懂是啥,这个教程是起到啥作用的。我是菜鸟

ff7327 发表于 2026-8-14 09:43

不明觉厉,不过没有看懂是干啥的:rggrg
页: [1] 2 3 4 5
查看完整版本: 小蓝鸟 x-client-transaction-id 追溯分析