2014-09-25

Bash の脆弱性について

CVE-2014-6271 の件について、何かわかってない記事が散見されるので簡単にまとめ。

問題点

環境変数が特殊な文字列の場合、Bash はそれを関数定義とみなす(この仕様がそもそもどうなのよ、と思う人は/bin/shをbashにするのはもうやめましょう)。そのロジックに誤りがあり、関数定義でない部分も読み込み、その部分が実行されてしまう。

影響範囲

/bin/sh が bash でないシステムの場合

明示的に bash スクリプトとして起動されたスクリプトだけが影響を受ける。ただし SSH サーバーの場合、ユーザープログラムを実行する場合にユーザーのシェルを使用するので、そこで影響を受けることがある。

/bin/sh が bash であるシステムの場合

system(3) や popen(3) が内部で /bin/sh を利用しているので、影響は広範に渡る。

CGI


特に話題になってるのが CGI である。CGIは仕様 (RFC3875) により、外部から受け取った情報を環境変数に入れた上で (section 4.1.18 参照) 外部コマンドを実行することになっている。ウェブサーバーの実装によるが、このとき system(3) (またはそれに類する) を使って外部コマンドを実行していれば今回の脆弱性の影響を受ける(が、普通は system(3) は使わないし、少なくともapacheはそんなことしない)。

また、環境変数は明示的に制限しない限り子供のプロセスにも引き継がれるので、ウェブサーバーが system(3) を使っていなくても、そこから起動されたプログラムがさらに外部コマンドを system(3) 相当で起動した場合、同じように脆弱性の影響を受ける。

SSH

openssh はサーバー側の AcceptEnv の設定(とクライアント側の SendEnv)によっては手元の環境変数をリモートに送ることができる。環境変数が評価されるのは認証後なので、通常はあまり大きな問題ではないが、サーバー側で ForceCommand で特定のコマンドしか実行しないようにしていた場合、それをバイパスすることができる。なお、ForceCommand はそのユーザーのシェルで実行されるので、zsh とかにしていると影響を受けない。

<追記>
bugレポートちゃんと読んでなかった。もしかしたら AcceptEnv に関係なく TERM は送られるかも。それと、ForceCommand の場合、クライアント側の引数が SSH_ORIGINAL_COMMAND 環境変数にセットされるので、ssh remote_host "() { :;}; ..." とかするだけでいけるっぽい。
</追記>

なおほとんどの人には関係ないとは思うが、クライアント側で ProxyCommand でモニョモニョしている場合、ProxyCommand は実行ユーザーのシェルで解釈される (/bin/shではない) ので、もし ProxyCommand にもクライアントの環境変数が渡る場合(まだチェックしてないので、誰かチェックしてよー)は、リモートを攻撃しようとしてたら何故か自分を攻撃してたということもなきにしもあらずなので、そんな人は注意が必要である。

<おまけ>
ForceCommand とか ProxyCommand が /bin/sh を使わないのは、多分、ユーザーのシェルを制限付きシェル (rsh, rbash) にしてセキュリティを強化たい場合用のはず。
</おまけ>

dhclient

DHCPクライアントの実装の一つである dhclient は、システム管理者によって簡単にカスタマイズできるように、受け取った情報を環境変数に入れた上で各種スクリプトを起動する仕組みになっている。偽の、というか勝手に DHCP サーバーを立てるのは簡単なので、偽DHCPサーバーから偽装された応答を返すことで、DHCPクライアントが動いているホストを乗っ取ることができる。DHCPクライアントは root 権限で動くので深刻な問題になりうる。

CUPS
よく知らん。バグレポートに影響受けるよって書いてあった。

2013-12-20

ターミナルウィンドウに雪を降らせよう! - Bash Advent Calendar - Day 13

いまは、ぼくのこころの中では 12 月 13 日の 210時ぐらいです。もはや Advent Calendar の体をなしてない気がしますが、細かいことは気にしないで行きましょう。

terminfo

端末エミューレーター(以下「ターミナル」)は色々と標準、デファクトスタンダードが存在します。これから使う ANSI エスケープシーケンスについては、それと同等の機能のあるターミナルであれば、まずもって ANSI エスケープシーケンスに対応しています。

が、ターミナルの機能は ANSI エスケープシーケンスでサポートされているものだけではなく色々あるので、そうした色々な機能を抽象化して定義したのが terminfo です。昔は termcap が主流でしたが、最近は terminfo の方が Linux 界隈では優勢だと思います。

tputコマンド

tputコマンドは、terminfoで定義されている機能名を引数にとり、使用しているターミナルに対応するコマンドを出力します。

