[Clear] LINE CTF 2022 gotm Writeup
주어진 URL에 접근해보면 당장에는 빈 페이지로 보이지만, 주어진 소스를 보면 go언어로 만들어져있는 사이트이며, 유효한 경로와 해당 기능이 어떤 핸들러에 매핑되어 동작하는지 확인 할 수 있다.
// (3/3) of full source
func main() {
admin := Account{admin_id, admin_pw, true, secret_key}
acc = append(acc, admin)
http.HandleFunc("/", root_handler)
http.HandleFunc("/auth", auth_handler)
http.HandleFunc("/flag", flag_handler)
http.HandleFunc("/regist", regist_handler)
log.Fatal(http.ListenAndServe("0.0.0.0:11000", nil))
}
각각 기능을 살펴보면,
// (2/3) of full source
func auth_handler(w http.ResponseWriter, r *http.Request) {
uid := r.FormValue("id")
upw := r.FormValue("pw")
if uid == "" || upw == "" {
return
}
if len(acc) > 1024 {
clear_account()
}
user_acc := get_account(uid)
if user_acc.id != "" && user_acc.pw == upw {
token, err := jwt_encode(user_acc.id, user_acc.is_admin)
if err != nil {
return
}
p := TokenResp{true, token}
res, err := json.Marshal(p)
if err != nil {
}
w.Write(res)
return
}
w.WriteHeader(http.StatusForbidden)
return
}
func regist_handler(w http.ResponseWriter, r *http.Request) {
uid := r.FormValue("id")
upw := r.FormValue("pw")
if uid == "" || upw == "" {
return
}
if get_account(uid).id != "" {
w.WriteHeader(http.StatusForbidden)
return
}
if len(acc) > 4 {
clear_account()
}
new_acc := Account{uid, upw, false, secret_key}
acc = append(acc, new_acc)
p := Resp{true, ""}
res, err := json.Marshal(p)
if err != nil {
}
w.Write(res)
return
}
func flag_handler(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("X-Token")
if token != "" {
id, is_admin := jwt_decode(token)
if is_admin == true {
p := Resp{true, "Hi " + id + ", flag is " + flag}
res, err := json.Marshal(p)
if err != nil {
}
w.Write(res)
return
} else {
w.WriteHeader(http.StatusForbidden)
return
}
}
}
func root_handler(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("X-Token")
if token != "" {
id, _ := jwt_decode(token)
acc := get_account(id)
tpl, err := template.New("").Parse("Logged in as " + acc.id) ##### ...... (1)
if err != nil {
}
tpl.Execute(w, &acc)
} else {
return
}
}
1. / <--> root_handler
- X-Token 이라는 헤더에서 토큰을 읽어와 jwt디코딩을 하여 유효한 경우 해당 계정의 id를 출력해준다.
2. /regist <--> regist_handler
- id,pw 파라미터로부터 계정 정보를 읽어서 Account 구조체 형식에 맞게 계정정보 리스트에 등록한다.
3. /auth <--> auth_handler
- 사용자가 입력한 id,pw 값이 유효하다면 id와 is_admin 정보가 포함된 jwt토큰을 발급한다.
(해당 토큰이 root, flag 핸들러에 활용된다.)
4. /flag <--> flag_handler
- X-Token 헤더에서 읽어온 jwt 토큰 값을 디코딩하여 is_admin의 값이 True라면 플래그를 출력해준다.
그 외 소스는 아래와 같다. (전체 소스코드는 아래 소스코드를 시작으로 역순이다.)
// (1/3) of full source
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"text/template"
"github.com/golang-jwt/jwt"
)
type Account struct {
id string
pw string
is_admin bool
secret_key string ##### .................................................. (2)
}
type AccountClaims struct {
Id string `json:"id"`
Is_admin bool `json:"is_admin"`
jwt.StandardClaims
}
type Resp struct {
Status bool `json:"status"`
Msg string `json:"msg"`
}
type TokenResp struct {
Status bool `json:"status"`
Token string `json:"token"`
}
var acc []Account
var secret_key = os.Getenv("KEY")
var flag = os.Getenv("FLAG")
var admin_id = os.Getenv("ADMIN_ID")
var admin_pw = os.Getenv("ADMIN_PW")
func clear_account() {
acc = acc[:1]
}
func get_account(uid string) Account {
for i := range acc {
if acc[i].id == uid {
return acc[i]
}
}
return Account{}
}
func jwt_encode(id string, is_admin bool) (string, error) {
claims := AccountClaims{
id, is_admin, jwt.StandardClaims{},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) ##### ........... (3)
return token.SignedString([]byte(secret_key))
}
func jwt_decode(s string) (string, bool) {
token, err := jwt.ParseWithClaims(s, &AccountClaims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(secret_key), nil
})
if err != nil {
fmt.Println(err)
return "", false
}
if claims, ok := token.Claims.(*AccountClaims); ok && token.Valid {
return claims.Id, claims.Is_admin
}
return "", false
}
기타 소스에서 참고할만한 점은
(2)에서 account 구조체에 jwt 토큰을 encode/decode 하는데 필요한 secret_key 값이 굳이 포함되어있다는 점과
(3)에서 jwt토큰에 HS256 알고리즘을 사용하여 하나의 키(secret_key)만 얻으면 토큰의 정보를 조작 할 수 있다는 점이다.(HS256 알고리즘을 사용하는 경우 이론상 BruteForce 공격이 가능하긴 하지만 키의 길이가 매우 짧지 않은 경우가 아니면 오랜 시간이 필요하므로 풀이 방법에서 제외시켰다.)
소스코드를 분석하여 얻은 위 정보들을 미루어보았을 때,
플래그를 획득할 유일한 방법은
1. secret_key leak -> 2. 획득한 secret_key를 통해 is_admin 값이 True 인 jwt 토큰 생성 -> 생성한 토큰을 통해 플래그 획득
이라고 생각했다.
그래서 secret_key를 어떻게 얻을 수 있을까?
확실한건 발급되는 토큰 외 우리가 조작 가능한 값 혹은 서버에서 우리에게 정보를 주는 유일한 내용은 root_handler의 id 값이었다. 그래서 해당 부분을 다시 한번 천천히 분석해보았고,
(1) 과 같이 페이지를 출력해주는 과정에서 "Template" 을 사용 한다는것이 의심스러워졌다.
go언어가 아직 나에게 익숙한 언어는 아니지만 go 언에에도 SSTI가 가능할지도 모른다는 생각을 했고 이에 대해 공부해보았다.
http://blog.takemyhand.xyz/2020/05/ssti-breaking-gos-template-engine-to.html
[SSTI] Exploiting Go's template engine to get XSS
This vulnerability is an edge case of user data being passed directly into the template, and the exploit is a result of not using the int...
blog.takemyhand.xyz
우리가 평소에 SSTI 공격의 유효성을 판단할때 예시로 {{7*7}}을 사용한다. 하지만 go 언어에서 SSTI 유효성을 확인하려면 {{*}} 구문을 활용해야 하고, 유효할 경우 data struct 가 포함된 정보를 출력해준다고 한다.
즉, 이 문제의 케이스에서 SSTI가 유효할 경우 injection 포인트가 계정의 id이기 때문에 해당 구조체의 정보가 노출될 것이고 거기에는 소스코드의 (2) 에서 확인 했듯 secret_key가 포함되어 있을 것이다.
해당 시나리오가 유효한지 확인해보자.
1. id 값을 {{.}}로 하여 regist
2. 해당 계정의 jwt 토큰 확인
3. 로그인(root_hander 접근)
우리의 추측대로 해당 계정의 구조체 정보가 노출되어 secret_key 값을 획득 할 수 있었다.
아래와 같이 획득한 secret_key를 활용하여 is_admin 값이 True인 토큰을 발급 할 수 있었고
import jwt
encoded_jwt = jwt.encode({"id": "admin","is_admin":True}, "fasdf972u1031xu90zm10Av", algorithm="HS256")
print(encoded_jwt)
이를 활용하여 플래그를 획득 할 수 있었다.
FLAG = LINECTF{country_roads_takes_me_home}