RESP(Redisプロトコル)を話す in Golang.
概要
ひょんなことをキッカケにGo言語で簡易的なRedisのサーバを書いたので簡単にまとめようと思う。
https://github.com/0n1shi/beehive-redis
動作としては以下のように普通のRedisサーバとなんら変わりなく、redis-cli
からも接続することができる(コマンド数は極端に少ないが)。
# terminal 1 $ beehive-redis run --config conf.yaml 2022/02/20 09:17:27 starting Beehive Redis server ...
# terminal 2 $ redis-cli 127.0.0.1:6379> keys * (empty array) 127.0.0.1:6379> set msg "hello world" OK 127.0.0.1:6379> get msg "hello world"
背景
社会人になりたての頃、趣味で運用していたハニーポットだったが、低レイヤーの方へ興味を持ったタイミングに閉じた以来それっきりとなった。それから数年が経ち最近の転職を機にハニーポットへのモチベーションが再燃し、運用を再開したいと考えるようになった今日この頃だったが、しかしなんせ今は状況が変わってサーバを湯水のように利用できる環境ではなくなっている。以前はT-Potを16GBのメモリを載っけたVMで稼働さており潤沢に許されたリソースを余すことなく享受していたが、今は子も二人目が産まれ握りしめた千円札で何とか借りれるVPSをググる始末。
野口英世1枚で巨大なOSSが動かせるはずもなく、泣く泣くT-Potを諦めKagoyaでほぼワンコインな2Core&2GBの(コスパ最強)VPSを借りた。これで何ができるのかと考えた時に閃いたのは自作の軽量ハニーポットで、真っ先に思い付いたのが以前訳あって一瞬外部に公開した際に香ばしい文字列が格納されていたRedisだった。
RESP
そうとなればと早々にRedisプロトコルを調べると、なんと驚くほどヒューマンリーダブルである。
例えばよく利用するKEYS
コマンド。
127.0.0.1:6379> keys * 1) "msg1" 2) "msg2"
これはクライアントから以下のフォーマットで送信され
*2 $4 keys $1 *
レスポンスは以下。
*2 $4 msg2 $4 msg1
各行はCRLF(\r\n)
で改行される。
上記を見ても何となくわかる通り、*n
は配列を表現し、そのn
が要素数、$n
は文字列を表現しており、n
は文字列長、その後に実際の文字列が続く。
初見でも勘の良い人であれば何となく想像が付いてしまうほどシンプルである。
RedisのプロトコルはRESP (REdis Serialization Protocol)
と呼ばれ、Redisのためにデザインされており
- 実装の容易性
- パースの高速性
- ヒューマンリーダブル`
の3つを軸に策定されているよう。
(以下原文)
RESP is a compromise between the following things: - Simple to implement. - Fast to parse. - Human readable.
データ型
RESPには以下の5つのデータ型が定義されている。
- Simple Strings
- Errors
- Integers
- Bulk Strings
- Arrays
Simple strings
例えばSET
コマンドのレスポンスとして表示される
# e.g. 127.0.0.1:6379> set msg hello OK
"OK"
の文字列などがSimple strings
としてハンドリングされており、実際には+OK\r\n
として、+
から始まるデータがサーバから返される。
Errors
存在しないコマンドを送信した際などに返ってくる以下のようなメッセージは Errors
として返されており
# e.g. 127.0.0.1:6379> NONEXISTENT (error) ERR unknown command `NONEXISTENT`, with args beginning with:\r\n
実際のデータは
-ERR unknown command `NONEXISTENT`, with args beginning with:
上記のように-
から始まる。
Intergers
数値データは :
から始まり、Redisに実装されたコマンド数を返す
127.0.0.1:6379> COMMAND COUNT (integer) 224
上記のコマンドでは実際に:224\r\n
というデータがサーバから返される。
Bulk strings
INFO
コマンドのレスポンスなどはBulk strings
として扱われ
127.0.0.1:6379> info # Server redis_version:6.2.6 redis_git_sha1:00000000 redis_git_dirty:0 redis_build_id:b61f37314a089f19 redis_mode:standalone os:Linux 5.10.76-linuxkit x86_64 arch_bits:64 multiplexing_api:epoll atomicvar_api:atomic-builtin gcc_version:10.2.1 process_id:1 process_supervised:no :
上記は実際に以下のような$[文字列長]
から始まるデータとしてサーバから送信されてくる。
$4158 # Server redis_version:6.2.6 redis_git_sha1:00000000 redis_git_dirty:0 redis_build_id:b61f37314a089f19 redis_mode:standalone os:Linux 5.10.76-linuxkit x86_64 arch_bits:64 multiplexing_api:epoll atomicvar_api:atomic-builtin gcc_version:10.2.1 process_id:1 process_supervised:no :
Arrays型
クライアントコマンドは全て前述のようなArraysとして送信され、例えば
127.0.0.1:6379> set msg hello
とコマンドを送信した場合
*3 // 要素数3の配列 $3 // 長さ3の文字列が続く set // [文字列] $3 // 長さ3の文字列が続く msg // [文字列] $5 // 長さ5の文字列が続く hello // [文字列]
Bulk stringsのArraysとして上記のようなデータをサーバは受け取り、1つ目の文字列をコマンド、それ以降を引数と解釈することができる。
ちなみにIntergersのArraysは以下のようになる。
*3 :16 :2 :1024
実装
ここまで理解すれば実装は容易で、まず最初に基本的なTCPサーバを書き、クライアントコマンドのパーサとレスポンスのビルダさえあれば十分Redisサーバとして動作する。加えてクライアントから送信されたコマンドデータの保存を行えばハニーポットとしても十分に役割を果たすことができる。
まずクライアントのコマンドは以下のような構造体に格納する。
type Command struct { Length int Cmd string Args []string IP string Implemented bool }
Implemented
は自作のRedisサーバに当該コマンドが実装されているか否かを保持し、定期的に未実装コマンドを減らしていくことを目的に設けた。
データの保存部分はInterface
として定義し
type Repository interface { Save(*Command) error }
とりあえずMySQLに保存できる実装レイヤを用意した。
メインの処理は以下のようになっており
for { // コマンドのパース cmd, err := getCmd(conn) if err != nil { log.Println(err) break } // 送信されたコマンドデータの保存 log.Printf("received command \"%s\" from %s", cmd.ToString(), cmd.IP) if err := repo.Save(cmd); err != nil { log.Println(err) break } // レスポンスの作成及び送信 res := makeResStr(cmd) if _, err := io.WriteString(conn, res); err != nil { log.Println(err) break } }
コマンドのパース、受け取ったデータの保存、レスポンスの作成及び送信といった流れになる。
最後に
今回書いたハニーポットとしてのRedisサーバ実際にKagoyaのVPS上で稼働しており、香ばしいペイロードも眺めることができる。
一応収集したデータは以下で公開しているので興味があれば是非に(ペイロードに含まれるURLの中には生きているホストもありそうなので悪用は厳禁です🙅♂️)。