ScrapboxにおけるWebSocketの挙動
Scrapboxでは、ページの編集、関連ページリストの更新、Streamの更新などを
WebSocketを通じてserverとやり取りしている
この通信を調べてみたい
目的
WebSocketを乗っ取ってprogramからscrapboxを編集できるようにする
できるかどうかわからないが、できたら便利なので試してみる
接続開始までならできることは確認済み
具体的な通信内容を調べてみる
GET wss://scrapbox.io/socket.io/?EIO=4&transport=websocket
接続開始からの通信内容
sid
は伏せ字にした
down0{"sid":"xxxx","upgrades":[],"pingInterval":25000,"pingTimeout":20000}
up420["socket.io-request",{"method":"room:join","data":{"pageId":null,"projectUpdatesStream":false}}]
down430[{"data":{"success":true,"message":"leaved from projectRoom and pageRoom."}}]
up421["socket.io-request",{"method":"room:join","data":{"pageId":null,"projectId":"5c761758dfd2e10017490824","projectUpdatesStream":false}}]
down431[{"data":{"success":true,"pageId":null,"projectId":"5c761758dfd2e10017490824"}}]
up422["socket.io-request",{"method":"room:join","data":{"pageId":null,"projectId":"5c761758dfd2e10017490824","projectUpdatesStream":false}}]
down432[{"data":{"success":true,"pageId":null,"projectId":"5c761758dfd2e10017490824"}}]
up423["socket.io-request",{"method":"room:join","data":{"pageId":"60ea38769f2587001c75628b","projectId":"5c761758dfd2e10017490824","projectUpdatesStream":false}}]
down433[{"data":{"success":true,"pageId":"60ea38769f2587001c75628b","projectId":"5c761758dfd2e10017490824"}}]
うーん、冒頭の数字の意味が読めない……
単なる識別子?
decodeしているのは65714行あたり
js86496: (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がある?
jsclass 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
のクラス定義
js95485: (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()
の定義
jsn.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で使用している識別子だ
識別子一覧( _
に該当)
jsconst _ = {
0: "open",
1: "close",
2: "ping",
3: "pong",
4: "message",
5: "upgrade",
6: "noop",
}
これでJSONの先頭についている数字の意味がわかった
あとわからないのは、IDの生成規則くらいか。
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が動いた時
up42["cursor",{"user":{"id":"5ef2bdebb60650001e1280f0","displayName":"takker"},"pageId":"60ea38769f2587001c75628b","position":{"line":37,"char":10},"visible":false}]
cursorにfocusが当たっているときは "visible": true
になる
編集された時
up4223["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}}]
down4323[{"data":{"commitId":"60ea3fb77dc630001ce6abd3"}}]
ページの削除
up4277["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する
up422["socket.io-request",{"method":"commit","data":{"kind":"page","parentId":"614419bc5f0edc001d2779af","changes":[{"pin":9007197622886883}],"cursor":null,"pageId":"6093d2994082fd001c754546","userId":"5ef2bdebb60650001e1280f0","projectId":"5e2455255664e000177a46fc","freeze":true}}]
unpinする
up425["socket.io-request",{"method":"commit","data":{"kind":"page","parentId":"61441e1ba7e89e001d75d490","changes":[{"pin":0}],"cursor":null,"pageId":"6093d2994082fd001c754546","userId":"5ef2bdebb60650001e1280f0","projectId":"5e2455255664e000177a46fc","freeze":true}}]
ちなみにやろうとしていることはもろにこれに該当する
ここの「ケース2: ブラウザからサーバへの攻撃」
さすがにまずいかなあ
マルウェア仕込むのとほぼやっていることが変わらないんだよねえ
第三者の「攻撃者」がいない以上問題ないのでは
まあ攻撃しているわけではないのでそうですが、過失(userscriptのミス)でscrapbox.ioを攻撃してしまうことは十分に考えられるんですよね……
自前で立ち上げた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
函数っぽいものの使い方がわからないです
開いてsend/closeはできた
メッセージ?を受け取ってその中身をしりたい
とりあえずいらない気がしたので socket = new Websocket(url)
の方でためしてみます
sample.jsfor await (const event of recieve()) {
const data = event.data;
console.log(data);
// ...
}
addEventListener("message", (event) => {})
をfor loopにしたようなイメージ