たとえば、画面クリア "clear" では
clear="$(tput clear)"
printf "%q\n" "$clear"
$'\E[H\E[2J'
となります。ちなみに、printf %q とか $'...' 記法に「Bashらしさ」があるので、それで記事が書けそうな気がしてきました。

乱数

$RANDOM を参照すると Bash は擬似乱数を返してくれます。が、値は[0, 32767]の範囲とかなりしょぼい仕様です。以下では、頑張って[0, ターミナルカラム数)で一様に分布するっぽい乱数を生成していますが、実用上、Linux であれば
rand() {
  local -i r="0x$(
    dd if=/dev/urandom bs=4 count=1 2>/dev/null |
    od -A n -t x4 |
    tr -d ' ')"
  echo $((r % $1))
}
でいいでしょう。64bit 環境であれば dd と od の引数に出てくる 4 は 8 に変えてください。

さて、[0, 32767] の範囲でしか値を返さない乱数の場合、そこから [0, x) の範囲で得ようとした場合、単純に (mod x) を取ると偏りが出てしまいます。そこで以下では、例えば x が 80 であれば
32767 % 80 = 47
32767 - 47 = 32720
ということで、得られた乱数が [0, 32720) の範囲にある場合だけ有効な乱数とみなすことで一様な乱数を得ています。

雪を降らせてみよう

元々この記事は「ターミナルウィンドウに雪を降らせよう!」に触発されて書いてます。というかロジックと変数名は(あえて)そのままです。
C=$(tput cols)
RAND_MAX=32767;
rand(){
  local RAND_MAX=$(( RAND_MAX - (RAND_MAX + 1) % $1 ))
  local r=$((RAND_MAX+1))
  while ((r > RAND_MAX)); do
    r=$RANDOM
  done
  echo $((r % $1))
}
declare -i -a a=()
tput clear
tput home
while :; do
  a[$(rand $C)]=0
  for x in ${!a[@]}; do
    o=${a[x]}
    a[x]+=1
    printf "%s %s\u2743%s" $(tput cup $o $x) \
            $(tput cup ${a[x]} ${x}) $(tput cup 0 0)
  done
  usleep 0.1
done
元ネタコードと違うところは
  • ターミナルのカラム数の取得は stty ではなく tput コマンドを使う
  • randしょぼい
  • ANSI エスケープシーケンスは使わずに tput コマンドを使う
  • Bash なら \uxxxx で Unicode 文字の表示がそのまま可能
というところでしょうか。それ以外で使ってる Bash 機能は今まですでに説明したと思います。

tput に関しては、tput -S で標準入力からコマンドシーケンスを受け取れるらしいので、それを使うともっと短く書けるかもしれません。

タブ補完(その1) - Bash Advent Calendar - Day 12

いまは、ぼくのこころの中では 12 月 12 日の 234 時ぐらいです。当然「昨日」とは 12 月 11 日のことです(すべて US 太平洋時間)。

compgen関数

昨日は補完の仕様をさらっと説明しましたが、実際に補完をどう生成すればいいのかというのを説明していませんでした。COMPREPLYは配列変数なので、この Bash Advent Calendar を初日から読めば、自力でかなりのことができるはずです。

とは言え、Bashさんも鬼ではありません。ありがちなパターンに簡単に対応する関数 compgen 関数が予め用意されてます。、一般的には
COMPREPLY=($(compgen オプション... -- 補完したい単語))
という形で使います。

たとえば "-foo", "-bar", "-buzz" というオプション名を補完させたいとしましょう。そしてユーザーが "-" まで入力している場合には
COMPREPLY=($(compgen -W "-foo -bar -buzz" -- "-"))
となります。compgen だけを動作させると以下のようになります
$ compgen -W "-foo -bar -buzz" -- "-"
-foo
-bar
-baz
$ compgen -W "-foo -bar -buzz" -- "-b"
-bar
-baz
$ compgen -W "-foo -bar -buzz" -- "f"
$ echo $?
1
候補が何もなかった場合に、終了ステータスが1になります。

