前回 : https://qiita.com/tanakmura/items/ca0aaf4402ea0d399e0e CAR
起動直後のPCは、
# これはハングする
mov 0, %eax # アドレス0からロード
# これはハングしない
mov 0xc0000, %eax # アドレス0xc0000からロード
という状態になっている、というのが前回の謎だった。
これはなぜか、という話をする。
mov 0, %eax
と書いたとき、何が起こるか考えよう。(以下の話はx86依存ではない、例えば、FSBを実装してNorth bridgeと接続されたRISC-Vが仮に存在するとしたら、同じことが起こる)
これは、メモリリード、とは限らない。というのは、MMIOアクセスがあり、メモリではないデバイスもmovでアクセスできるアドレス空間にマップされることがあるためだ。
CPU から出たメモリリードリクエストは、DRAMに行くかもしれないし、PCI に行くかもしれない。
これは、PCでは、若干複雑な方法で決まっている。アドレス範囲ごとに、
- North bridge
- DRAM
- PCIe
- 内蔵GPU
- South bridgeにフォワードする
- South bridge
- PCI
- SPI Flash
- USB HCI
- Audio
- その他South bridge内のデバイス
それぞれ、どこに行くか決まっているのだ。
で、これの話をもう少し複雑にするのが、PC の歴史だ。PC は、16bit OS でも動くように作ってあって
16bitの範囲(正確には20bitだが16bitと言ったほうがわかりやすいので16bitで説明する)でも、32bitの範囲でも
- PCI アクセス (16bit OS では主にVGA)
- BIOS ROM
- DRAM
それぞれにアクセスできないと、システムが動かない。一方で、多数のPCIデバイスを同時に見えるようにするには、32bitや64bitの範囲でデバイスをマップできたほうが使いやすい。
このため、
- 16bit modeで指せる1MBの範囲に必要なデバイスを全部マップできる
- 多くのPCIデバイスをマップできるように、32bit空間にPCIe BARが配置できる
という両方を実現する必要がある。つまり、PC では 16bit用のメモリマップと、32bit用のメモリマップを実装しているのだ。
- 0x0_0000_0000 - 0x000F_FFFF : 16bit OS 用メモリ空間 (DRAM + option ROM + VGA mem + ROM BIOS)
- 0x0_0010_0000 - 0xFFFF_FFFF : 32bit OS 用メモリ空間 (DRAM + PCI bar + ROM BIOS)
それぞれのメモリ空間にDRAM領域とMMIOデバイス領域を持っている。
それを示す図が、これだ (https://www.intel.com/Assets/PDF/datasheet/307502.pdf
より引用)
32bit OS 領域は、North Bridge 内にある TOLUD レジスタが示す値によって、二分される。
TOLUDが示すより小さいアドレスは、DRAMアクセスになり、TOLUD より大きいアドレスは MMIO アクセスになる。MMIO アクセスは、内蔵GPU、PCIe などの North bridge 内のデバイスだった場合内部で処理され、それ以外のアドレスはSouth bridgeへ転送される。
16bit OS用の領域はもうちょっと複雑だ。まず、0x0_0000-0x9_FFFF
までの640KiBはDRAM固定である。
0xA_0000
以降のアドレスについては、領域ごとに DRAM or MMIO を設定できるようになっている。これを設定するのが、PAM(Programmable Attribut Map) レジスタだ。
PAM レジスタは、North bridge内 に bus=0, dev=0, fn=0 の PCI デバイスとして存在する Host Bridge のコンフィグ空間に存在する。
これを操作することで、対応する領域をDRAMかMMIOに設定できる。
(注 : Apollo Lake のデータシートを見ると、このあたりの世代から16bit OS用領域は使えないようになっていて、MMIOに向けることはできないようだ)
これで最初の謎はとけた。
mov 0, %eax # アドレス0からロード
これがハングするのは、この領域はDRAMに向けられていて、起動直後はDRAMが初期化されてないためだ。
mov 0xc0000, %eax # アドレス0xc0000からロード
これがハングしないのは、PAM1 のデフォルト設定で、この領域がMMIOへ向くように設定されているからなのだった。
前回は、0xFFFF_C000-0xFFFF_FFFF
をキャッシュ可能領域とした、これは、TOLUD を超えた範囲なので MMIO アクセスになり、アクセスしてもハングすることはない。
これらが分かっていれば、carを使うときにハングしないアドレスを見つけることができるはずだ。
SMM, memory sinkhole, sinkclose
関連する話題として、SMMと、そのSMMへ攻撃する方法について書いておこう。
SMM は、Ring-0(=OS) から触れない領域でプログラムが動くモードである。
この"OSから触れられない"というのは、CPUとNorth bridgeが協力して実現されている。
North bridge のメモリコントローラにSMM用領域を設定すると、SMMでないときにその領域にアクセスできなくなるのだ。この領域を TSEG という。(互換性維持のための領域も他にあるが特に触れない)
(CPUのSMMの状態を North bridge がどうやって知るかというと…調べてもよくわからなかった。)
ここまでをまとめて、アドレスの扱いを python 風に書くと、こんな感じになる。
# north bridge
def north_bridge_access(addr):
if addr >= TOLUD: # addr が TOLUD より大きいと MMIO アクセス
north_bridge_mmio_access(addr)
elif addr < 0x1_0000_0000 # addr が 1MB より小さいと 16bit 用領域として処理
# PAMの設定によって DRAM or MMIO アクセスになる
# (略)
else: # addr が TOLUD より小さいと DRAM アクセス
TSEG_start = TOLUD - TSEG_size
if addr >= TSEG_start:
# アクセス範囲がTSEGに入ってる
if CPU_is_in_SMM():
# CPU が SMM だとメモリアクセス
return DRAM_access(addr)
else:
# SMMでないと invalid access
# (って何?945GCのマニュアルの9.2.2 TSEGにはinvalid accessと書いてある)
return invalid_access(addr)
else:
# 通常のDRAMアクセス
return DRAM_access(addr)
def north_bridge_mmio_access(addr):
if addr in PCIE_range: # North bridge の PCIe のBARに入ってたらPCIeアクセス
return PCIE_access(addr)
if addr in IGPU_range: # North bridge の 内蔵 GPU のBARに入ってたら内蔵GPUアクセス
return IGPU_access(addr)
else:
# North bridge のデバイスとマッチしないと south bridge アクセスになる
return south_bridge_access(addr)
# south bridge
def south_bridge_access(addr):
# south bridge 内の各デバイスのアドレス範囲に入ってたらそのデバイスにアクセスする
if addr in AHCI_range:
return AHCI_access(addr)
if addr in EHCI_range:
return EHCI_access(addr)
if addr in EHCI_range:
return EHCI_access(addr)
if addr in XXXX_range:
return XXXX_access(addr)
else:
# south bridge 内のデバイスの範囲に入っていないと外部PCIアクセスになる
# (ich7のマニュアルによると、 Subtractive Decode Polic というビットで動作を変えられる)
return PCI_access(addr)
マザーボードメーカーやPCメーカーが、マザーボード固有の機能をファームウェアに入れたい場合は、この領域を使って機能を実現する。
SMMに関する機能は強力で、外部からSMIという割り込みを入れるとRing0(OS)がどのような状態にあろうと(割り込み禁止状態だろうと)、強制的に割り込んで、SMMに入って、何かを処理することができる。
SMMで使うメモリ領域であるTSEGは、Ring0からはアクセスできないため、OSから見えないところで、状態を記録できる。SMMには制約はないので、Ring-0の状態は全て観測できる。
SMMから抜けると、CPUのレジスタ等の内部状態は全て元の状態に戻ってRing0に処理が戻るので、Ring0から見ると、「よくわからないけど時間が経過した」以上のことが何も分からないまま、処理が戻されるのである。
通常は、ホットキーなどを処理する程度らしいので問題はないが、問題はこれが攻撃に悪用された場合である。
なんらかの拍子にSMMに悪意のあるプログラムをSMMで動くプログラムとしてインストールされると、OSからは二度と観測できない場所から一方的に攻撃される状態になる。このため、SMMを乗っとる手法というのは昔から研究されてきた。
今年(2024年)の8月に話題になった、AMD の CPU にあった脆弱性、sinkclose が、SMMを乗っとるための手法のひとつである。ここまでの話を理解できれば、何が起こってるか理解できるはずなので、紹介しておこう。
まず、sinkclose の前に、これから参照されていた、 memory sinkhole のほうが面白いと思ったので先に紹介する(https://www.blackhat.com/docs/us-15/materials/us-15-Domas-The-Memory-Sinkhole-Unleashing-An-x86-Design-Flaw-Allowing-Universal-Privilege-Escalation.pdf sinkcloseは、これの亜種になる)
上の疑似コードでは、north bridgeのアクセスから書いていた。
実際にはmov命令を実行すると、north bridge の前にCPU内でのアドレスハンドリング処理が入る。すぐ思い付くのはキャッシュだろう。
こんな感じになる。
def cpu_access(addr):
if cacheable_addr(addr) and cache_hit(addr):
# addrがキャッシュ可能かつ、キャッシュにヒットしてるとキャッシュで処理する
return cache_access(addr)
else:
# キャッシュになかったらnorth bridgeにアクセスする
return north_bridge_access(addr)
CPUを当時作った人もこんな感じの認識だったのかもしれない。
しかし、実際には、x86 には Local APIC という割り込みコントローラがコアごとに付いていたのだった。Local APICは、コア毎に状態を持つ必要があるため、システムに一個しかないNorth bridgeではなく、コア内に実装される。
Local APICは割り込みコントローラであり、割り込みの管理はRing-0のOSの役割になるので、Local APICのレジスタは Ring-0 から自由に読み書きできた。
不幸なことは、この Local APIC をマップするアドレスが自由に設定できた点だ。CPU によるデータアクセスは、実際には、
def cpu_access(addr):
if cacheable_addr(addr) and cache_hit(addr):
# addrがキャッシュ可能かつ、キャッシュにヒットしてるとキャッシュで処理する
return cache_access(addr)
elif addr in Local_APIC_range:
# addr が Local APIC のレジスタを指していたら Local APICにアクセスする (SMMかどうかに関係なく)
return Local_APIC_access(addr)
else:
# キャッシュになかったらnorth bridgeにアクセスする
return north_bridge_access(addr)
こうなっていた。これの問題は、Local APIC へのアクセス判定が、North bridge による SMM の範囲判定よりも優先されているという点だ。
この状況で、Local APIC を TSEG にオーバーラップするように配置すると、SMM で動くプログラムがTSEGにアクセスしたつもりが、Local APICのレジスタにアクセスすることになってしまうのだ。
普通のSMMプログラムは、TSEGの内容を信頼しているので、値の検証はしないのが普通だろう。そこを突いて、SMMプログラムに変なデータを読ませて、意図しない動作をさせる、というのが、このsinkholeである。
Local APIC のレジスタは自由に読み書きできる箇所は少なく、意味のある動作をさせることが難しかったようだが、Local APIC のレジスタをGDTとして読ませることでRing-0からアクセスできるメモリにジャンプさせられたようである。詳細はもとの資料を参照のこと。
で、これの亜種である sinkclose だが、AMD の CPU には TClose という、一時的にTSEGを無効にするレジスタがあったらしく、これだけがロック不可能な状態で残ってたらしい。
あ、ロックの説明をしてないSMMは初期設定のときだけはSMMでない状態で設定する必要があるので、起動直後は開放されている。初期設定後に、ロックをかけることで、設定を変えられないようにできる。(そもそも初期のSMM脆弱性はロックしてないみたいな単純な穴だったようだ : 参考 https://xtech.nikkei.com/it/article/COLUMN/20091004/338335/)
ところが、この TClose は、ロックしても読み書きできる状態になっていた。これを突いたのが sinkclose だった。(参考 : https://media.defcon.org/DEF%20CON%2032/DEF%20CON%2032%20presentations/DEF%20CON%2032%20-%20Enrique%20Nissim%20Krzysztof%20Okupski%20-%20AMD%20Sinkclose%20Universal%20Ring-2%20Privilege%20Escalation.pdf)
個人的には、現代では使いみちがなさそうなTCloseよりは全員が必要とするLocal APICを突いてるほうが面白いなと思ったので、sinkclose については特に解説しない。気になった人は上の資料を参照してほしい。
まー個人的にはSMMなんてあるのがいけないと思いますね。BIOSがドライバの仕事を半分やっていた時代ならともかく、ドライバがほぼ全てOSに移ってしまった現代なら、SMMではなく普通にドライバとして実装すべきだと思いますよ。
まとめ
PC は、かなり複雑な方法で、MMIO or DRAMアクセスを区別してることが分かったかと思う。
また、複雑なので、穴になりやすいという話でもある。
次回、DRAM初期化の第一段階、SMBusを使ってDRAMの情報を取得する話に続くhttps://qiita.com/tanakmura/items/1951f3f6ff84fd27fe31