깜짝놀란 nginx rever proxy의 misconfiguration에 대해서 다루는 머신이라 신선했다. 실제로 이런 케이스를 마주칠 가능성이 크진 않겠지만 아는만큼 보이지 않겠나?

Port Scan

생성한 머신을 대상으로 가장 먼저 포트 스캔을 진행한다. 대상 머신 IP에서 22/tcp 80/tcp 3000/tcp가 확인되며, 22는 SSH, 80, 3000는 웹 서비스로 확인되며 microblog.htb라는 호스트명을 확인하였다.

80/tcpapp.microblog.htb로 서비스중으로 아래와 같이 로그인, 회원가입 기능이 확인된다.

또 메인 페이지 최하단에 공개된 소스코드 저장소 링크가 확인되며 해당 링크는 gitea 레포지토리로 microblog관련 소스코드를 다운로드 할 수 있다.

.
└── microblog
    ├── README.md
    ├── html
    │   └── index.html
    ├── microblog
    │   ├── app
    │   │   ├── brain.ico
    │   │   ├── brain.png
    │   │   ├── dashboard
    │   │   │   └── index.php
    │   │   ├── index.php
    │   │   ├── login
    │   │   │   └── index.php
    │   │   ├── logout
    │   │   │   └── index.php
    │   │   └── register
    │   │       └── index.php
    │   └── sunny
    │       ├── content
    │       │   ├── 2766wxkoacy
    │       │   ├── jtdpx1iea5
    │       │   ├── order.txt
    │       │   ├── rle1v1hnms
    │       │   └── syubx3wiu3e
    │       ├── edit
    │       │   └── index.php
    │       ├── images
    │       │   └── brain.ico
    │       └── index.php
    ├── microblog-template
    │   ├── content
    │   │   └── order.txt
    │   ├── edit
    │   │   └── index.php
    │   ├── images
    │   │   └── brain.ico
    │   └── index.php
    ├── microbucket
    │   ├── css
    │   │   ├── health.txt
    │   │   └── styles.css
    │   └── js
    │       ├── fontawesome.js
    │       ├── health.txt
    │       ├── jquery.js
    │       └── typed.js
    └── pro-files
        └── bulletproof.php

코드를 확인하기 전 웹 서비스 기능을 파악하기위해 회원가입을하고 나만의 블로그를 생성하였다.

생성된 블로그는 juicemon.microblog.htb로 접근가능하며 로그인 세션이 존재할 경우 /edit 페이지에 접근하여 h1과 txt를 수정할 수 있다.

edit 기능에서 Stored XSS가 가능했지만 쓸모있는 취약점은 아니였고, edit 과정에서 전달되는 HTTP Request를 확인해보니 POST Data로 id와 txt가 전달된다.

다운로드한 소스코드를 통해 해당 POST 요청 어떤식으로 처리하는지 확인해보니 유저가 생성한 블로그의 소스코드는 microblog-template 인것으로 확인됐으며, 해당 요청은 mircoblog-template/edit/index.php의 80번째 라인인 아래 코드를 통해 처리된다.

id로 전달받은 파라미터의 파일을 열어 특정 HTML과 함께 포함되는 txt 파라미터의 데이터를 쓰고 order.txt 파일에 전달받은 id 값을 작성한다.

//add text
if (isset($_POST['txt']) && isset($_POST['id'])) {
    chdir(getcwd() . "/../content");
    $txt_nl = nl2br($_POST['txt']);
    $html = "<div class = \"blog-text\">{$txt_nl}</div>";
    $post_file = fopen("{$_POST['id']}", "w");
    fwrite($post_file, $html);
    fclose($post_file);
    $order_file = fopen("order.txt", "a");
    fwrite($order_file, $_POST['id'] . "\n");  
    fclose($order_file);
    header("Location: /edit?message=Section added!&status=success");
}

아무튼 여기서 LFI의 냄새를 맡고 그렇다면 블로그의 페이지들의 콘텐츠는 어떤 파일에서 전달되는것인지 확인해보니 fetchPage() 함수를 통해 반환되는 데이터를 json 인코딩하여 html에 삽입한다.

