Flatt Security Developers Quiz 6 Writeup⚡️

2024/01/08に公開

2023年年末にFlatt Securityの公式アカウントから出題された、Developer's Quiz 6を解いてみました。
公式のWriteupが公開されていますが、個人的にWriteup作成していたのでZennにて供養したいと思います。

出題ツイート

アプリケーション構成

ソースコード等

レポジトリは以下より。
GitHub

Go Application
    package main
    
    import (
    	"bytes"
    	"io"
    	"net/http"
    	"os"
    
    	"github.com/buger/jsonparser"
    	"github.com/google/uuid"
    	"github.com/labstack/echo/v4"
    	"github.com/labstack/echo/v4/middleware"
    )
    
    var (
    	userSessions = make(map[string]string)
    	legacyHost   = os.Getenv("LEGACY")
    )
    
    func main() {
    	if legacyHost == "" {
    		legacyHost = "localhost:4567"
    	}
    
    	e := echo.New()
    	e.Use(middleware.Logger())
    
    	e.POST("/user", registerUser)
    	e.POST("/result", postResult)
    	e.POST("/vote", postVote)
    	e.GET("/summary", getSummary)
    
    	e.Logger.Fatal(e.Start(":5000"))
    }
    
    func registerUser(c echo.Context) error {
    	data, err := io.ReadAll(c.Request().Body)
    	if err != nil {
    		return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
    	}
    	username, err := jsonparser.GetString(data, "username")
    	if err != nil {
    		return echo.NewHTTPError(http.StatusBadRequest, "username is required")
    	}
    	sessionID, _ := jsonparser.GetString(data, "session_id")
    
    	if username == "" {
    		return echo.NewHTTPError(http.StatusBadRequest, "Username is required")
    	}
    	if username == "admin" {
    		return echo.NewHTTPError(http.StatusBadRequest, "Admin is not allowed")
    	}
    	if prev, exists := userSessions[username]; exists {
    		if sessionID == prev {
    			return c.JSON(http.StatusCreated, echo.Map{
    				"username":   username,
    				"session_id": sessionID,
    			})
    		}
    		return echo.NewHTTPError(http.StatusBadRequest, "Already exists")
    	}
    
    	if sessionID == "" {
    		tmp, _ := uuid.NewRandom()
    		sessionID = tmp.String()
    	}
    	userSessions[username] = sessionID
    
    	return c.JSON(http.StatusCreated, echo.Map{
    		"username":   username,
    		"session_id": sessionID,
    	})
    }
    
    func waf(data []byte) bool {
    	return bytes.Contains(data, []byte("admin"))
    }
    
    func handleSession(c echo.Context, data []byte) error {
    	if waf(data) {
    		return echo.NewHTTPError(http.StatusBadRequest, "Bad text detected")
    	}
    
    	sessionID := c.Request().Header.Get("Authorization")
    	username, err := jsonparser.GetString(data, "username")
    	if err != nil {
    		return echo.NewHTTPError(http.StatusBadRequest, "username is required")
    	}
    
    	if _, exists := userSessions[username]; !exists {
    		return echo.NewHTTPError(http.StatusUnauthorized, "Invalid session")
    	}
    	if userSessions[username] != sessionID {
    		return echo.NewHTTPError(http.StatusUnauthorized, "Invalid session")
    	}
    
    	return nil
    }
    
    func postResult(c echo.Context) error {
    	data, err := io.ReadAll(c.Request().Body)
    	if err != nil {
    		return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
    	}
    
    	if err := handleSession(c, data); err != nil {
    		return err
    	}
    
    	resp, err := http.Post("http://"+legacyHost+"/result", "application/json", bytes.NewBuffer(data))
    	if err != nil {
    		return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
    	}
    	defer resp.Body.Close()
    	body, err := io.ReadAll(resp.Body)
    	if err != nil {
    		return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
    	}
    
    	return c.JSONBlob(resp.StatusCode, body)
    }
    
    func postVote(c echo.Context) error {
    	data, err := io.ReadAll(c.Request().Body)
    	if err != nil {
    		return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
    	}
    
    	if err := handleSession(c, data); err != nil {
    		return err
    	}
    
    	resp, err := http.Post("http://"+legacyHost+"/vote", "application/json", bytes.NewBuffer(data))
    	if err != nil {
    		return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
    	}
    	defer resp.Body.Close()
    	_, err = io.ReadAll(resp.Body)
    	if err != nil {
    		return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
    	}
    
    	return c.NoContent(resp.StatusCode)
    }
    
    func getSummary(c echo.Context) error {
    	resp, err := http.Get("http://" + legacyHost + "/summary")
    	if err != nil {
    		return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
    	}
    	defer resp.Body.Close()
    	body, err := io.ReadAll(resp.Body)
    	if err != nil {
    		return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
    	}
    
    	return c.JSONBlob(resp.StatusCode, body)
    }
Ruby Application
require 'sinatra'
require 'sinatra/json'
require 'json'