ではよく使うオプションについて説明します。

  • -W 文字列
    引数で与えられた文字列を「単語分割」($IFS のいずれかの文字で区切る)して、それを候補とみなします。 補完候補の中に空白が含まれる場合は、IFSを適宜変更しておく必要があります。何を言ってるかわからない人は諦めてくださいw
  • -P 文字列
    引数で与えられた文字列を、補完候補それぞれの先頭に挿入します。

    例えばディレクトリ名を ":" 区切りで入力するとします。既に "/bin:/usr/bin:/usr/l" まで入力されている状態であれば、まずは "/bin:/usr/bin:" と "/usr/l" に入力を分割、"/usr/l" に対して補完候補を生成、最後に compgen -W "生成結果" -P "/bin:/usr/bin:" -- "/usr/l" とすることで、処理を簡潔にすることができます。
  • -S 文字列
    引数で与えられた文字列を、補完候補それぞれの末尾に追加します。

    上記の -P の例であれば、-S ":" とすれば、ディレクトリ名の末尾に : が追加され、引き続きユーザーがディレクトリ名を入力できます。

以上です。ちなみに、compgen は重複削除をしてくれないので、複雑な補完をする場合には気をつける必要があります。

_get_comp_words_by_ref関数

これはBashビルトイン関数ではありません。昨日の記事を参照してください。どうしても自力で全部したい人は、以下
function ... () {
  local cur prev
  _get_comp_words_by_ref cur prev
  ...
}

function ... () {
  local cur="${COMP_WORDS[COMP_CWORD]}"
  local prev="${COMP_WORDS[COMP_CWORD-1]}"
  ...
}
と読み替えてください。違いは COMP_WORDBREAKS にある文字を正しくハンドリングできるかどうかです。

実例

自分の欲しいパスワードマネージャーがなかった時代、私はテキストファイルにパスワードを書き込みつつ GPG でそれを保護してました。それらは
pass_edit パスワード種別
pass_show パスワード種別
pass_export パスワード種別 メールアドレス [メールアドレス ...]
というコマンド形式です。 パスワード種別は単純に暗号化されたファイルのファイル名から .gpg を取り除いた部分、メールアドレスは gpg で信頼済みのユーザーのメールアドレスです。暗号化されたファイルは $HOME/secure 以下にあります。

以上の条件のとき、~/.bashrc には
complete -F _pass_tool -o bashdefault pass_show
complete -F _pass_tool -o bashdefault pass_edit
complete -F _pass_tool -o bashdefault pass_export
と書いておきます(~/.bashrc が読み込まれる条件は色々あるのですが、ここでは詳細は割愛。自分で調べてね)。これでこれらのコマンドが実行されると、_pass_tool 関数が実行されます