// microblog-template/index.php의 52번 라인 -->
const html = <?php echo json_encode(fetchPage()); ?>.replace(/(\r\n|\n|\r)/gm, "");
$(".push-for-h1").after(html);

fetchPage() 함수의 내용은 아래와 같이 확인할 수 있으며 WEBROOT/content/order.txt 파일을 라인바이라인으로 읽어서 HTML 형태로 반환한다.

결과적으로 위에서 txt edit요청에서 id 파라미터를 통해 LFI가 가능하다.

function fetchPage() {
    chdir(getcwd() . "/content");
    $order = file("order.txt", FILE_IGNORE_NEW_LINES);
    $html_content = "";
    foreach($order as $line) {
        $temp = $html_content;
        $html_content = $temp . "<div class = \"{$line}\">" . file_get_contents($line) . "</div>";
    }
    return $html_content;
}

개행 문자가 이스케이프되어 보기 킹받지만 ../../../../../etc/passwd를 id 파라미터에 전달하여 LFI가 가능했다.

핵더박스 머신 제작자들은 LFI를 정말 좋아하는것같다...

어찌됐건 LFI로 시스템 파일 정찰을 시작했고 nginx 설정파일 중 /etc/nginx/sites-enabled/default 파일을 확인할 수 있었고 그중 일부는 다음과 같다.

80/tcp로 서비스되는 microblog.htb는 리버스 프록시로 구성되어있음을 확인할 수 있다.

server { 
	listen 80; 
	listen [::]:80; 
	root /var/www/microblog/app; 
	index index.html index.htm index-nginx-debian.html; 
	server_name microblog.htb; 
	location / { 
		return 404; 
	} 
	location = /static/css/health/ { 
		resolver 127.0.0.1; 
		proxy_pass http://css.microbucket.htb/health.txt; 
	} 
	location = /static/js/health/ { 
		resolver 127.0.0.1; 
		proxy_pass http://js.microbucket.htb/health.txt; 
	} 
	location ~ /static/(.*)/(.*) { 
		resolver 127.0.0.1; 
		proxy_pass http://$1.microbucket.htb/$2; 
	}
}

해당 설정이 정상적으로 동작하는지 확인을 위해 css.microbucket.htb, js.microbucket.htb 의 소스코드도 존재하지는 다운받은 소스코드에서 찾아보니 microbucket/ 경로에 존재했고 설정파일에서 확인되는 health.txt 파일도 각각 존재하였다.

.
├── css
│   ├── health.txt
│   └── styles.css
└── js
    ├── fontawesome.js
    ├── health.txt
    ├── jquery.js
    └── typed.js

3 directories, 6 files

결과적으로 리버스 프록시 설정파일을 해석하면 http://microblog.htb/static/css/health/, http://microblog.htb/static/js/health/ 에 접근하면 각 health.txt 파일을 읽을 수 있다.

위 내용은 리버스 프록시가 정상적으로 동작하고있는지 확인을 위해 진행했으며, 처음 알게된 nginx 리버스 프록시의 misconfiguration은 3번째 프록시 설정이다.

/static/(.*)/(.*) 경로로 접근 시 첫번째 정규식에 매치되는 값($1)과 두번째 정규식에 매치되는 값($2)를 http://$1.microbucket.htb/$2 형태로 프록시한다.

여기서 nginx 리버스 프록시를 사용하여 소켓을 연결할때 아래와 같이 설정될 수 있는 점을 이용한다.

proxy_pass http://unix:/socket/path.sock:/uri

그렇다면 어떤 소켓 파일에 접근할 수 있을까?

소스코드에서 자주 확인되는 redis socket 파일에 접근할 수 있다.

// redis socket 파일을 사용하여 Redis와 통신하는 소스코드 일부
function getFirstName() {
    if(isset($_SESSION['username'])) {
        $redis = new Redis();
        $redis->connect('/var/run/redis/redis.sock');
        $firstName = $redis->HGET($_SESSION['username'], "first-name");
        return "\"" . ucfirst(strval($firstName)) . "\"";
    }
}

