CTF/LINE CTF 2022

[Clear] LINE CTF 2022 gotm Writeup

Vardy 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}

반응형