Tallman

技術とか読書とかいろいろ

ブログをGithub Pagesに引っ越します

みなさまあけましておめでとうございます! 色々考えてはてなブログから引っ越すことにしました! 新しいブログはこちら!

qwyng.dev

考えたことは↓の記事で書いてます

はてなブログからHugo + GitHub Pagesに引っ越した - QWYNG.dev

読者になってくれたりスターくれたりしてくれた方々ありがとうございました。
記事は全て新しいブログにインポートしましたが一応このサイトは残しておきます。RSSも用意してるので良かったら購読してください!

2021年の振り返り

年初に引っ越して東京を出ました。今までワンルーム5畳に190cmの人間が存在する限界状態だったので広いところに引っ越しました。家の中で歩けると便利。
4月は情報処理安全確保支援士試験合格したのと大学院に入学しました。最低学費になる3年間の間に卒業したいところ。
残りの八ヶ月はレポートしたり授業受けたりしてました。2/4期分の成績がでていて11単位です。卒業に32単位必要なのでやっていき。
C使った並列処理の実装とか面白い授業が結構ありました。CPUがキャッシュミスするか?とかパイプラインレジスタってなんであるの?とか考えるのは大学院っぽくてよかったです。 勿論いいことだけじゃなくて、大学のスパコンに340GB以上のテキストファイル生成しちゃって怒られが発生したり単位落としたり色々ありました。
正直今年は去年と比べてアウトプットが少なかったし来年はもうちょい頑張りたいところですね。あとはてなブログが最近宣伝の仕方が自分の嫌いな感じになってきたのでちゃんと自分でドメイン買って運用しようかなと思います。qwy.ngのための.ngドメインかqwyn.ggのためのggドメインどっちかほしいんですけどどっちも普通に高くて困ってます。
来年も人生をやっていきですな〜〜〜〜〜〜〜〜〜〜〜〜〜〜!

stashを検索するGitHub CLI Extensionを書いた

一年前stashを検索するCLIツールをRustで書いたのだが、正直実用に耐えるものではなかったのでどこかで作り直したいと思っていた。そんなことを考えているうちに世の中にGitHub CLI Extensionという概念ができていたので、ちょうどいいと思って書いた。

github.com

f:id:sasa5740:20211024112645g:plain

基本 git stash list -G<regexp>をfzfにバインドしてpreviewgit stash show -pしているだけなのだが、grepと違ってgit stash list -G は空文字のクエリを投げると何にもマッチせず出力しないという挙動をするため、起動時のクエリなしの時に表示するリストをいい感じにしたり、クエリを入力したけどマッチしてない時とクエリを入力したのにマッチした時2つのケースをサポートしたりするのがまぁまぁ面倒だった。

previewではgit stash show -p -G<query>しているのだけど、マッチした文字が含まれるファイルが表示されるという方式なので行数が多いファイルだとあんまりgrepしている感じがでない、ハイライトしたりいい感じにpreviewする方法はまだありそう。 GitHub CLIから簡単にインストールできるのでぜひ使ってみてください。

SECCON Beginners CTF 2021 writeup

@hyper0dietterさんに誘われてSECCON Beginners CTF 2021に参加してきました。 チームの最終順位は1033点で157位です。一時は80位になって二人で興奮してました。

自分は主にWebやってました。最後のmagicはあんまり時間もなくて通せずじまい。 @hyper0dietterさんはcryptoでなんがすごい数学使っててすごかった(小並)。

Web

check_url

URLをPOSTするとそこにcurlしてくれるアプリ。