app.microblog.htb에는 pro모드가 존재하는데 redis에서 HGET을 통해 username 세션값을 key로해서 pro 필드의 값을 가져와서 pro인지 여부를 판단한다.

내가 생성한 계정이 프로가 되기위해 redis.sock을 통해 pro 필드를 변경해줘야한다.

function isPro() {
    if(isset($_SESSION['username'])) {
        $redis = new Redis();
        $redis->connect('/var/run/redis/redis.sock');
        $pro = $redis->HGET($_SESSION['username'], "pro");
        return strval($pro);
    }
    return "false";
}

위 내용을 기반으로 redis.sock에 접근하려면 http://microblog.htb/static/unix:%2fvar%2frun%2fredis%2fredis.sock:TEST/uri에 접근해서 로컬에서 동작하는 Redis에 접근할 수 있다(SSRF)

그치만 뭔가 정상적이지 응답이 온다. 하지만 일반적인 경로에 curl을 날렸을때 404가 오지만 502가 오는것은 무엇인가 요청이 잘못됐다고 판단된다.

좀더 확인해보니 proxy_pass로 전달되는 모습은 아래와 같이 예상된다.

http://unix:/var/run/redis/redis.sock:TEST.microbucket.htb/uri

이때 redis.sock에 요청받는 데이터는 아래와 같이 예상된다.

GET TEST.microbucket.htb/uri HTTP/1.0
Host: localhost
Connection: close

궁극적 목적은 현재 로그인한 juicemon 계정의 세션을 키로 pro 필드의 값을 true로 변경해줘야되는데, 위에서 확인된 HGET의 반대인 HSET을 이용해야한다.

이는 HTTP Method를 통해 전달할 수 있다.

curl -X HSET http://microblog.htb/static/unix:%2fvar%2frun%2fredis%2fredis.sock:juicemon%20pro%20true%20/uri

