RでRStudio Serverのログイン画面を突破する

ちょっとやろうと思っていることがあったので試してみたときのメモです。

とりあえずGET

何も考えずにGET()してみます。

library(httr)

res <- GET("localhost:8787")
res
#> Response [http://localhost:8787/unsupported_browser.htm]
#>   Date: 2016-12-03 22:44
#>   Status: 200
#>   Content-Type: text/html
#>   Size: 894 B
#> <html>
#> 
#> <head>
#> <title>RStudio: Browser Not Supported</title>
#> 
#> <link rel="stylesheet" href="rstudio.css" type="text/css"/>
#> <link rel="shortcut icon" href="images/favicon.ico" />
#> 
#> <style type="text/css">
#> 
#> ...

unsupported browserと言われるので、User Agentを偽らなくてはいけません。適当に、手元のFirefoxと同じUser Agentにしてみます。

res <- GET("localhost:8787",
           user_agent("Mozilla/5.0 (Windows NT 10.0; WOW64; rv:50.0) Gecko/20100101 Firefox/50.0"))
res
#> Response [http://localhost:8787/auth-sign-in]
#>   Date: 2016-12-03 22:49
#>   Status: 200
#>   Content-Type: text/html
#>   Size: 6.18 kB
#> <!DOCTYPE html>
#> 
#> <!--
#> #
#> # encrypted-sign-in.htm
#> #
#> # Copyright (C) 2009-12 by RStudio, Inc.
#> #
#> # This program is licensed to you under the terms of version 3 of the
#> # GNU Affero General Public License. This program is distributed WITHOUT
#> ...

今度はうまくいってそうです。

ログインフォーム

次に、ログインフォームを探します。

library(rvest)

content(res) %>%
  html_form()
#> No encoding supplied: defaulting to UTF-8.
#> [[1]]
#> <form> '<unnamed>' (POST javascript:void)
#>   <input text> 'username': 
#>   <input password> 'password': 
#>   <input checkbox> 'staySignedIn': 1
#>   <input hidden> 'appUri': 
#>   <button submit> '<unnamed>
#> 
#> [[2]]
#> <form> 'realform' (POST auth-do-sign-in)
#>   <input hidden> 'persist': 
#>   <input hidden> 'appUri': 
#>   <input hidden> 'clientPath': 
#>   <input hidden> 'v': 

なぜか2つ出てきます。たぶんrealformっていう方が名前からして本物っぽいです。<unnamed>ってなっているのが目に見えている方のフォームで、こっちを送信するとJavaScriptがいろいろ動いてrealformが送信されるのでしょう。

scriptを解読

scriptタグを抽出してみます。

scripts <- content(res) %>%
  html_nodes("script")

scripts
#> No encoding supplied: defaulting to UTF-8.
#> {xml_nodeset (4)}
#> [1] <script language="javascript"><![CDATA[\nfunction verifyMe()\n{\n   if(do ...
#> [2] <script type="text/javascript" src="js/encrypt.min.js"/>
#> [3] <script type="text/javascript"><![CDATA[\nfunction prepare() {\n   if (!v ...
#> [4] <script type="text/javascript"><![CDATA[\ndocument.getElementById('userna ...

いろいろ見比べた結果、どうやら3つ目が本物のようです。

