ScrapboxにおけるWebSocketの挙動
Scrapboxでは、ページの編集、関連ページリストの更新、Streamの更新などをWebSocketを通じてserverとやり取りしている この通信を調べてみたい
目的
WebSocketを乗っ取ってprogramからscrapboxを編集できるようにする
できるかどうかわからないが、できたら便利なので試してみる
接続開始までならできることは確認済み
具体的な通信内容を調べてみる
GET wss://scrapbox.io/socket.io/?EIO=4&transport=websocket
接続開始からの通信内容
sidは伏せ字にした
code:down
0{"sid":"xxxx","upgrades":[],"pingInterval":25000,"pingTimeout":20000}
code:up
40
code:down
40{"sid":"yyyy"}
code:up
code:down
code:up
code:down
code:up
code:down
code:up
code:down
code:down
2
code:up
3
うーん、冒頭の数字の意味が読めない……takker.icon
単なる識別子?
decodeしているのは65714行あたり
code: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がある?
code: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++) {
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.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)
}
}
送信データのエンコードを呼び出しているところ
code:js
_packet(n) {
$('writing packet %j', n);
const i = this.encoder.encode(n);
for (let s = 0; s < i.length; s++) this.engine.write(is, n.options) }
EncoderとDecoderのクラス定義
code: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) {
}(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 === js.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()の定義
code: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 ? { data: n.substring(1)
}
: {
}
: w
}
13:14:20 一番最初の数字は、socket.ioで使用している識別子だ
識別子一覧(_に該当)
code:js
const _ = {
0: "open",
1: "close",
2: "ping",
3: "pong",
4: "message",
5: "upgrade",
6: "noop",
}
これでJSONの先頭についている数字の意味がわかった
あとわからないのは、IDの生成規則くらいか。
code: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が動いた時
code:up
cursorにfocusが当たっているときは"visible": trueになる
編集された時
code: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}}] code:down
ページの削除
code: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する
code: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する
code: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}}] ちなみにやろうとしていることはもろにこれに該当する
ここの「ケース2: ブラウザからサーバへの攻撃」
さすがにまずいかなあtakker.icon
マルウェア仕込むのとほぼやっていることが変わらないんだよねえ
第三者の「攻撃者」がいない以上問題ないのではblu3mo.icon
まあ攻撃しているわけではないのでそうですが、過失(userscriptのミス)でscrapbox.ioを攻撃してしまうことは十分に考えられるんですよね……takker.icon
自前で立ち上げたWebSocketからの挙動
テストコード
code:js
(async () => {
window.port_test = await createWS("wss://scrapbox.io/socket.io/?EIO=4&transport=websocket");
})();
このあと、port_test.send("...")で試せる
undefinedやaなどの文字列を送ると即接続が切れる
数字を送ると別の数字が返ってきたりする
接続は切れないみたい
しばらく何も送信しないと接続が切れる
40を送るとsidを取得できる
42だけ送ったらこんなのがたくさん返ってきた
code:json
{
"type": 4,
"nsp": "/",
"id": 2,
"data": [
"graceful-shutdown"
]
}
port_testのrecieve函数っぽいものの使い方がわからないですyuyasurarin.icon
開いてsend/closeはできた
メッセージ?を受け取ってその中身をしりたい
とりあえずいらない気がしたのでsocket = new Websocket(url)の方でためしてみます
code:sample.js
for await (const event of recieve()) {
const data = event.data;
console.log(data);
// ...
}
addEventListener("message", (event) => {})をfor loopにしたようなイメージ