generated at
ScrapboxにおけるWebSocketの挙動
Scrapboxでは、ページの編集、関連ページリストの更新、Streamの更新などをWebSocketを通じてserverとやり取りしている
この通信を調べてみたい

目的
WebSocketを乗っ取ってprogramからscrapboxを編集できるようにする
できるかどうかわからないが、できたら便利なので試してみる

接続開始までならできることは確認済み
具体的な通信内容を調べてみる
GET wss://scrapbox.io/socket.io/?EIO=4&transport=websocket
接続開始からの通信内容
sid は伏せ字にした
down
0{"sid":"xxxx","upgrades":[],"pingInterval":25000,"pingTimeout":20000}
up
40
down
40{"sid":"yyyy"}
up
420["socket.io-request",{"method":"room:join","data":{"pageId":null,"projectUpdatesStream":false}}]
down
430[{"data":{"success":true,"message":"leaved from projectRoom and pageRoom."}}]
up
421["socket.io-request",{"method":"room:join","data":{"pageId":null,"projectId":"5c761758dfd2e10017490824","projectUpdatesStream":false}}]
down
431[{"data":{"success":true,"pageId":null,"projectId":"5c761758dfd2e10017490824"}}]
up
422["socket.io-request",{"method":"room:join","data":{"pageId":null,"projectId":"5c761758dfd2e10017490824","projectUpdatesStream":false}}]
down
432[{"data":{"success":true,"pageId":null,"projectId":"5c761758dfd2e10017490824"}}]
up
423["socket.io-request",{"method":"room:join","data":{"pageId":"60ea38769f2587001c75628b","projectId":"5c761758dfd2e10017490824","projectUpdatesStream":false}}]
down
433[{"data":{"success":true,"pageId":"60ea38769f2587001c75628b","projectId":"5c761758dfd2e10017490824"}}]
down
2
up
3
うーん、冒頭の数字の意味が読めない……takker
単なる識別子?
Socket.IOのなんらかのIDっぽい
index.js packet で検索するとわかりそう
decodeしているのは65714行あたり
js
86496: (n, i, s) =>{ const _ = s(69743), w = s(78746), P = s(14802) ('engine.io-client:transport'); n.exports = class Transport extends w { constructor(n) { super (), this.opts = n, this.query = n.query, this.readyState = '', this.socket = n.socket } onError(n, i) { const s = new Error(n); return s.type = 'TransportError', s.description = i, this.emit('error', s), this } open() { return 'closed' !== this.readyState && '' !== this.readyState || (this.readyState = 'opening', this.doOpen()), this } close() { return 'opening' !== this.readyState && 'open' !== this.readyState || (this.doClose(), this.onClose()), this } send(n) { 'open' === this.readyState ? this.write(n) : P('transport is not open, discarding packets') } onOpen() { this.readyState = 'open', this.writable = !0, this.emit('open') } onData(n) { const i = _.decodePacket(n, this.socket.binaryType); this.onPacket(i) } onPacket(n) { this.emit('packet', n) } onClose() { this.readyState = 'closed', this.emit('close') } } },
66105行辺りに送信と受信のclassがある?
js
class WS extends w { constructor(n) { super (n), this.supportsBinary = !n.forceBase64 } get name() { return 'websocket' } doOpen() { if (!this.check()) return; const n = this.uri(), i = this.opts.protocols, s = le ? { } : B(this.opts, 'agent', 'perMessageDeflate', 'pfx', 'key', 'passphrase', 'cert', 'ca', 'ciphers', 'rejectUnauthorized', 'localAddress', 'protocolVersion', 'origin', 'maxPayload', 'family', 'checkServerIdentity'); this.opts.extraHeaders && (s.headers = this.opts.extraHeaders); try { this.ws = ne && !le ? i ? new $(n, i) : new $(n) : new $(n, i, s) } catch (_) { return this.emit('error', _) } this.ws.binaryType = this.socket.binaryType || ie, this.addEventListeners() } addEventListeners() { this.ws.onopen = () =>{ this.opts.autoUnref && this.ws._socket.unref(), this.onOpen() }, this.ws.onclose = this.onClose.bind(this), this.ws.onmessage = n=>this.onData(n.data), this.ws.onerror = n=>this.onError('websocket error', n) } write(n) { this.writable = !1; for (let i = 0; i < n.length; i++) { const s = n[i], w = i === n.length - 1; P.encodePacket(s, this.supportsBinary, (n=>{ const i = { }; if (!ne && (s.options && (i.compress = s.options.compress), this.opts.perMessageDeflate)) { ('string' == typeof n ? _.byteLength(n) : n.length) < this.opts.perMessageDeflate.threshold && (i.compress = !1) } try { ne ? this.ws.send(n) : this.ws.send(n, i) } catch (P) { se('websocket closed before onclose event') } w && oe((() =>{ this.writable = !0, this.emit('drain') })) })) } } onClose() { w.prototype.onClose.call(this) } doClose() { void 0 !== this.ws && (this.ws.close(), this.ws = null) } uri() { let n = this.query || { }; const i = this.opts.secure ? 'wss' : 'ws'; let s = ''; this.opts.port && ('wss' === i && 443 !== Number(this.opts.port) || 'ws' === i && 80 !== Number(this.opts.port)) && (s = ':' + this.opts.port), this.opts.timestampRequests && (n[this.opts.timestampParam] = j()), this.supportsBinary || (n.b64 = 1), n = A.encode(n), n.length && (n = '?' + n); return i + '://' + ( - 1 !== this.opts.hostname.indexOf(':') ? '[' + this.opts.hostname + ']' : this.opts.hostname) + s + this.opts.path + n } check() { return !(!$ || '__initialize' in $ && this.name === WS.prototype.name) } }
送信データのエンコードを呼び出しているところ
js
_packet(n) { $('writing packet %j', n); const i = this.encoder.encode(n); for (let s = 0; s < i.length; s++) this.engine.write(i[s], n.options) }
Encoder Decoder のクラス定義
js
95485: (n, i, s) =>{ 'use strict'; Object.defineProperty(i, '__esModule', { value: !0 }), i.Decoder = i.Encoder = i.PacketType = i.protocol = void 0; const _ = s(15778), w = s(67719), P = s(22986), A = s(41618) ('socket.io-parser'); var j; i.protocol = 5, function (n) { n[n.CONNECT = 0] = 'CONNECT', n[n.DISCONNECT = 1] = 'DISCONNECT', n[n.EVENT = 2] = 'EVENT', n[n.ACK = 3] = 'ACK', n[n.CONNECT_ERROR = 4] = 'CONNECT_ERROR', n[n.BINARY_EVENT = 5] = 'BINARY_EVENT', n[n.BINARY_ACK = 6] = 'BINARY_ACK' }(j = i.PacketType || (i.PacketType = { })); i.Encoder = class Encoder { encode(n) { return A('encoding packet %j', n), n.type !== j.EVENT && n.type !== j.ACK || !P.hasBinary(n) ? [ this.encodeAsString(n) ] : (n.type = n.type === j.EVENT ? j.BINARY_EVENT : j.BINARY_ACK, this.encodeAsBinary(n)) } encodeAsString(n) { let i = '' + n.type; return n.type !== j.BINARY_EVENT && n.type !== j.BINARY_ACK || (i += n.attachments + '-'), n.nsp && '/' !== n.nsp && (i += n.nsp + ','), null != n.id && (i += n.id), null != n.data && (i += JSON.stringify(n.data)), A('encoded %j as %s', n, i), i } encodeAsBinary(n) { const i = w.deconstructPacket(n), s = this.encodeAsString(i.packet), _ = i.buffers; return _.unshift(s), _ } }; class Decoder extends _ { constructor() { super () } add(n) { let i; if ('string' == typeof n) i = this.decodeString(n), i.type === j.BINARY_EVENT || i.type === j.BINARY_ACK ? (this.reconstructor = new BinaryReconstructor(i), 0 === i.attachments && super.emit('decoded', i)) : super.emit('decoded', i); else { if (!P.isBinary(n) && !n.base64) throw new Error('Unknown type: ' + n); if (!this.reconstructor) throw new Error('got binary data when not reconstructing a packet'); i = this.reconstructor.takeBinaryData(n), i && (this.reconstructor = null, super.emit('decoded', i)) } } decodeString(n) { let i = 0; const s = { type: Number(n.charAt(0)) }; if (void 0 === j[s.type]) throw new Error('unknown packet type ' + s.type); if (s.type === j.BINARY_EVENT || s.type === j.BINARY_ACK) { const _ = i + 1; for (; '-' !== n.charAt(++i) && i != n.length; ); const w = n.substring(_, i); if (w != Number(w) || '-' !== n.charAt(i)) throw new Error('Illegal attachments'); s.attachments = Number(w) } if ('/' === n.charAt(i + 1)) { const _ = i + 1; for (; ++i; ) { if (',' === n.charAt(i)) break; if (i === n.length) break } s.nsp = n.substring(_, i) } else s.nsp = '/'; const _ = n.charAt(i + 1); if ('' !== _ && Number(_) == _) { const _ = i + 1; for (; ++i; ) { const s = n.charAt(i); if (null == s || Number(s) != s) { --i; break } if (i === n.length) break } s.id = Number(n.substring(_, i + 1))
↑ここでJSONの頭についている数字 xxxx[...] からidを分離している
先頭一文字がpacket type, 後ろの数字がid
この数字は実際にwebsocketで通信されている数字と違う
実際は先頭にさらに 4 がついている
どこで削られたかをstack traceからたどる
13:08:17 decodePacket() で削られていた
13:13:26 decodePacket() の定義
js
n.exports = (n, i) =>{ if ('string' != typeof n) return { type: 'message', data: mapBinary(n, i) }; const s = n.charAt(0); if ('b' === s) return { type: 'message', data: decodeBase64Packet(n.substring(1), i) }; return _[s] ? n.length > 1 ? { type: _[s], data: n.substring(1) } : { type: _[s] } : w }
13:14:20 一番最初の数字は、socket.ioで使用している識別子だ
識別子一覧( _ に該当)
js
const _ = { 0: "open"​, 1: "close"​, 2: "ping"​, 3: "pong"​, 4: "message"​, 5: "upgrade"​, 6: "noop", }
これでJSONの先頭についている数字の意味がわかった
あとわからないのは、IDの生成規則くらいか。
githubsocketio/socket.io-clientを見ればわかるか?
てかここのコードってもろにこれじゃん
js
} if (n.charAt(++i)) { const _ = function tryParse(n) { try { return JSON.parse(n) } catch (i) { return !1 } }(n.substr(i)); if (!Decoder.isPayloadValid(s.type, _)) throw new Error('invalid payload'); s.data = _ } return A('decoded %s as %j', n, s), s } static isPayloadValid(n, i) { switch (n) { case j.CONNECT: return 'object' == typeof i; case j.DISCONNECT: return void 0 === i; case j.CONNECT_ERROR: return 'string' == typeof i || 'object' == typeof i; case j.EVENT: case j.BINARY_EVENT: return Array.isArray(i) && i.length > 0; case j.ACK: case j.BINARY_ACK: return Array.isArray(i) } } destroy() { this.reconstructor && this.reconstructor.finishedReconstruction() } } i.Decoder = Decoder; class BinaryReconstructor { constructor(n) { this.packet = n, this.buffers = [ ], this.reconPack = n } takeBinaryData(n) { if (this.buffers.push(n), this.buffers.length === this.reconPack.attachments) { const n = w.reconstructPacket(this.reconPack, this.buffers); return this.finishedReconstruction(), n } return null } finishedReconstruction() { this.reconPack = null, this.buffers = [ ] } } },
cursorが動いた時
up
42["cursor",{"user":{"id":"5ef2bdebb60650001e1280f0","displayName":"takker"},"pageId":"60ea38769f2587001c75628b","position":{"line":37,"char":10},"visible":false}]
cursorにfocusが当たっているときは "visible": true になる
編集された時
up
4223["socket.io-request",{"method":"commit","data":{"kind":"page","parentId":"60ea3f9a7dc630001ce6abc8","changes":[{"_update":"60ea3f9a1280f00000821065","lines":{"text":"  code:up"}},{"_insert":"60ea3d621280f00000c87813","lines":{"id":"60ea3fb81280f00000821066","text":"   "}}],"cursor":null,"pageId":"60ea38769f2587001c75628b","userId":"5ef2bdebb60650001e1280f0","projectId":"5c761758dfd2e10017490824","freeze":true}}]
down
4323[{"data":{"commitId":"60ea3fb77dc630001ce6abd3"}}]
ページの削除
up
4277["socket.io-request",{"method":"commit","data":{"kind":"page","parentId":"60ea40c37dc630001ce6ac90","changes":[{"deleted":true}],"cursor":null,"pageId":"60ea406402a097001c3154a6","userId":"5ef2bdebb60650001e1280f0","projectId":"5c761758dfd2e10017490824","freeze":true}}]
Pinの付け外し
これもwebsocketを使っている
pinする
up
422["socket.io-request",{"method":"commit","data":{"kind":"page","parentId":"614419bc5f0edc001d2779af","changes":[{"pin":9007197622886883}],"cursor":null,"pageId":"6093d2994082fd001c754546","userId":"5ef2bdebb60650001e1280f0","projectId":"5e2455255664e000177a46fc","freeze":true}}]
unpinする
up
425["socket.io-request",{"method":"commit","data":{"kind":"page","parentId":"61441e1ba7e89e001d75d490","changes":[{"pin":0}],"cursor":null,"pageId":"6093d2994082fd001c754546","userId":"5ef2bdebb60650001e1280f0","projectId":"5e2455255664e000177a46fc","freeze":true}}]