candidates = {
  'Dog' => 0,
  'Cat' => 0,
  'Fox' => 0,
  'Giraffe' => 0,
  'Wolf' => 0
}
voting = Mutex.new
users = {}
flag = ENV['FLAG'] || "DUMMY"
users["admin"] = flag # Can you read other's vote?

set :bind, '0.0.0.0'
set :port, 4567

post '/vote' do
  data = JSON.parse(request.body.read)
  username = data['username']
  candidate = data['candidate']

  if username == 'admin'
    status 403
    return json error: "Please don't do this, even if you can"
  end

  unless candidates.has_key?(candidate)
    status 400
    return json error: "Invalid candidate"
  end
  voting.synchronize do
    if users.has_key?(username) && candidates.has_key?(users[username])
      previous_vote = users[username]
      candidates[previous_vote] -= 1
    end

    candidates[candidate] += 1
    users[username] = candidate
  end

  status 200
end

post '/result' do
  d = request.body.read 
  data = JSON.parse(d)
  username = data['username']
  candidate = users[username]

  if candidate
    json candidate: candidate
  else
    status 403
    json error: "User not found"
  end
end

get '/summary' do
  max_votes = candidates.values.max
  leading_candidates = candidates.select { |k, v| v == max_votes }.keys

  json candidate: leading_candidates.sample
end

解法

ユーザー登録

こちらは正規のリクエスト。

/result からFlagを取得

username を2つ含むJSONのペイロードを送信、最初の username には作成したユーザー名を、2つ目の username には admin をunicodeエンコードした値を与える。( Authorization には作成したアカウントの session_id を与える。)
このリクエストを送信することで、Flagを取得できる。

筋道

ゴールの確認

今回Flagは、Rubyアプリの users のadminキーに格納されている。

users["admin"] = flag # Can you read other's vote?

users にアクセスできるエンドポイントは /result で、以下の通り。

post '/result' do
  d = request.body.read 
  data = JSON.parse(d)
  username = data['username']
  candidate = users[username]

  if candidate
    json candidate: candidate
  else
    status 403
    json error: "User not found"
  end
end

よって、GoアプリからRubyアプリに対して "username": "admin" となるようなJSONペイロードを送信すればよい。

Rubyアプリの /result を叩いているGoアプリの該当部分は postResult(echo.Context) error

func postResult(c echo.Context) error {
	data, err := io.ReadAll(c.Request().Body)
	if err != nil {
		return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
	}

	if err := handleSession(c, data); err != nil {
		return err
	}

	resp, err := http.Post("http://"+legacyHost+"/result", "application/json", bytes.NewBuffer(data))
	if err != nil {
		return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
	}
	defer resp.Body.Close()
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
	}

	return c.JSONBlob(resp.StatusCode, body)
}

Sessionの管理は handleSession(echo.Context, []byte)error 。handleSessionを突破できれば /result にアクセス可能。

func handleSession(c echo.Context, data []byte) error {
	if waf(data) {
		return echo.NewHTTPError(http.StatusBadRequest, "Bad text detected")
	}

	sessionID := c.Request().Header.Get("Authorization")
	username, err := jsonparser.GetString(data, "username")
	if err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, "username is required")
	}

	if _, exists := userSessions[username]; !exists {
		return echo.NewHTTPError(http.StatusUnauthorized, "Invalid session")
	}
	if userSessions[username] != sessionID {
		return echo.NewHTTPError(http.StatusUnauthorized, "Invalid session")
	}

	return nil
}

func waf(data []byte) bool {
	return bytes.Contains(data, []byte("admin"))
}

ここでWAFが組まれてしまっており、 admin という文字列を直接入れることができない。しかし、バイト列での比較となっているため、 unicode 変換したペイロードを送信することで解決できる。 ⇒ \u0061\u0064\u006D\u0069\u006E

handleSession では、 usersusername にてキーが含まれているかを確認している。 registerUser を確認すると usersadmin キーを作ることができないことが分かる。

よって、Flagを取得する条件として以下に整理できる。

  • Go アプリでJSONから読み込んだ username は登録されている正規のものである必要がある。
  • RubyアプリでJSONから読み込んだ usernameadmin になっている必要がある。

Go側とRuby側でJSONをParseした結果を変えることは一見不可能に見えるが、 username を複数含んだ場合の挙動に違いがあるのではないかと考えた。

JSONのParse部分は以下の通り。

Go側

	username, err := jsonparser.GetString(data, "username")
	if err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, "username is required")
	}

Ruby側

  data = JSON.parse(request.body.read)
  username = data['username']

2つ同じキーを含んだ場合について調べたところ、以下が判明した。

  • Go側: 1つ目の値を採用する
  • Ruby側: 2つ目の値を採用する

よって、以下のようなPayloadを送信すると、前述の条件を達成でき、Flagを取得できる。

{
	"username":"作成したusername",
	"username":"\u0061\u0064\u006D\u0069\u006E"
}

Discussion