で _pass_tool 関数の中身は
_pass_tool () {
  local data_dir=/home/${USER}/secure
  local cur candidates
  COMPREPLY=()
  _get_comp_words_by_ref cur prev
  if [[ ! -d ${data_dir} ]] || \
     [[ ! -x ${data_dir} ]] || \
     [[ ! -r ${data_dir} ]]; then
    return 1
  fi
  if [[ "${COMP_WORDS[0]##*/}" == "pass_export" ]] && \
     [[ ${COMP_CWORD} -ge 2 ]]; then
    candidates=$( gpg --list-keys 2>/dev/null | \
                    sed -n 's:^uid.*<\([^>]*\)>.*:\1:p')
    COMPREPLY=($(compgen -W "${candidates}" -- "${cur}"))
  else
    candidates=$(
      /bin/ls -1 ${data_dir}/*.gpg | \
      sed -n -e 's:.*/::' -e 's:\.gpg$::p')
    COMPREPLY=($(compgen -W "${candidates}" -- "${cur}"))
  fi
}
これは実際に使ってたスクリプトをベースにしてるので、Bash Advent Calendarの俺ルール「sed, awk を使わない」に反してるし、もうちょっとキレイに、というかもっと Bash らしい書き方ができるところがありますが、もう力尽きたの許してください。あと、公開するとまずいところや改行位置を修正した過程でなんらかのミスがあるかもしれません。というわけで実際に動くかどうかはわかりません。が、雰囲気が伝わればそれでよしということでお願いします。

本来はもっと _get_comp_words_by_ref が生きる例を出す予定だったのですが、とにかくもう、力尽きたので許してください。

追記

スクリプトのフォーマットが崩れてたのを直しました。Bloggerはコードスニペットを書くのには向いてないですねorz

2013-12-11

タブ補完(その0) - Bash Advent Calendar - Day 11

ネタがない。

準備

基本的にありものを使ってね、というのは嫌なのですが、まずは bash-completion というパッケージを導入するのが近道です。そこで共通で使われてる関数達が超絶便利なんです。とりあえず、それをインストールしたら今日はおしまいです。続きは明日。

仕様

ユーザーが入力している情報

とりあえず、自作関数で補完をしようとした場合、以下の変数のお世話になります。

  • COMP_WORDS
    現在入力中の行が、単語分割されて、配列として格納されています。ただし、単語分割は Bash の文法とは関係なく、readline というライブラリが好き勝手に分割してくれます。
  • COMP_WORDBREAKS
    上記 readline ライブラリが単語と単語の区切りとみなす文字のリスト。 参照オンリーです。この値を変更することは可能ですが、この値を変更しても readline ライブラリは何もしてくれません。昔は変更すると Bash が死んで楽しかったのですが、最近はそんなことはさすがにないみたいです。
  • COMP_CWORD
    現在カーソルがある場所の、その単語が COMP_WORDS で格納されている位置。
  • COMP_LINE
    現在入力中の行全体。
  • COMP_POINT
    現在のカーソルの場所、COMP_LINEでの文字位置に相当(0スタートなので、行末にカーソルがある場合はCOMP_POINTの長さに同じ)
他にも少し、名前が COMP_ で始まる変数があるのですが、使い道がよくわかりません。

とりあえず ${COMP_WORDS[COMP_CWORD]} で「何となく」のカーソル位置の単語が取れるのですが、COMP_WORDBREAKS に中の文字がカーソル位置の単語に含まれている場合にうまくいきません。

補完候補をユーザーに返す方法

COMPREPLYという配列変数に文字列をセットすると、それらが補完候補とみなされます。これはグローバル変数なので、なんの気にもせず上書きしてオッケーです。

TABでの補完時に自作関数が使われるようにする

complete -F 関数名 [-o オプション [-o オプション]... ] コマンド名
で登録が可能です。オプションは以下から複数指定が可能です、というか機能的にどうみても複数指定可能じゃないとおかしいだけど、man ページ読んだだけじゃ分からないよ。
  • bashdefault
    COMPREPLY の中身が空だったら、Bashのデフォルト動作(ファイル名補完とか、変数名補完とか)をする。自作関数がしょぼくて(意図せず)何も補完候補を返せなくても、全く補完をしない代わりにそれっぽい補完候補を返してくれる。他人に使って貰う場合には必須のオプションw
  • default
    bashdefault とほぼ一緒だけど、readline のデフォルト動作をする。と、man ページには書いてあるが、じゃー readline がデフォルトでどんな補完してくれるのかというのはどこにも書いてない。よく分からん。とりあえず、ファイル名補完、~展開、環境変数の変数名補完ぐらいはしてくれる気がする。
  • dirnames
    上のやつらと一緒で、COMPREPLYが空だった場合の動作指定。dirnamesはディレクトリ名補完をしてくれる。mount コマンドのように、オプションじゃない部分はがディレクトリ名と確定しているような場合に使う。ので、普通はこれ単独で使う機会はそうそうない。
  • filenames
    これまた上のやつらの仲間。-o dirnames -o filenames とすると、まーそれっぽい動作はする。ユーザーにデフォルトでは変数名補完とかさせたくない、という偏屈な人は "-o dirnames -o filenames" とかすればいいよ!
  • nospace
    このオプションが指定された場合、補完した結果の末尾に自動的にスペースを追加しない。たとえば "ホスト名:パス" のホスト名(と後続のコロン)だけを補完させる場合、勝手にスペースを追加されると困りますよね。そんな場合に使うのですが、この場合「パス」の後ろにはスペースを追加したいわけでして、自力で補完候補に適宜スペースを追加するという面倒な作業が発生します。
  • plusdirs
    このオプションが指定された場合、補完結果がディレクトリ名(の一部)だとみなし、ディレクトリ名補完を追加でおこないます。って書いてみたけどさっぱり意味がわからない。特定のディレクトリを除いてディレクトリ名補完をしたい、というような場合(昔で言えば CSV/ とか RCS/ とかを補完候補に出させない)に使うのかもしれません。
仕様としては以上です。これで君も明日から補完マスター。

なんてことがあるなら、こんなブログを読んでないでしょう。というわけで明日に続きます。

2013-12-10

拡張case文 - Bash Advent Calendar - Day 10

今日も本当に1行もスクリプトも書いてないし、なんの検証もしてない状態でこのブログを書き始めました。途中で破綻しないといいんですけどね。

そもそも、今日のお題はどちらかというと extglob なので、"c" の順番じゃないんじゃないかと思うんですが、所詮は個人でやってる Advent Calendar、細かいことは気にしないでください。

もし気にする方は、代わりにお題をください。いや、気にしない方もこれ読んだらお題をください。

globってなんぞや

今日の本当のお題である extglob という単語は拡張 extended glob の略です、多分。じゃ glob って何って言ったら、そりゃ Wikipedia でも参照してください。Wikipedia の当該エントリ曰く、"Global command" の略だそうです。

で、世間一般では、よく言われるのはワイルドカードってやつですね。POSIX だと、単に「パターンマッチング」とか呼ばれてるアレです。man 7 glob でもいけるかもしれません。あの、
  • *任意の0文字以上マッチ
  • ?任意の1文字にマッチ
  • [c...]ブラケット内のいずれか1文字([a-z]のような範囲指定も可)にマッチ
という、単純なやつです。で、こんなの拡張するぐらいだったら正規表現使えばいいじゃんと、世界中から総ツッコミが入りそうなところですが、シェルスクリプトでは "echo ファイル*" みたいなパス名マッチングだけでなく
${parameter%pattern}
case ... in pattern) ...
と、それ以外にも Bash では
[[ str == pattern ]]
とか pattern が使われるところがあるので、これを拡張すると他のLLは嫌いだけどシェルスクリプトの最新機能は好きとかいう偏食家に大いに受けるわけです。ちなみに最後のは、複雑なことをしたければ [[ str =~ regex ]] 使ってください、と言いたいところだけど例のごとく Bash は遅いので、sed とか awk, grep を使ったほうが無難です。