socket.io-request /shokai/socket.io-requestに依る通信

ちなみにやろうとしていることはもろにこれに該当する
ここの「ケース2: ブラウザからサーバへの攻撃」
さすがにまずいかなあtakker
マルウェア仕込むのとほぼやっていることが変わらないんだよねえ
まあホワイトハッカーということでyosider
第三者の「攻撃者」がいない以上問題ないのではblu3mo
まあ攻撃しているわけではないのでそうですが、過失(userscriptのミス)でscrapbox.ioを攻撃してしまうことは十分に考えられるんですよね……takker

自前で立ち上げたWebSocketからの挙動
テストコード
js
(async () => { const {createWS} = await import("https://scrapbox.io/api/code/takker/%E7%B0%A1%E5%8D%98WebSocket_Promise_wrapper/mod.js") window.port_test = await createWS("wss://scrapbox.io/socket.io/?EIO=4&transport=websocket"); })();
このあと、 port_test.send("...") で試せる
undefined a などの文字列を送ると即接続が切れる
数字を送ると別の数字が返ってきたりする
接続は切れないみたい
しばらく何も送信しないと接続が切れる
40 を送ると sid を取得できる
42 だけ送ったらこんなのがたくさん返ってきた
json
{ "type": 4, "nsp": "/", "id": 2, "data": [ "graceful-shutdown" ] }
port_test recieve 函数っぽいものの使い方がわからないですyuyasurarin
開いてsend/closeはできた
メッセージ?を受け取ってその中身をしりたい
とりあえずいらない気がしたので socket = new Websocket(url) の方でためしてみます
recieve async generatorというやつで、 for await 文とともに使いますtakker
sample.js
for await (const event of recieve()) { const data = event.data; console.log(data); // ... }
addEventListener("message", (event) => {}) をfor loopにしたようなイメージ
それと、このscript(/takker/簡単WebSocket Promise wrapper)はまだよくscrapboxのwebsocketの挙動がわかっていない頃に作った書き捨てコードなので、使用はおすすめしませんtakker
socket.IOでちゃんと動かしているtakker/scrapbox-userscript-stdを使っていただけたらと思います