百趣云 百趣云的博客

1688 千牛 IM 协议逆向:WebSocket 上徒手构造一条消息

背景

做 1688 商家工具,绕不开千牛 IM:自动回复、发商品卡片、发位置消息。但 1688 的聊天不是普通的 HTTP 接口——它走的是 WebSocket + 阿里 LWP 私有协议,网上几乎找不到任何资料。这篇文章记录我从零摸透这套协议、并徒手构造一条位置消息的全过程。

第一步:确定协议栈

打开 1688 聊天页,F12 看 Network,会发现消息收发根本不走 XHR,全部在一条 WebSocket 连接里。所以第一件事是 hook 掉页面的 WebSocket,把真实流量看清楚:

function hookWS() {
    if (window.__wsHooked) return console.log('WS 已 hook');
    window.__wsHooked = true;
    window.__wsList = [];

    const Orig = WebSocket;
    window.WebSocket = function (...args) {
        const ws = new Orig(...args);
        window.__wsList.push(ws);
        console.log('[WS open]', args[0]);

        const origSend = ws.send.bind(ws);
        ws.send = function (data) {
            const text = typeof data === 'string' ? data : '';
            if (!text) return origSend(data);

            window.__lastWSSend = text;
            const midM = text.match(/"mid"\s*:\s*"?([^",}\s]+)"?/);
            if (midM) window.__lastMid = midM[1];

            if (/sendByReceiverScope|MessageSend/i.test(text)) {
                console.log('[WS send]', text.slice(0, 500));
            }
            return origSend(data);
        };
        return ws;
    };
    window.WebSocket.prototype = Orig.prototype;
}

注意一个细节:window.WebSocket.prototype = Orig.prototype 必须保留,否则页面里 instanceof 检查会直接炸。

解剖消息 frame

手动发一条消息,从 hook 日志里就能看到 LWP 协议的真容。一条发送消息的 frame 长这样:

{
  "lwp": "/r/MessageSend/sendByReceiverScope",
  "headers": { "mid": "1756xxxxxxxxxx" },
  "body": [
    {
      "cid": "2218845998810",
      "uuid": "_loc_1756xxxxxxxxxx_1",
      "conversationType": 1,
      "redPointPolicy": 0,
      "extension": {
        "senderBizDomain": "cbu",
        "receiverBizDomain": "cbu",
        "pushMsgType": "bc_chat",
        "senderMainUserId": 2218xxxxxxxx,
        "sender_nick": "cnalichn你的昵称"
      },
      "content": {
        "contentType": 101,
        "custom": {
          "type": 10,
          "data": "eyJsb2NhdGlvbk5hbWUiOiIuLi4ifQ=="
        }
      },
      "ctx": { "senderBizDomain": "cbu", "receiverBizDomain": "cbu" }
    },
    { "actualReceivers": ["cnalichn对方昵称"] }
  ]
}

关键字段逐个拆解:

  • lwp: /r/MessageSend/sendByReceiverScope:LWP 的路由路径,相当于 HTTP 里的 URL
  • headers.mid:消息 ID,单调递增,直接复用主程序上一条的 mid 最稳妥
  • cid:会话 ID
  • content.contentType: 101:自定义消息类型
  • custom.type: 10 + base64 的 {locationName}:位置消息的载体
  • actualReceivers:接收方 nick,注意 nick 都要带 cnalichn 前缀(1688 的 appKey)

会话参数从哪来

构造消息需要四个参数:cidpairSecond(对方 nick)、senderMainUserId、自己的 sender_nick。它们散落在页面各处:

  • cid:藏在 window._vs 里,但路径不固定,我的办法是递归深搜整个对象,找 cid 字段或以 @ 开头的长字符串
  • senderMainUserId:优先从 _vs.loginID 里取,取不到就从 cookie 的 unb 字段兜底
  • nick:从任意一条历史消息的 ext.sender_nick / receiver_nick 推断,排除掉"官方助手"就是自己和对方
function findCidInVS(vs = window._vs || {}) {
    const hits = [];
    (function walk(o, path = '_vs', depth = 0, seen = new WeakSet()) {
        if (!o || typeof o !== 'object' || depth > 8) return;
        if (seen.has(o)) return;
        seen.add(o);
        for (const [k, v] of Object.entries(o)) {
            if (/^cid$/i.test(k) && v) hits.push({ path: path + '.' + k, cid: String(v) });
            if (typeof v === 'string' && /^@/.test(v) && v.length > 10) {
                hits.push({ path: path + '.' + k, cid: v });
            }
            if (v && typeof v === 'object') walk(v, path + '.' + k, depth + 1, seen);
        }
    })(vs);
    return hits[0]?.cid || vs.cid || new URLSearchParams(location.search).get('cid') || null;
}

递归时一定要带 WeakSet 去重和深度限制,_vs 里有循环引用,裸递归直接栈溢出。

最大的坑:cbu_url_safety

消息内容里只要带链接,直接发出去会被风控拦下。抓包对比发现,主程序发送带链接的消息时,extension 里会多一个 cbu_url_safety 字段,结构还不公开。

解决办法很"流氓"但有效:先 hook 着,手动发一条带链接的消息,把主程序生成的 cbu_url_safety 样本偷下来,之后构造消息时照着样本填

// hook 时顺手抓样本
const safetyM = text.match(/"cbu_url_safety"\s*:\s*("(?:\\.|[^"\\])*"|{[\s\S]*?})/);
if (safetyM) {
    try {
        window.__cbuUrlSafetySample = JSON.parse(safetyM[1]);
    } catch {
        window.__cbuUrlSafetySample = safetyM[1];
    }
}

// 构造时复用样本结构,只替换 url
if (Array.isArray(parsed)) {
    return JSON.stringify(urls.map((url, i) => ({
        ...(parsed[i] || parsed[0] || {}), url
    })));
}

实在没有样本时的兜底结构:

return JSON.stringify({
    urlList: urls.map((url) => ({
        url,
        domain: new URL(url).hostname,
        safe: true,
    })),
});

发送

frame 构造好后,直接往 hook 到的最后一个 WebSocket 里 send 即可:

function sendFrame(frame = window.__frame) {
    const ws = window.__wsList?.[window.__wsList.length - 1];
    if (!ws || ws.readyState !== WebSocket.OPEN) {
        throw new Error('没有可用 WebSocket,请先 hookWS() 并重进聊天页');
    }
    ws.send(typeof frame === 'string' ? frame : JSON.stringify(frame));
}

对方客户端实时收到位置消息卡片,和手动发送的完全一致。

总结

1688 IM 的门槛不在加密,而在协议完全不公开:LWP 路由、frame 结构、nick 前缀、cbu_url_safety,每一步都得靠 hook 主程序"偷样本"来对齐。这套"hook WS → 抓样本 → 构造 frame → 复用连接发送"的方法论,同样适用于钉钉、千牛卖家版等所有阿里系 IM。

完整工具脚本约 350 行,需要交流的微信:teawhites

By 百趣云 阅读量:4 On