[참고][Middleware, middleware everywhere - and lots of misconfigurations to fix](https://labs.detectify.com/2021/02/18/middleware-middleware-everywhere-and-lots-of-misconfigurations-to-fix/)

왼쪽 상단에서 볼 수 있는것처럼 프로 모드가 되었다!

소스코드에서 확인했지만 프로모드가되면 이미지 블로그에 이미지 업로드 기능을 사용할 수 있다.

소스코드를 볼수있고 이미지 업로드 기능을 지원하는것을 파악했으면서 왜 먼저 IDOR을 테스트하지않았는지는 아래와 같이 프로모드가 아니면 생성한 블로그의 웹루트에 uploads 디렉터리가 존재하지도 않고 권한도 없어 업로드를 할 수 없다.

// microblog-template/edit/index.php

function provisionProUser() {
    if(isPro() === "true") {
        $blogName = trim(urldecode(getBlogName()));
        system("chmod +w /var/www/microblog/" . $blogName);
        system("chmod +w /var/www/microblog/" . $blogName . "/edit");
        system("cp /var/www/pro-files/bulletproof.php /var/www/microblog/" . $blogName . "/edit/");
        system("mkdir /var/www/microblog/" . $blogName . "/uploads && chmod 700 /var/www/microblog/" . $blogName . "/uploads");
        system("chmod -w /var/www/microblog/" . $blogName . "/edit && chmod -w /var/www/microblog/" . $blogName);
    }
    return;
}

프로모드가 되었으니 이미지 업로드가 아닌 php 파일을 업로드하여 웹쉘을 띄우려했으나 확장자 검증을 하고있는것을 소스코드에서 확인할 수 있다.

//add image
if (isset($_FILES['image']) && isset($_POST['id'])) {
    if(isPro() === "false") {
        print_r("Pro subscription required to upload images");
        header("Location: /edit?message=Pro subscription required&status=fail");
        exit();
    }
    $image = new Bulletproof\Image($_FILES);
    $image->setLocation(getcwd() . "/../uploads");
    $image->setSize(100, 3000000);
    $image->setMime(array('png'));

    if($image["image"]) {
        $upload = $image->upload();

        if($upload) {
            $upload_path = "/uploads/" . $upload->getName() . ".png";
            $html = "<div class = \"blog-image\"><img src = \"{$upload_path}\" /></div>";
            chdir(getcwd() . "/../content");
            $post_file = fopen("{$_POST['id']}", "w");
            fwrite($post_file, $html);
            fclose($post_file);
            $order_file = fopen("order.txt", "a");
            fwrite($order_file, $_POST['id'] . "\n");  
            fclose($order_file);
            header("Location: /edit?message=Image uploaded successfully&status=success");
        }
        else {
            header("Location: /edit?message=Image upload failed&status=fail");
        }
    }
}

그렇다면 이전에 LFI를 찾았던 add text기능을 하는 코드를 이용하여 특정 경로에 코드를 작성할 수 있다.

LFI는 order.txt에 기록되는 id 파라미터 값으로 인해 발생하지만, 원래 로직이면 난수의값을 가진 파일에 블로그 내 삽입될 텍스트를 Write하고 읽어온다.

여기서 id 파라미터에 웹쉘 기능을 하는 /var/www/microblog/juicemon/uploads/shell.php를 생성한다.

지체할것없이 바로 리버스 커넥션을 맺어 www-data 계정의 쉘을 획득한다.

쉘을 통해 열린 포트 목록을 확인하고 이래저래 확인해도 redis관련 포트는 없었다.

그렇기에 reverse porxy에서 사용한 /var/run/redis/redis.sock 소켓을 이용해서 Redis에 저장된 데이터를 파악한다.

echo "keys *" | redis-cli -s /var/run/redis/redis.sock
echo "type cooper.dooper" | redis-cli -s /var/run/redis/redis.sock
echo "hgetall cooper.dooper" | redis-cli -s /var/run/redis/redis.sock

/etc/passwd에서 확인됐던 cooper 계정의 비밀번호로 추정되는 텍스트를 얻어 cooper계정으로 전환을 시도하니 비밀번호가 맞아서 로그인이 가능했다.

습관적으로 sudo 권한을 체크하니 /usr/bin/license 파일을 실행할 수 있었고 해당 파일은 파이썬 스크립트였다.

해당 파이썬 스크립트는 프로모드에 대한 라이센스와 관련된 스크립트로 파악되었다.

usage: license [-h] (-p username | -d username | -c license_key)

Microblog license key manager

optional arguments:
  -h, --help            show this help message and exit
  -p username, --provision username
                        Provision license key for specified user
  -d username, --deprovision username
                        Deprovision license key for specified user
  -c license_key, --check license_key
                        Check if specified license key is valid
#!/usr/bin/python3

import base64
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.fernet import Fernet
import random
import string
from datetime import date
import redis
import argparse
import os
import sys

class License():
    def __init__(self):
        chars = string.ascii_letters + string.digits + string.punctuation
        self.license = ''.join(random.choice(chars) for i in range(40))
        self.created = date.today()

if os.geteuid() != 0:
    print("")
    print("Microblog license key manager can only be run as root")
    print("")
    sys.exit()

parser = argparse.ArgumentParser(description='Microblog license key manager')
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument('-p', '--provision', help='Provision license key for specified user', metavar='username')
group.add_argument('-d', '--deprovision', help='Deprovision license key for specified user', metavar='username')
group.add_argument('-c', '--check', help='Check if specified license key is valid', metavar='license_key')
args = parser.parse_args()

r = redis.Redis(unix_socket_path='/var/run/redis/redis.sock')

secret = [line.strip() for line in open("/root/license/secret")][0]
secret_encoded = secret.encode()
salt = b'microblogsalt123'
kdf = PBKDF2HMAC(algorithm=hashes.SHA256(),length=32,salt=salt,iterations=100000,backend=default_backend())
encryption_key = base64.urlsafe_b64encode(kdf.derive(secret_encoded))

f = Fernet(encryption_key)
l = License()

#provision
if(args.provision):
    user_profile = r.hgetall(args.provision)
    if not user_profile:
        print("")
        print("User does not exist. Please provide valid username.")
        print("")
        sys.exit()
    existing_keys = open("/root/license/keys", "r")
    all_keys = existing_keys.readlines()
    for user_key in all_keys:
        if(user_key.split(":")[0] == args.provision):
            print("")
            print("License key has already been provisioned for this user")
            print("")
            sys.exit()
    prefix = "microblog"
    username = r.hget(args.provision, "username").decode()
    firstlast = r.hget(args.provision, "first-name").decode() + r.hget(args.provision, "last-name").decode()
    license_key = (prefix + username + "{license.license}" + firstlast).format(license=l)
    print("")
    print("Plaintext license key:")
    print("------------------------------------------------------")
    print(license_key)
    print("")
    license_key_encoded = license_key.encode()
    license_key_encrypted = f.encrypt(license_key_encoded)
    print("Encrypted license key (distribute to customer):")
    print("------------------------------------------------------")
    print(license_key_encrypted.decode())
    print("")
    with open("/root/license/keys", "a") as license_keys_file:
        license_keys_file.write(args.provision + ":" + license_key_encrypted.decode() + "\n")

#deprovision
if(args.deprovision):
    print("")
    print("License key deprovisioning coming soon")
    print("")
    sys.exit()

#check
if(args.check):
    print("")
    try:
        license_key_decrypted = f.decrypt(args.check.encode())
        print("License key valid! Decrypted value:")
        print("------------------------------------------------------")
        print(license_key_decrypted.decode())
    except:
        print("License key invalid")
    print("")

여기서 해당 스크립트의 --provision 옵션을 처리하는 코드에서 Format String 취약점을 확인할 수 있다.

username = r.hget(args.provision, "username").decode()
firstlast = r.hget(args.provision, "first-name").decode() + r.hget(args.provision, "last-name").decode()
license_key = (prefix + username + "{license.license}" + firstlast).format(license=l)

Format String 취약점을 이해하기위해 다음과 같은 코드를 통해 취약점에 접근하는 방법을 익혀본다.

먼저 해당 코드는 config라는 글로벌 변수에 API_KEY를 선언해두고있다. 이후 인자로 전달받은 첫번째 인자를 print의 formater로 사용하고 두번째 인자를 포맷에 전달되는 값으로 사용한다.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import sys

config = {
    'API_KEY' : "212817d980b9a03add91e5814d02"
}

class API(object):
    def __init__(self, apikey):
        self.apikey = apikey

    def renderHTML(self, templateHTML, title, text):
        return (templateHTML.format(self=self, title=title, text=text))


if __name__ == '__main__':
    if len(sys.argv) != 3:
        print("Usage : python3 "+sys.argv[0]+" TEMPLATE CONTENT")
    else :
        a = API(config['API_KEY'])
        print(a.renderHTML(sys.argv[1], "Vuln web render App", sys.argv[2]))
python3 format.py "<p>{text}</p>" "Wow such string"
<p>Wow such string</p>

결과적으로 위에서 print함수가 사용되는 부분을 풀어서 확인해보면 print("<p>{text}</p>.format("Wow such string")) 처럼 확인할 수 있다.

여기서 Format String 취약점을 테스트하기위해 첫번째 인자로 전달되는 formater를 아래와 같이 전달하여 실행해보면서 취약점의 결을 확인했다.

다시 cooper 계정으로 돌아와 Format String 취약점을 진행하고자 Redis에서 juicemon 계정의 username 값을 {license.__init__.__globals__}로 변경하였다.

이후 /usr/bin/license에 juicemon 계정의 라이센스를 확인하는 옵션을 전달하여 확인 결과 글로벌 변수 중 secret을 확인할 수 있었다.

확인해보니... 저 secret 값은 root 패스워드였다...

profile
블로그 이사 (https://juicemon-code.github.io/)

0개의 댓글