case文

簡単に。書式としては
case WORD in
  pattern) expression;;
  pattern | pattern ...) expression;;
esac
で、WORD が pattern にマッチしたら expression が実行されます。複数のパターンにパッチする場合、最初にマッチしたものが使われます。

このとき pattern は glob パターンとして解釈されるので
case foo in
  *) echo "wildcard";;
esac
と実行すると、"wildcard" と表示されます。Bash の場合は [[ WORD == pattern ]] というマッチングがありますが、そうじゃないシェルでも case 文ではパターンマッチングが使えるので、case による場合分けがなくてもパターンマッチングのためによく使われます。 ちなみに、ワンライナーの場合は
$ case foo in *) echo bar;; esac
とそのままくっつけちゃって大丈夫です。

shopt

少し寄り道を。Bash の動作を操作する方法として、set コマンドというのが良く使われると思います。たとえば
  • set -xでデバッグ
  • set +Hでヒストリー展開を無効にする
  • set -uで typo 探し
と、皆さん馴染み深いと思います。が、色々と(変な)機能が満載の Bash さん、a-z A-Z の 52 文字じゃ全然足りません。というわけで "shopt -s OPTION" という形で、色々な動作変更ができます。暇な人は man bash で compat31 で検索してみてください。Bash の黒歴史がかいま見えます。

で、そうしたオプションの中に extglob というオプションがあって、これを設定することで拡張 glob パターンが使えるようになります。ちなみに shopt の使い方は
  • shopt -s OPTION
    OPTION を有効にする
  • shopt -u OPTION
    OPTION を無効にする
  • shopt -q OPTION
    OPTION が有効なら終了ステータス 0, そうでなければ 1 を返す
となっております。shopt はシェルの動作仕様を変えることができるので、shopt -q で現在の設定を取得、色々処理した後に、必要であればもとに戻すというのがお行儀よいと(私の心の中でだけ)言われております。

extglobで使えるパターン

やっと本題に近づいて参りました。なんかもう飽きてきた。みんな本当にここ読んでる?
さて、extglob でどんなことができるかというと
  • pattern|pattern
    パターンのいずれかにマッチ(前述のとおり、case 文で | が使えるのはそれが case の文法の一部だからであって、デフォルトの glob パターンには | はありません)。以下の (...) 内でのみ使えます。
  • ?(pattern-list)
    パターンに0回または1回マッチ
  • *(pattern-list)
    パターンの0回または1回以上の繰り返しにマッチ
  • +(pattern-list)パターンの1回以上の繰り返しにマッチ
  • @(pattern-list)パターンのいずれかにマッチ
  • !(pattern-list)パターンのいずれにもマッチしない任意の文字列にマッチ
以上のとおりです。@(...)以外は(その記法は置いておくとして)なんとなく分かるとおもいます。@(...) については、既存の bash の文法に無理矢理追加する上で何らかの記号が必要であった事情をお察しください。

ここまで長かったですが、では、ちょっとここまで説明してきた機能を試してみましょう。
$ foo="foobarbuzz"
$ case "${foo}" in "foo"*) echo 0;; *) echo 1;; esac
0
$ case "${foo}" in "FOO"*) echo 0;; *) echo 1;; esac
1
ここまでは普通。
$ shopt -s extglob
で extglob が有効にして試してみましょう。
$ case "${foo}" in @(FOO|foo)*) echo 0;; *) echo 1;; esac
0
となりました。これだけだと本当は
$ case "${foo}" in FOO* | foo*) echo 0;; *) echo 1;; esac 0
と一緒で大したことないんですが
case ${method_name} in @(set|get)_@(foo|bar)) echo buzz ;; qux) ... ;; esac
みたいな感じで、便利に使える場合があります。

