MRCTF 2022 复现(一)一道非常有意思的misc (pdd)

这是一道web+crypto题,第一次让我切实体会到ECB加密模式所存在一大缺陷,就是难以抵抗统计攻击,相同明文所对应的密文是一样的,在知道一些明文-密文片段的情况下,可以通过拼接得到我们想要的密文。

简单来说EBC模式下图所示,明文先被分成固定长度的分组,随后每一组进行相同的加密操作得到对应的密文分组,再将其拼接起来即可。

话不多说直接看题目,打开是一个抽奖页面,正常情况下有十次机会,当进度达到100/100的时候即可得到flag。

通过更改X-Forwarded-For修改ip发现剩余抽奖次数刷新,也就是说可以通过无限次抽奖,于是编写代码进行自动抽奖,把进度抽到100那不就可以得到flag了吗?但是pdd终究还是不能信的,抽了白天从99.0抽到了0.99999999999991然后就直接跳到了0.9999987,想要通无限抽奖来使进度达到100是不可能的。

只能寻找别的办法,先来看一下源码,看看具体是干了什么事情:

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
help(t) {
this.$axios.get(`/lucky.php?action=help&udb=${t}`).then((t => {
console.log(t.data);
let e = t.data;
200 === t.data.code ? this.$vToastify.success(e.detail) : this.$vToastify.error(e.detail)
}))
},
randomString(t) {
t = t || 32;
let e = "ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678",
n = e.length,
r = "";
for (let o = 0; o < t; o++) r += e.charAt(Math.floor(Math.random() * n));
return r
},
start() {
let t = "user_" + this.randomString(6);
this.$axios.post("/lucky.php?action=start", { username: t }).then((t => {
console.log(t.data);
let e = t.data;
if (200 === t.data.code) {
let n = t.data.enc;
this.setEnc(n), this.user = { username: e.username, times: e.times, money: e.money },
this.sharelink = `/?udb=${e.userdb}`, this.remain = 100 - this.user.money,
alert(`运气王!恭喜你还差${this.remain}就能免费拿flag`)
} else this.$vToastify.error(e.detail)
}))
},
getUserInfo() {
this.$axios.post("/lucky.php?action=info", { enc: this.getEnc() }).then((t => {
let e = t.data;
if (200 == t.data.code) {
let n = t.data.enc;
this.setEnc(n), this.user = { username: e.username, times: e.times, money: e.money },
this.remain = 100 - this.user.money, this.sharelink = `/?&udb=${e.userdb}`,
100 === e.money && this.getflag()
} else this.$vToastify.error(e.detail), this.logout()
}))
},
logout() { sessionStorage.clear(), localStorage.clear() },
setEnc(t) { sessionStorage.setItem("enc", t), localStorage.setItem("enc", t) },
getEnc() { return localStorage.getItem("enc") },
getFlag() {
this.$axios.post("/lucky.php?action=getFlag", { enc: this.getEnc() }).then((t => {
let e = t.data;
200 === t.data.code ? this.$vToastify.success(e.flag) : this.$vToastify.error(e.detail)
}))
},
startCallback() {
this.$refs.myLucky.play(), this.$axios.post("/lucky.php?action=lucky",
{ enc: this.getEnc() }).then((t => {
let e = t.data;
if (200 == t.data.code) {
let n = t.data.enc;
this.setEnc(n), this.remain -= e.bonus, this.index = e.arg
} else this.index = -1, this.$vToastify.error(e.detail);
this.$refs.myLucky.stop(this.index)
}))
},
endCallback() {
this.getUserInfo(), -1 != this.index && alert(`太棒了${this.prizes[this.index].fonts[0].text}一刀!还剩${this.remain}就能免费拿flag了`) }
},
mounted() {
this.getQueryVariable("udb") && this.help(this.getQueryVariable("udb"));
let t = localStorage.getItem("enc");
t ? this.getUserInfo() : this.start()
}
},