<!-- HTML Template -->
          <?php
            error_reporting(0);
            if ($_SERVER["REMOTE_ADDR"] === "127.0.0.1"){
              echo "Hi, Admin or SSSSRFer<br>";
              echo "********************FLAG********************";
            }else{
              echo "Here, take this<br>";
              $url = $_GET["url"];
              if ($url !== "https://www.example.com"){
                $url = preg_replace("/[^a-zA-Z0-9\/:]+/u", "👻", $url); //Super sanitizing
              }
              if(stripos($url,"localhost") !== false || stripos($url,"apache") !== false){
                die("do not hack me!");
              }
              echo "URL: ".$url."<br>";
              $ch = curl_init();
              curl_setopt($ch, CURLOPT_URL, $url);
              curl_setopt($ch, CURLOPT_CONNECTTIMEOUT_MS, 2000);
              curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
              echo "<iframe srcdoc='";
              curl_exec($ch);
              echo "' width='750' height='500'></iframe>";
              curl_close($ch);
            }
          ?>
<!-- HTML Template -->

localhostcurlさせられれば良いですね、しかし単純にlocalhost指定すると弾かれてしまいます。
curl 127.0.0.1正規表現の置換により .が置換されてしまいます。最初は :が置換除外されているのもありipv6で指定すれば良いのかと思いましたが、curlipv6アドレスそのまま渡すには
curl [0:0:0:0:0:ffff:7f00:0001] のようにアドレスをくくる必要があるらしく、[が置換されてしまい駄目でした。
最終的にipv4アドレスを. 無しにすれば良いと気づいて16進数にしたら通りました。0x7F000001をフォームからPOSTすればOK。

json

社内ネットワークからしか情報閲覧できない(らしい)社内アプリケーション。 bffアプリとapiアプリで別れてます。nginxに直接つながっているのはbffのみ。

// bff/main.go
package main

import (
    "bytes"
    "encoding/json"
    "io/ioutil"
    "net"
    "net/http"

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

type Info struct {
    ID int `json:"id" binding:"required"`
}

// check if the accessed user is in the local network (192.168.111.0/24)
func checkLocal() gin.HandlerFunc {
    return func(c *gin.Context) {
        clientIP := c.ClientIP()
        ip := net.ParseIP(clientIP).To4()
        if ip[0] != byte(192) || ip[1] != byte(168) || ip[2] != byte(111) {
            c.HTML(200, "error.tmpl", gin.H{
                "ip": clientIP,
            })
            c.Abort()
            return
        }
    }
}

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

    r.GET("/", func(c *gin.Context) {
        c.HTML(200, "index.html", nil)
    })

    r.POST("/", func(c *gin.Context) {
        // get request body
        body, err := ioutil.ReadAll(c.Request.Body)
        if err != nil {
            c.JSON(400, gin.H{"error": "Failed to read body."})
            return
        }

        // parse json
        var info Info
        if err := json.Unmarshal(body, &info); err != nil {
            c.JSON(400, gin.H{"error": "Invalid parameter."})
            return
        }

        // validation
        if info.ID < 0 || info.ID > 2 {
            c.JSON(400, gin.H{"error": "ID must be an integer between 0 and 2."})
            return
        }

        if info.ID == 2 {
            c.JSON(400, gin.H{"error": "It is forbidden to retrieve Flag from this BFF server."})
            return
        }

        // get data from api server
        req, err := http.NewRequest("POST", "http://api:8000", bytes.NewReader(body))
        if err != nil {
            c.JSON(400, gin.H{"error": "Failed to request API."})
            return
        }
        req.Header.Set("Content-Type", "application/json")
        client := new(http.Client)
        resp, err := client.Do(req)
        if err != nil {
            c.JSON(400, gin.H{"error": "Failed to request API."})
            return
        }
        defer resp.Body.Close()
        result, err := ioutil.ReadAll(resp.Body)
        if err != nil {
            c.JSON(400, gin.H{"error": "Failed to request API."})
            return
        }

        c.JSON(200, gin.H{"result": string(result)})
    })

    if err := r.Run(":8080"); err != nil {
        panic("server is not started")
    }
}
// api/main.go
package main

import (
    "io/ioutil"
    "os"

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

func main() {
    r := gin.Default()

    r.POST("/", func(c *gin.Context) {
        body, err := ioutil.ReadAll(c.Request.Body)
        if err != nil {
            c.String(400, "Failed to read body")
            return
        }

        id, err := jsonparser.GetInt(body, "id")
        if err != nil {
            c.String(400, "Failed to parse json")
            return
        }

        if id == 0 {
            c.String(200, "The quick brown fox jumps over the lazy dog.")
            return
        }
        if id == 1 {
            c.String(200, "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.")
            return
        }
        if id == 2 {
            // Flag!!!
            flag := os.Getenv("FLAG")
            c.String(200, flag)
            return
        }

        c.String(400, "No data")
    })

    if err := r.Run(":8000"); err != nil {
        panic("server is not started")
    }
}

この問題は2つ関門があります。
1つ目はbff/main.goのcheckLocal()の突破です。nginxの設定ファイルを見てみます。

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

    location / {
        proxy_pass   http://bff:8080;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }

    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   /usr/share/nginx/html;
    }

}

$proxy_add_x_forwarded_forをそのまま渡してるのでX-Forwarded-Forの改ざんは容易です。リクエストヘッダでX-Forwarded-ForをいじればOK。

2つ目はbffでのjsonチェックの回避です。 { "id": 2 }apiサーバーに送信したいのですが、bffのチェックに弾かれてしまいます。 bffとapijsonのパースの仕方が違うことに着目して、試しに{"id": 2, "id": 0}を渡したらbffのinfo構造体には0がマッピングされて、apijsonparser.GetInt(body, "id")では2を渡せたのでこれで解けました。

curl -k  'https://json.quals.beginners.seccon.jp/' \
    -X POST \
  -H 'X-Forwarded-For:192.168.111.0' \
  -d '{"id": 2, "id": 0}'

cant_use_db

DBではなくファイルシステムに直接writeして情報を永続化してるアプリ。

import os
import re
import time
import random
import shutil
import secrets
import datetime
from flask import Flask, render_template, session, redirect

app = Flask(__name__)
app.secret_key = secrets.token_bytes(256)


def init_userdata(user_id):
    try:
        os.makedirs(f"./users/{user_id}", exist_ok=True)
        open(f"./users/{user_id}/balance.txt", "w").write("20000")
        open(f"./users/{user_id}/noodles.txt", "w").write("0")
        open(f"./users/{user_id}/soup.txt", "w").write("0")
        return True
    except:
        return False


def get_userdata(user_id):
    try:
        balance = open(f"./users/{user_id}/balance.txt").read()
        noodles = open(f"./users/{user_id}/noodles.txt").read()
        soup = open(f"./users/{user_id}/soup.txt").read()
        return [int(i) for i in [balance, noodles, soup]]
    except:
        return [0] * 3


@app.route("/")
def top_page():
    user_id = session.get("user")
    if not user_id:
        dirnames = datetime.datetime.now()
        user_id = f"{dirnames.hour}{dirnames.minute}/" + secrets.token_urlsafe(30)
        if not init_userdata(user_id):
            return redirect("/")
        session["user"] = user_id
    userdata = get_userdata(user_id)
    info = {
        "user_id": re.sub("^[0-9]*?/", "", user_id),
        "balance": userdata[0],
        "noodles": userdata[1],
        "soup": userdata[2]
    }
    return render_template("index.html", info = info)


@app.route("/buy_noodles", methods=["POST"])
def buy_noodles():
    user_id = session.get("user")
    if not user_id:
        return redirect("/")
    balance, noodles, soup = get_userdata(user_id)
    if balance >= 10000:
        noodles += 1
        open(f"./users/{user_id}/noodles.txt", "w").write(str(noodles))
        time.sleep(random.uniform(-0.2, 0.2) + 1.0)
        balance -= 10000
        open(f"./users/{user_id}/balance.txt", "w").write(str(balance))
        return "💸$10000"
    return "ERROR: INSUFFICIENT FUNDS"


@app.route("/buy_soup", methods=["POST"])
def buy_soup():
    user_id = session.get("user")
    if not user_id:
        return redirect("/")
    balance, noodles, soup = get_userdata(user_id)
    if balance >= 20000:
        soup += 1
        open(f"./users/{user_id}/soup.txt", "w").write(str(soup))
        time.sleep(random.uniform(-0.2, 0.2) + 1.0)
        balance -= 20000
        open(f"./users/{user_id}/balance.txt", "w").write(str(balance))
        return "💸💸$20000"
    return "ERROR: INSUFFICIENT FUNDS"


@app.route("/eat")
def eat():
    user_id = session.get("user")
    if not user_id:
        return redirect("/")
    balance, noodles, soup = get_userdata(user_id)
    shutil.rmtree(f"./users/{user_id}/")
    session["user"] = None
    if (noodles >= 2) and (soup >= 1):
        return os.getenv("CTF4B_FLAG")
    if (noodles >= 2):
        return "The noodles seem to get stuck in my throat."
    if (soup >= 1):
        return "This is soup, not ramen."
    return "Please make ramen."


if __name__ == "__main__":
    app.run()

セッションごとに所持金20000円で40000円分の買い物をしてねという問題。当然普通に順番にPOSTしても解けないです。
着眼点は明らかに怪しいtime.sleep(random.uniform(-0.2, 0.2) + 1.0)です。
買い物処理中に別の買い物プロセスを発火させれば他のプロセスがbalanceを書き換える前にbalanceチェックを突破することができます。 セッションだけセットして非同期でcurl

curl -k  'https://cant-use-db.quals.beginners.seccon.jp/buy_soup' \
      -X 'POST' \
      -H 'cookie: session=SESSION' &
  curl -k  'https://cant-use-db.quals.beginners.seccon.jp/buy_noodles' \
      -X 'POST' \
      -H 'cookie: session=SESSION' &
  curl -k  'https://cant-use-db.quals.beginners.seccon.jp/buy_noodles' \
      -X 'POST' \
      -H 'cookie: session=SESSION'

この後同じセッションつかって/eatをGETすればOK。

misc

Mail_Address_Validator

#!/usr/bin/env ruby
require 'timeout'

$stdout.sync = true
$stdin.sync = true

pattern = /\A([\w+\-].?)+@[a-z\d\-]+(\.[a-z]+)*\.[a-z]+\z/i

begin
  Timeout.timeout(60) {
    Process.wait Process.fork {
      puts "I check your mail address."
      puts "please puts your mail address."
      input = gets.chomp
      begin
        Timeout.timeout(5) {
          if input =~ pattern
            puts "Valid mail address!"
          else
            puts "Invalid mail address!"
          end
        }
      rescue Timeout::Error
        exit(status=14)
      end
    }
    
    case Process.last_status.to_i >> 8
    when 0 then
      puts "bye."
    when 1 then
      puts "bye."
    when 14 then
      File.open("flag.txt", "r") do |f|
        puts f.read
      end
    else
      puts "What's happen?"
    end
  } 
rescue Timeout::Error
  puts "bye."
end

いわゆるRedosの問題。メールアドレスに対する正規表現のチェックに負荷をかけろというもの。一応60秒の制限もありクソデカ文字列では突破できません。

この問題については銀座Railsで全く同じ話を聞いたことがあったのでその方の素晴らしい資料を貼っておきます。

speakerdeck.com

~  nc mail-address-validator.quals.beginners.seccon.jp 5100

I check your mail address.
please puts your mail address.
username@host.abcde.abcde.abcde.abcde.abcde.abcde.abcde.abcde.abcde.

でOK

感想

webの最初の問題が時事ネタで笑いました。事件を聞いて作成したのか元々この問題だったのかはわかりません。
CTFのwebを解いているといつも感じることですが、典型的なものも多いし明らかにヤバいコードの実例がわかるので、Web系企業の研修に最適だと思います。自分もX-Forwarded-Forはこの問題で初めて知りました。
運営の皆様ありがとうございました。楽しかったです。

mimemagicに依存しなくなったmarcelのmime type判定の変化には気をつけようって話

mimemagic gemのライセンス問題で色々ありましたね。ライセンスは法的な問題なので確認も難しい問題でした。

さて、marcelからmimemagicへの依存が無くなってmime typeの判定がApache Tikaを元にしたものになりました。

hackmd.io

しかし、この変更にはmarcelのmime type判定結果が変化するものが含まれていました。
例えば、以下のsample.xlsxというファイルのmime typeの判定をMarcel: 1.0.0、0,3,3それぞれに行わせると...

❯ xxd sample.xlsx                                                                                          
00000000: 504b 0304 1400 0808 0800 3123 7d52 0000  PK........1#}R..
00000010: 0000 0000 0000 0000 0000 1800 0000 786c  ..............xl
00000020: 2f64 7261 7769 6e67 732f 6472 6177 696e  /drawings/drawin
00000030: 6731 2e78 6d6c 9dd0 5d6e c230 0c07 f013  g1.xml..]n.0....
00000040: ec0e 55de 695a 1813 4314 5ed0 4e30 0ee0  ..U.iZ..C.^.N0..
00000050: 256e 1b91 8fca 0ea3 dc7e d14a 3669 7b01  %n.......~.J6i{.
00000060: 1e6d cb3f f9ef cd6e 74b6 f844 6213 7c23  .m.?...nt..Db.|#

❯ file -i sample.xlsx                                                                                
sample.xlsx: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet; charset=binary
irb(main):001:0> require 'marcel'
=> true
irb(main):002:0> Marcel::VERSION
=> "1.0.0"
irb(main):003:0> require 'pathname'
=> true
irb(main):004:0> Marcel::MimeType.for Pathname.new('sample.xlsx')
=> "application/zip"
irb(main):001:0> require 'marcel'
=> true
irb(main):002:0> Marcel::VERSION
=> "0.3.3"
irb(main):003:0> require 'pathname'
=> true
irb(main):004:0> Marcel::MimeType.for Pathname.new('sample.xlsx')
=> "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"

このように同じファイルに対して判定結果が異なっています。

この理由はmarcelのmimemagicをreplaceした差分でわかります。

github.com

marcelはlib/marcel/mime_type/definitions.rb内にて 'require 'mimemagic/overlay'していました。 mimemagic/overlayはxlsxやらpptやらをapplication/zipと区別してくれるMimeMagic.addをしていたファイルです。
最初に貼ったmimemagic最新動向の記事でも触れられていましたね。

この'require 'mimemagic/overlay'が無くなったことによってmarcelはxlsxやらpptをapplication/zipと判定するようになりました。*1

marcelのREADMEにはもともと

By preference, the magic number data in any passed in file is used to determine the type. If this doesn't work, it uses the type gleaned from the filename, extension, and finally the declared type. If no valid type is found in any of these, "application/octet-stream" is returned.

Some types aren't easily recognised solely by magic number data. For example Adobe Illustrator files have the same magic number as PDFs (and can usually even be viewed in PDF viewers!). For these types, Marcel uses both the magic number data and the file name to work out the type:

とあるので、書いてある通りファイル名を渡すのがとりあえずの対処策になるかと思います。

irb(main):001:0> require 'marcel'
=> true
irb(main):002:0> Marcel::VERSION
=> "1.0.0"
irb(main):003:0> require 'pathname'
=> true
irb(main):004:0> Marcel::MimeType.for Pathname.new('sample.xlsx')
=> "application/zip"
irb(main):005:0> Marcel::MimeType.for Pathname.new('sample.xlsx'), name: 'sample.xlsx'
=> "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"

shrineにはそういったオプションもありますね。

xlsxとかpptを扱うRailsアプリはそれなりに存在するでしょうから各位気をつけていきましょう。

追記

@furish さんがPR投げてくれてますね ある場でこの人と話した内容がこの記事の発端でもあります。 github.com

*1:一部のxlsxとかpptはmarcel 1.0.0でもそのままapplication/zipと区別してくれるかもしれません。マジックナンバーにあまり詳しくないのでわかりませんが、特定のマジックナンバーを持つxlsxとかpptも存在するかも

JAISTに入学します

面接試験でボコボコになりかなり落ち込んでましたが、なぜか受かってました。

f:id:sasa5740:20210307163633j:plain

卒業できるように頑張るぞ 💪

面接試験でおきたこと

「〜〜ってご存知ですか?」

僕> わかりません〜〜〜(ToT)

「〜〜の原理を説明してください」

僕> あれがこうしてこうなってなんやかんやあってこうなりました(ToT)

「哲学が得意科目にはいってますけど*1計算機にとって”正しさ”とはなんですか?」

僕> (死)

研究テーマの質問に答えてくれた先生、面接資料みてくれた友人たち、本当にみなさんありがとうございました!!!!

*1:大学のゼミが哲学だったので書いた、何もわかっていません

JAIST科目履修生を終えて

9月から履修していたJAISTのソフトウェア設計論が終了しました。

sasa5740.hatenablog.com

単位とれたの

100点で優でした( ・´ー・`)どや f:id:sasa5740:20210201205443j:plain

実際は105/120なので全然パーフェクトじゃないんですが。 9月から新年まで毎週課題の嵐で正直大変でした...。難易度自体は仕事等である程度Java使っていれば優とれると思います。僕は使ったことないんですけどね。

どんな内容だったの

ソフトウェア設計論というと意味が広そうですが、主に2つの観点から講義が行われました。

  1. Javaを用いたOODM。
  2. 並列処理とモデル検査

Javaを用いたOODM

講義前半では、Object-oriented design methodologiesの略としてOODMという言葉が用いたソフトウェアの設計のお話でした。OODのことだと思います。UMLを使ってユースケース図、シーケンス図、関連データモデルをバリバリ書いてました。最終的にVMorコンパイルで動く簡易プログラミング言語を作成するのですが、VMとかコンパイルの原理とOODどちらも学べてお得感がありました。
講義の中でJavaコンパイル時のメソッドシグネチャーの決定と実行時のメソッド探索の話がありました。静的型付け言語のメソッド探索を調べたことはなかったのでいい知見が得れました。

並列処理とモデル検査

講義後半ではサンプルコードにJavaを使った非同期処理のお話でした。レースコンディションやデッドロック、ライブロックの話から始まり、生産者/消費者問題と食事する哲学者問題を実際のコードを使ってなぜうまくいかないのか?を順を追って説明してくれました。解決策も課題で提出することになるのでかなり勉強になりました。
並列処理と合わせてモデル検査の話もありました。ソフトウェアの観点からいうとモデル検査とは、プログラム実行時の組み合わせを網羅的に探索して人間が自動テストを書くのでは気づけないパターンの問題(並列処理のデッドロックとか)を検知できるというものです。Javaのモデル検査ツールであるJava Path Finderを使って先の生産者/消費者問題と食事する哲学者のコードを検査してデッドロックするスケジューリングを発見、解消するという行為が楽しめました。

まとめ

全体として満足度高い講義でした!
ソフトウェア設計というテーマの中に知識がたくさん詰め込まれた講義が毎週きけて、質問もできて、毎回の課題を採点してくれて、いたれりつくせりでした。
学校で興味あることを学ぶって楽しいですね。JAISTに入学を考えている方は一度科目履修生として講義を体験してみるのはどうでしょうか。自信を持ってオススメできます。