今日のネタ

さて、全然ネタっぽいところがなくてつまらないですね。ただただ便利なだけ。しかし、もう一度途中で出てきたコマンドを extglob を無効にしてから実行してみましょう。
$ shopt -s extglob
$ case "${foo}" in @(FOO|foo)*) echo 0;; *) echo 1;; esac
0
$ shopt -u extglob
$ case "${foo}" in @(FOO|foo)*) echo 0;; *) echo 1;; esac
bash: 予期しないトークン `(' 周辺に構文エラーがあります
わーい。というわけで、なんと extglob の有無で言語の文法が変わってしまうという、実は超強力機能なんです。 

で、これはコマンドラインで実行してるから可愛いものなのですが、たとえば .bashrc で
shopt -s extglob
case $HOSTNAME in)
  @(foo|bar).example.com)
    PS1="SERVER $PS1"
   ;;
esac
とか書いておくと、場合によっては bash が起動時に激おこになります。なぜなら、 .bashrc 読み込み時点で extglob が有効になってない場合、shopt -s extglob を実行する以前にパースエラーになってしまって何も実行されないからです。

というのを知らずに、みんなの .bashrc から読み込まれるファイルに上記のようなコードを仕込んで、数百人に迷惑をかけたことがあるので、みなさんも気をつけましょうというお話でした。

2013-12-09

Base64 decoder - Bash Advent Calendar - Day 9

むか~し、このネタをどこかに書いた気がするので、既に見たことがあったらゴメンナサイ。何度も書くけどネタがないんです。ネタください。

Base64の仕様

RFC3548読んでください。以上。だって、ここ読むような人は大体知ってるか、知らなくてもこの後読めば分かるでしょ。

では何なので、エンコーダーの場合

  1. 3 octet (24 bit, ASCII char 3 文字分) を 6bit のかたまり x 4 に展開
  2. 各 6 bit を以下の文字に変換
    • 0 〜 25: "A" 〜 "Z"
    • 26 〜 51: "a" 〜 "z"
    • 52 〜 61: "0" 〜 "9"
    • 62: "+", 63: "/" 
    • 処理の最後、入力が 1 octet もしくは 2octet しかない場合には "=" で padding
という感じで変換します。これでも意味不明なら、Wikipedia でもなんでも、適当に検索して自分で調べてね。

Bashの64進数

前回、基数が37以上の場合を説明しませんでしたが、下記64進数のマッピングから類推してください。で、64進数のマッピングは

  • 0 〜 9: "0" 〜 "9"
  • 10 〜 35: "a" 〜 "z"
  • 36 〜 61: "A" 〜 "Z"
  • 62: "@", 63: "_" 
となっております。そんなわけで、上のマッピングのの違いを吸収してあげるだけで、Base64デコーダーが算術式評価で簡単に作れる、はず。paddingとか面倒だけど。

検証用入力値

"Bash" AND "base64" で検索すると出てくる数々の、「コマンドライン上でBase64」を処理する方法。全然、Bash スクリプトじゃないじゃないか。そこですぐスクリプトが検索結果に出てきたらネタにならないんですけどね。

とにかく、検索したところ base64 コマンドを使えと。まんまですね。というわけで
$ echo "Hello World!!" | base64
SGVsbG8gV29ybGQhIQo=
全然関係ないですけど、おせっかいにもヒストリー展開が有効になってると !! がヒストリー展開されていやーんなことになるかもしれませんが、ここは適宜自分の好きな文字に置き換えるなり、ヒストリー展開を未来永劫禁止したり、好きなようにしてください。

スクリプト

とりあえずネタなので、不正な入力のハンドリングはなしということで。誰も実際にこんなん使わないでしょ。

四文字受け取って三オクテット返す関数

これがメインですが
four_char_to_three_octet() {
    local input="$1"
    local bash_64=$(LC_ALL=C tr "A-Za-z0-9+/=" "0-9a-zA-Z@_0" <<< "${input}")
    local -i base_64_num="64#${bash_64}"
    printf '\\x%02x\\x%02x\\x%02x\n' \
      $(( (base_64_num & 0xff0000) >> 16)) \
      $(( (base_64_num & 0x00ff00) >>  8)) \
      $(( base_64_num & 0x0000ff ))
}
tr を Bash ビルトインだけで書くこともできるのですが、全然関係ないところでスクリプトの量が10倍ぐらいになるので今日のところは tr で勘弁してください。tr の同等の処理じゃなく、単純に仕様に従って数値に変換してもいいんですが、そうすると「Bashの64進数表記」というネタの根本がどっかにいってしまうし、面白くもなんともありません。

色々実装の仕方はあると思うのですが、とりあえずここでは padding は無視して "=" も 0 に変換してます。それと printf の出力に余計な \ がついてるのは後で出てきます。とりあえず、実行結果は
$ four_char_to_three_octet "SGVs"
\x48\x65\x6c
となりますが、これは "Hello world!!" の最初の3文字 Hel に一致するはず
$ echo -n Hel | od -An -t x1
 48 65 6c

1行分の文字列を4文字ずつに区切る

仕様上、パディングを含めれば必ず1行は4の倍数文字なので
split_line() {
  local line="$1"
  local result=()
  local -i i
  for ((i = 0; i < ${#line}; i += 4)); do
    result+=(${line:i:4})
  done
  echo "${result[@]}"
}
と何も考えずに4文字ずつ区切るだけ。
$ split_line "SGVsbG8gV29ybGQhIQo="
SGVs bG8g V29y bGQh IQo=

パディング処理

詳細は自分で計算するなり、仕様をチェックしてくれればいいのですが、パディングはなし、1文字、2文字の3通りで、パディングの文字数分のオクテットだけ出力を削ればOKです。four_char_to_three_octet は1オクテットにつき \xXX で4文字出力するので、パディングの長さ×4文字削除しています。
strip_padded_part() {
  local src="$1"    # XXXX, XXX= or XX==
  local output="$2" # \xXX\xXX\xXX
  local src_except_padding="${src%%=*}"
  local -i padding_length="4 - ${#src_except_padding}"
  echo "${output:0:12-(padding_length * 4)}"
}
12は "\xXX\xXX\xXX" の文字数です。なんかもっとスマートな方法がある気がする、というか3オクテットを一つの文字列で返したのが敗因ですね。思いつきでこの記事を書きながらスクリプトを書いてるのでこんなハメに。最初にスクリプトを全部書き終えてから記事にすればよかった。

閑話休題、これで
$ four_char_to_three_octet "IQo="
\x21\x0a\x00
$ strip_padded_part "IQo=" '\x21\x0a\x00'
\x21\x0a
ちゃんと不要な \x00 が削除されてるのが分かります。

バイナリ出力

あとは今までのをまとめて、バイナリ出力するだけです。
base64_decode() {
  local line
  local four_char
  local three_octet
  while read line; do
    for four_char in $(split_line "${line}"); do
      three_octet=$(four_char_to_three_octet "${four_char}")
      if [[ "${four_char}" == *= ]]; then
        three_octet=$(strip_padded_part "${four_char}" "${three_octet}")
      fi
      printf "${three_octet}"
    done
  done
}
printf に引数一つだけというのは、Cならかなりダメコードのサインなのですが、printf "\xXX" (または "\0XXX")、もしくは echo -e だけがバイナリーを出力する手段 (printf "%c" は違うのが残念)なので、このようになっております。今回は printf の直後に - が来ないのが分かってますが、そうじゃない場合に気持ち悪いので echo -n -e "..." よりは printf -- "..." の方が好みです。って、"--" をここでは指定してないので一緒ですけどね。

とにかく
$ base64_decode <<< "SGVsbG8gV29ybGQhIQo="
Hello World!!
$
と、まさしくデコードできました。めでたしめでたし。ちなみに途中経過で \0x0a が出てくる通り、エンコードされた文字列に改行文字が入ってるので、出力にも改行が入ってますが、末尾に改行なんてないバイナリーデータでも正しく処理できるはずです。

今日の結論。スクリプトを書き終わって、ちゃんと恥ずかしくないようなものになってから記事を書きましょう。


Misc - Bash Advent Calendar - Day 8

今までちょっと説明が足りなかったところのランダムな補足。正直、既に息切れなので小休止。

配列変数の展開

特定の要素に対する文字列展開は、普通の変数の文字列展開と一緒なので省略。

${var[@]:-str}
var が存在し1個以上あれば ${var[@]} に同じ、そうでなければ str になる。var の中身を見て、それが空白文字だったら str に置きかわる、という動作をするわけではない。

${var[@]-str}
var が存在し1個以上あれば ${var[@]} に同じ、そうでなければ str になる。中身がない空の配列でも str に置き代わってしまうという残念な動作。仕様としては未定義っぽいけど。

${var[@]:?str}, ${var[@]:+word}
上と同様。

${var[@]:=str}
エラーになる。正直、エラーにならない場合、どんな動作をすべきか思いつかないので、妥当な仕様だと思う。

${var[@]:offset}, ${var[@]:offset:length}
offset 番目から length 個の要素を取り出す。実は同様に ${@:offset} とか ${@:offset:length} で、位置パラメータを部分的に取得できるんだけど、普通は shift 使えということで目にすることはまずないでしょう。残念ながら、各要素の部分文字列展開をしたい場合には、for ループで回すしかない。

${#var[@]}
配列の要素数になる。「各要素の文字列の長さ」が返ってくるわけではない。残念だけど、じゃぁ、配列の要素数を返すのに他に適当な記法があるかといえば、たしかにこれがベストと言わざるを得ないでしょう。

${var[@]#str}, ${var[@]##str}, ${var[@]%str}, ${var[@]%%str}
それぞれの要素に対して、先頭または末尾にマッチする文字列が削除、その結果を返します。

${var[@]/pattern/str}, ${var[@]//pattern/str}
それぞれの要素に対して、パターンにマッチした文字列を置換して、その結果を返します。

${var[@]^pattern}, ${var[@]^^pattern}, ${var[@],pattern}, ${var[@],,pattern}
それぞれの要素に対して、大文字、小文字変換変換した結果を返します。

というわけで、

  • 「変数が存在するかどうか、または空文字列か」をチェックする系のは動作はするけど : の有無が影響しないのでつまらない。
  • 数値を返す、または数値を引数に取る系のは、各要素に対して何か操作をするのではなく、配列全体に対する動作になる
  • 文字列置換系は各要素に対しての操作になる

とまとめられるかと思います。

[[ X -gt Y ]] と [ X -gt Y ] の互換性

正直、undocumented なので言及するだけ時間の無駄な気がしますが、現状、[[ ]] の場合は X Y ともに算術式評価がされるようです。なので
$ X=2
$ Y=1
$ [[ X -gt Y ]]; echo $?
0
$ [ X -gt Y ]; echo $?
bash: [: X: 整数の式が予期されます
2
と非互換の動作をします。算術式評価であれば、ちゃんと
(( X > Y ))
と書く方法があるので、"-gt" と test と同じ名前を使うのであれば、やっぱり test と同じような動作をすべきなんじゃないかと思うわけです。

declare コマンドと変数宣言

さてここまでずっと変数の宣言として declare コマンドを使ってきましたが、何回か書いてあるとおり、これらは内部「コマンド」であって bash の文法の一部ではありません。キバヤシなら「な、何だってー−!」と驚くところです。

で、何がポイントかというと、あるコマンドの実行結果を変数に代入する場合に問題になります。

  • $?は最後のコマンドの実行結果を返す
  • declareコマンドが実行される前に $() のコマンド展開が行われる
ということで

$ declare shadow=$(cat /etc/shadow 2>/dev/null)
$ echo $?
0
$ unset; declare shadow
$ shadow=$(cat /etc/shadow 2>/dev/null)
$ echo $?
1
実際のところ、declare コマンドでこれが問題になるケースは稀だと思いますが、その兄弟的な local コマンドでありがちなミスとして
function foo(){
  local tmp=$(mktemp foo.XXXXXX)
  if [[ $? -ne 0 ]]; then
    ...
}
みたいなのがあると思います。mktemp コマンドが失敗しても local コマンドは成功してしまうので、この if 文の評価は常に偽になります。

というわけで
local var=value
と書くと短く書けるのはいいのですが、右辺にコマンド展開がある場合は
local var
value=$(...)
と書く癖を付けておきましょう。

あと、これを書く動機として

  • declare -i -A または declare -i -a で右辺が算術評価されない

というバグがあります。これは
declare -i -a foo
foo=("1 + 2")
のように宣言と代入を分けることで回避できます。これ自体で困ることはそうそうないとは思いますが、こういうバグを見ると「どうやったらこんなバグ生じさせられるんだよ」とか「こんなところでバグるソフトとか信頼性どうなのよ」とか、でもきっと中の人は大変なんだろうなとか、いろいろと空想できて楽しいと思います。

というわけで今日の結論。やっぱり配列とか連想配列とか使うんだったら他のLL使いましょうw

prometheusのrate()関数の罠

 久しぶりのAdventカレンダー挑戦、うまくいく気がしません。 閑話休題。実のところ、rate()関数というよりは、サーバー側のmetric初期化問題です。 さて、何らかのサーバーAがあったとして、それが更に他のサーバーBにRPCを送っているとします。サーバーBの方でホワイトボ...