-
[Clear] LINE CTF 2022 gotm WriteupCTF/LINE CTF 2022 2022. 3. 28. 06:56
주어진 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}
반응형'CTF > LINE CTF 2022' 카테고리의 다른 글
[Study] LINE CTF 2022 bb Writeup (0) 2022.03.29 [Study] LINE CTF 2022 Memo Drive Writeup (0) 2022.03.28