cat(as.character(scripts[[3]]))
#> <script type="text/javascript"><![CDATA[
#> function prepare() {
#>    if (!verifyMe())
#>       return false;
#>    try {
#>       var payload = document.getElementById('username').value + "\n" +
#>                     document.getElementById('password').value;
#>       var xhr = new XMLHttpRequest();
#>       xhr.open("GET", "auth-public-key", true);
#>       xhr.onreadystatechange = function() {
#>          try {
#>             if (xhr.readyState == 4) {
#>                if (xhr.status != 200) {
#>                   var errorMessage;
#>                   if (xhr.status == 0)
#>                      errorMessage = "Error: Could not reach server--check your internet connection";
#>                   else
#>                      errorMessage = "Error: " + xhr.statusText;
#>                      
#>                   var errorDiv = document.getElementById('errorpanel');
#>                   errorDiv.innerHTML = '';
#>                   var errorp = document.createElement('p');
#>                   errorDiv.appendChild(errorp);
#>                   if (typeof(errorp.innerText) == 'undefined')
#>                      errorp.textContent = errorMessage;
#>                   else
#>                      errorp.innerText = errorMessage;
#>                   errorDiv.style.display = 'block';
#>                }
#>                else {
#>                   var response = xhr.responseText;
#>                   var chunks = response.split(':', 2);
#>                   var exp = chunks[0];
#>                   var mod = chunks[1];
#>                   var encrypted = encrypt(payload, exp, mod);
#>                   document.getElementById('persist').value = document.getElementById('staySignedIn').checked ? "1" : "0";
#>                   document.getElementById('package').value = encrypted;
#>                   document.getElementById('clientPath').value = window.location.pathname;
#>                   document.realform.submit();
#>                }
#>             }
#>          } catch (exception) {
#>             alert("Error: " + exception);
#>          }
#>       };
#>       xhr.send(null);
#>    } catch (exception) {
#>       alert("Error: " + exception);
#>    }
#> }
#> function submitRealForm() {
#>    if (prepare())
#>       document.realform.submit();
#> }
#> ]]></script>

まず、この行で公開鍵を取ってきているようです。

      xhr.open("GET", "auth-public-key", true);

そしてそれを用いて、このpayloadというやつを、

      var payload = document.getElementById('username').value + "\n" +
                    document.getElementById('password').value;

こんな感じで暗号化しています。

                  var response = xhr.responseText;
                  var chunks = response.split(':', 2);
                  var exp = chunks[0];
                  var mod = chunks[1];
                  var encrypted = encrypt(payload, exp, mod);

encryptという関数は、名前からして上のscriptsの2つ目にあったjs/encrypt.min.jsで定義されてそうな雰囲気です。

scripts[[2]]
#> {xml_node}
#> <script type="text/javascript" src="js/encrypt.min.js">

V8でむりやり動かす

V8パッケージで動くか試してみましょう。

library(V8)

encryptjs <- content(GET("http://localhost:8787/js/encrypt.min.js"), as = "text")

ct <- new_context()
ct$eval(encryptjs)
#> Error in context_eval(join(src), private$context) : 
#>   ReferenceError: navigator is not defined

なにかエラーが出ます。ググってみたところ、このnavigatorという変数は、ブラウザの情報を保持しているもののようです。

developer.mozilla.org

ということで、これを適当に定義してやればよさそうです。どういう使われ方をしているかもう少し詳しく見てみます。このJavaScriptはminifyされているので、もとのソースを見るほうが読みやすそうです。

navigatorが出てくるのは以下の3か所です。