它的运行逻辑为打开网页开始抽奖前会先判断当前是否已经登录,如果没有登录就会随机生成一个用户名进行注册,随后才可以进行抽奖。每次抽奖都会发送enc给服务器,服务器运行抽奖后就会生成新的enc,同时也会返回进度money、次数time、奖励bonus、和序列化信息debug等,如果进度达到100就会获取flag。那么我们就能够从这段代码中提取出与服务器进行交互的数据接口:

url 参数 返回值
/lucky.php?action=help&udb= udb=用户数据库信息 用户查询结果
/lucky.php?action=start username code,enc,username,times,money,remain,userdb,debug
/lucky.php?action=info enc code,enc,username,money,times,userdb
/lucky.php?action=getFlag enc code,flag/detail
/lucky.php?action=lucky enc code,arg,remain,bonus,money,debug

每次抽奖都会交互一个键名为enc键值为base64字符串的键值对,同时还有一个键名为debug键值一串序列化字符串的键值对,而且enc的值和debug的值的变化都是局部变化,而且变化的位置是相对应的,猜想enc的值是对debug的值的加密,而且加密模式很有可能是ECB模式。因为用户名username是可控的,所以可以来验证一下这个猜想,发送几个固定长度的username后观察enc的变化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
debug序列化字符串依次为:(长度73)
O:4:"User":3:{s:8:"username";s:3:"123";s:5:"times";i:0;s:5:"money";i:50;}
O:4:"User":3:{s:8:"username";s:3:"120";s:5:"times";i:0;s:5:"money";i:62;}
O:4:"User":3:{s:8:"username";s:3:"110";s:5:"times";i:0;s:5:"money";i:57;}

相对应的enc依次为:
SRMr2xR0uuLsQScgoAegYwrQrdxDA2bfJG2zu/f8n1+A+TMB5YDee3ol2o8qhvX96lZQYpXeLEVisXnuj463nKa6JLxkUp8N6AbFMBuadHI=
SRMr2xR0uuLsQScgoAegYwrQrdxDA2bfJG2zu/f8n1+ytSKLMwHB+38uW0MmS4oA6lZQYpXeLEVisXnuj463nIf5+8R4W7nifRCmLvINSgw=
SRMr2xR0uuLsQScgoAegYwrQrdxDA2bfJG2zu/f8n1/0lOjp3Dr/NylSajbI9u+p6lZQYpXeLEVisXnuj463nLZp2pD6RKVo7oQHJxSovWA=

把enc转为16进制数并按32一组分隔开:(长度为160)
49132bdb1474bae2ec412720a007a063 0ad0addc430366df246db3bbf7fc9f5f 80f93301e580de7b7a25da8f2a86f5fd ea56506295de2c4562b179ee8f8eb79c a6ba24bc64529f0de806c5301b9a7472

49132bdb1474bae2ec412720a007a063 0ad0addc430366df246db3bbf7fc9f5f b2b5228b3301c1fb7f2e5b43264b8a00 ea56506295de2c4562b179ee8f8eb79c 87f9fbc4785bb9e27d10a62ef20d4a0c

49132bdb1474bae2ec412720a007a063 0ad0addc430366df246db3bbf7fc9f5f f494e8e9dc3aff3729526a36c8f6efa9 ea56506295de2c4562b179ee8f8eb79c b669da90fa44a568ee84072714a8bd60

观察到debug字符串的长度刚好接近enc的十六进制数表示的二分之一,而1个字符占8比特,一个十六进制数能够表示4比特,两个十六进制数就可以表示一个字符。那么160个十六进制数能够表示80个字符,也就是说debug的序列化字符串会被填充到80个,80=16*5,这里猜测分组加密是16个字符(32个十六进制数)一组进行加密的,如果序列化字符串不够16的整数倍的话,则会被填充,至于填充的内容是什么呢那就不得而知了。观察这三个enc的十六进制发现只有第3、5组是不一样的,结合这三个enc所表示的username和money不一样,猜测这两个部分应该就是用户名和当前进度所在的位置,至于分别对应那一个我们可以通过拼接enc然后给向服务器查询来获知。

