Flatt Security Developers Quiz 6 Writeup⚡️
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
では、 users
に username
にてキーが含まれているかを確認している。 registerUser
を確認すると users
に admin
キーを作ることができないことが分かる。
よって、Flagを取得する条件として以下に整理できる。
- Go アプリでJSONから読み込んだ
username
は登録されている正規のものである必要がある。 - RubyアプリでJSONから読み込んだ
username
はadmin
になっている必要がある。
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