if(j_lm && (navigator.appName == "Microsoft Internet Explorer")) {
else if(j_lm && (navigator.appName != "Netscape")) {
 if(navigator.appName == "Netscape" && navigator.appVersion < "5" && window.crypto) {

どうやら、ブラウザの種類によって条件分岐があるようです。とりあえず、appNameappVersionをつくってやればよさそうです。これでもう一度チャレンジしてみます。

ct$eval("var navigator = {appName: 'Netscape', appVersion: '5.0 (windows)'};")
ct$eval(encryptjs)
#> Error in context_eval(join(src), private$context) : 
#>   ReferenceError: window is not defined

windowがないと言われます。これはググったらタイポさんがIssueを挙げてるのを見つけました。new_context()に引数を渡せば、globalオブジェクトをwindowという名前にすることができます。

github.com

ct <- new_context("window")
ct$eval("var navigator = {appName: 'Netscape', appVersion: '5.0 (windows)'};")
ct$eval(encryptjs)
#> [1] "function (b,a,c){var d=new $;d.X(c,a);b=d.N(b);d=\"\";for(a=0;a+3<=b.length;a+=3){c=parseInt(b.substring(a,a+3),16);d+=\"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/\".charAt(c>>6)+\"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/\".charAt(c&63)}if(a+1==b.length){c=parseInt(b.substring(a,a+1),16);d+=\"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/\".charAt(c<<2)}else if(a+2==b.length){c=parseInt(b.substring(a,a+2),16);d+=\"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/\".charAt(c>>\n2)+\"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/\".charAt((c&3)<<4)}for(;(d.length&3)>0;)d+=\"=\";return d}"

今度はうまくいきました。encryptができているか確かめましょう。

ct$get(JS('Object.keys(window)'))
#>  [1] "console"      "print"        "window"       "ArrayBuffer"  "Int8Array"   
#>  [6] "Uint8Array"   "Int16Array"   "Uint16Array"  "Int32Array"   "Uint32Array" 
#> [11] "Float32Array" "Float64Array" "DataView"     "navigator"    "g"           
#> [16] "j"            "k"            "l"            "m"            "o"           
#> [21] "p"            "s"            "t"            "u"            "x"           
#> [26] "z"            "A"            "B"            "D"            "E"           
#> [31] "F"            "H"            "I"            "J"            "K"           
#> [36] "L"            "M"            "N"            "aa"           "ba"          
#> [41] "ca"           "da"           "ea"           "fa"           "ga"          
#> [46] "ha"           "ja"           "P"            "ka"           "la"          
#> [51] "ma"           "na"           "oa"           "pa"           "Q"           
#> [56] "qa"           "ra"           "sa"           "ta"           "ua"          
#> [61] "va"           "wa"           "xa"           "G"            "O"           
#> [66] "R"            "ya"           "za"           "S"            "T"           
#> [71] "U"            "V"            "W"            "X"            "Y"           
#> [76] "Aa"           "Ba"           "Z"            "$"            "Ca"          
#> [81] "Da"           "Ea"           "encrypt"     

ちゃんと定義されていますね。大丈夫そうです。

では、いよいよこれを使ってRStudio Serverにログインしてみましょう。

# 公開鍵を取得
response <- content(GET('http://localhost:8787/auth-public-key'))

ct$assign("response", response)
ct$eval("var chunks = pubkey.split(':', 2);
         var exp = chunks[0];
         var mod = chunks[1];")

# ペイロードを渡して公開鍵で暗号化
ct$assign("payload", paste("rs", "rstudio", sep = "\n"))
v <- ct$eval("encrypt(payload, exp, mod);")

# vは暗号化されたペイロード。後のパラメータは適当に埋める。
res <- POST("http://localhost:8787/auth-do-sign-in",
     body = list(
       persist = "0",
       appUri = "",
       clientPath = "",
       v = v
     ),
     user_agent("Mozilla/5.0 (Windows NT 10.0; WOW64; rv:50.0) Gecko/20100101 Firefox/50.0"))

成功していれば、http://localhost:8787/にリダイレクトされているはずです。httrは自動でリダイレクトも追ってくれて、responseオブジェクトをそのまま表示したときは最終的な結果のみが表示されます。

res
#> Response [http://localhost:8787/]
#>   Date: 2016-12-04 00:50
#>   Status: 200
#>   Content-Type: text/html
#>   Size: 899 B
#> <!DOCTYPE html>
#> ...snip...

リダイレクトのレスポンスも見たければ、all_headersという要素に格納されています。Cookieも見ることができます。

purrr::map_int(res$all_headers, "status")
#> [1] 302 200

res$all_headers[[1]]$headers$location
#> [1] "http://localhost:8787/"

res$all_headers[[1]]$headers$`set-cookie`
#> [1] "user-id=...|...GMT|...; path=/; HttpOnly"

ほんとはV8使わなくてもいいはず...

あまり理解できてないんですが、modulusとpublic exponentがあれば暗号化はできるはずなので、V8は使わなくてもいいはずです。ちなみにこのJavaScriptのソースは以下で公開されているもののようです。

RSA and ECC in JavaScript

こういう感じ?

rsa - The Go Programming Language