将第一个enc的第三部分替换成第二个enc的第三部分,然后提交到/lucky.php?action=info进行查询就可获得拼接成的enc所表示的信息,通过返回的debug可以发现第三部分表示的是有关用户名的信息,那第五部分就是表示当前进度的块了。如下所示将debug和enc分别按16和32分组对齐:

image-20220430014635634

可以观察到其实就是直接把debug序列化字符串拿去分组加密了,这样的话,我们可以通过控制发送给/lucky.php?action=start的username来获取到任意块的密文也就是所其实我们是得到了一个加密机。那么接下来就是利用加密机来构造出表示进度为100的密文块,从而拼接得到进度为100的用户enc,从而获取flag。

在返回得debug序列化字符串中,包含的内容分别为用户名username,抽奖次数times,当前进度money,我们需要将money的值替换为100,但是其所在位置并不是完整的一个分组所以会被填充字符,在不知道填充字符是什么的情况下我们并不能够伪造得到相应的密文,比赛的时候也是踩了这个坑,去试了好几个填充都不对。等到后来看大佬的做法才发现,原来可以把money的位置前移,使其不会在最后那么也就不必考虑填充的问题。

如下图所示,控制username长度使得分组便于我们进行操作,把原始序列化对象的money属性前移,随后补上其值,最后再将属性补全,因为times是供客户端判断,修改掉并不会产生影响,这样也就构造出了进度为100的用户序列化对象,接下来就是获取相对于的密文了。

前文提到我们只需要控制username就能够得到对应块的密文,所以我们构造内容为0000000000000";s:5:"money";i:100;s:3:"tim";i:的用户名,这样就可以得到上图两个修改后的蓝色块所对应的密文:

1
2
3
4
5
6
7
8
9
10
O:4:"User":3:{s:	49132bdb1474bae2ec412720a007a063
8:"username";s:4 15f621f17ab386f772f62c463899dedd
5:"0000000000000 506ffa9bb6c9e6203423f7dff0acb353

";s:5:"money";i: 1d56d601aaa47d48b75190a7495101c2
100;s:3:"tim";i: db9942cb8f914708c6c3265bd825b812

";s:5:"times";i: ab18b48b4d2709573c5f69fdd22e053c
0;s:5:"money";i: a06e52b7688f7cf039c751ff8b5c30c5
63;} fe58b84deeb172bbc47e811c80434ac4

再将其替换掉原有的密文相应的部分:

1
2
3
4
5
6
O:4:"User":3:{s:	49132bdb1474bae2ec412720a007a063
8:"username";s:1 0973f6da1decade02d33d372a3869726
3:"1230000000000 b01de36b0bca82112784705763ee8073
";s:5:"times";i: ab18b48b4d2709573c5f69fdd22e053c ==> ";s:5:"money";i: 1d56d601aaa47d48b75190a7495101c2
0;s:5:"money";i: a06e52b7688f7cf039c751ff8b5c30c5 ==> 100;s:3:"tim";i: db9942cb8f914708c6c3265bd825b812
68;} ee6c3c234980cf4a510ee40f34ca4a14

即可得到我们想要的密文:

1
2
3
4
5
6
O:4:"User":3:{s:	49132bdb1474bae2ec412720a007a063
8:"username";s:1 0973f6da1decade02d33d372a3869726
3:"1230000000000 b01de36b0bca82112784705763ee8073
";s:5:"money";i: 1d56d601aaa47d48b75190a7495101c2
100;s:3:"tim";i: db9942cb8f914708c6c3265bd825b812
68;} ee6c3c234980cf4a510ee40f34ca4a14

将十六进制表示转为base64:

1
enc=SRMr2xR0uuLsQScgoAegYwlz9tod7K3gLTPTcqOGlyawHeNrC8qCESeEcFdj7oBzHVbWAaqkfUi3UZCnSVEBwtuZQsuPkUcIxsMmW9gluBLubDwjSYDPSlEO5A80ykoU

发送给/lucky.php?action=getFlag即可得到flag:

1
{"code":"200","flag":"MRCTF{Xi_Xi0ngDi_9_Na_Kan_w0!}"}

转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 hututu1024@126.com