*파트너스 활동을 통해 일정액의 수수료를 제공받을 수 있음

개발 블로그를 개설하고 처음으로 쓰는 글이면서 새로운 마음가짐으로 시작을 하게된다.
처음 스타트를 어떻게 이어갈까 하다가 얼마전 있었던 (보안관련) 에피소드를 하나 적어보려고한다.


지난 9월 마지막 주간이었나 그쯔음 있었던 일이었다.
이제 개발에 입문한지 얼마되지 않아 개발을 하면서 다른 웹사이트를 탐방하며 겪었던 에피소드이다. 개발에 들어선지 얼마 되지않아 타 웹사이트를 탐방하게 된것이 사건의 시작이었다.

슬슬 눈에 코드가 들어오고 돌아가는 구조가 보이게되면 타 사이트를 분석도 해보고 장난질? 도 해본 경험들쯤은 아마 있었을것 같다.
실제로 정말 허접하게 개발된 사이트도 많이 만나게되었고 웬만하게 잘 많들어놓은 사이트도 대다수 많이있었다.
그런데 이번에는 정말 큰 회사 넥슨 의 페이지를 장난질? 하다가 웹에서 클라이언트 데이터조작이 되어버린 사례를 공개하려고 한다.

#서버와 클라이언트의 역할

먼저 웹에서는 크게 서버와 클라이언트로 나누어 볼 수가 있다.
웹을 개발할때에 특별한케이스가 아니라면 클라이언트쪽에서는 데이터를 조작하지 못하게 막아두는것이 맞을것이다.
기본이지만 보통 데이터를 처리하는 하는것은 서버에서 처리를 한다.

요즘은 Ajax 비동기 통신을 통해 클라이언트와 서버쪽이 json데이터로 통신을 하게된다. 그렇지만 json은 서버와 클라이언트의 중간에서 json형식으로 데이터를 주고받기만 할뿐 처리를 하는부분은 서버에서 전달받은 데이터로 처리를 하게된다.

1
클라이언트 (Ajax 요청) --> 서버 (응답 --> 내부적 처리 --> 클라이언트 전달) --> 클라이언트 처리된 결과 수신

크게보면 웹사이트는 대부분 이러한 구조로 돌아가고있다.
물론 처리부분에서 내부적으로 많은일이 일어나겠지만 생략하겠다.

중요한 사실은 클라이언트쪽(html/javascript…등)은 브라우저에서 얼마든지 조작하고 넘겨버릴 수 있기때문에!!
“스크립트로직으로 데이터 무결성을 책임지겠다?” 라는말은 절대로 있어서도 안될 일일 것이다.

그래서 서버와 클라이언트의 역할은 분명한 것이다. 클라이언트측에서 보안을 담당하는것은 절대로 있어서도 안될것이다. 나도 처음 아무것도 몰랐을때에 스크립트 로직으로 보안을 담당하려고했던 어리석은 시절이 있었기때문에 누구나 모르면 실수할 수도있다. 그렇지만 두번다시 반복된 실수는 있어서는 안된다.

이것이 왜 절대로 안되는것인지 간단한 예시를 통해서 보여주도록 하겠다.

국내 커뮤니티 뽐뿌 - 데이터 조작전 예시국내 커뮤니티 뽐뿌 - 데이터 조작전 예시

허접한 페이지를 찾을수가 없어 정상적인 게시판을 통해 이야기를 하겠습니다.
노란 하이라이트를 보게되면 삭제하기버튼에 링크가 걸려있는게 delete명령을 수행하는 get방식 주소이다. url방식으로 서버에 정의해놓은 delete방식의 양식에따라 요청이 들어오면 삭제명령이 수행되는 것이다.
key값으로 게시글번호가 있다. 개발자도구로 이 게시글 번호쯤이야 조작해서 변경해버릴 수가 있다. 그래서 게시글 번호를 조작한 다음에 서버에 요청을 하면 어떤 결과가 일어날까? 내 게시글이 아닌 다른사람의 게시글이 삭제가 될까?

국내 커뮤니티 뽐뿌 - 데이터 조작한 결과국내 커뮤니티 뽐뿌 - 데이터 조작한 결과

결론은 삭제가 되지 않는다. 왜냐하면! 클라이언트쪽에서 얼마든지 key값을 변경할 수 있기 때문이다. 삭제명령이 들어가면 보통 세션에 담겨있는 로그인 정보를 통해 확인을 하는 절치를 밟는다. 그래서 클라이언트쪽에서 로직으로 막아놓는것이 아니라 이것은 서버에서 담당을 해야하는 일이다.
아마 웬만한 사이트는 이정도의 보안취약점을 달고다니진 않을것이다..

그런데 서론과 제목에 이야기했다시피 넥슨에 관련된 보안취약점을 발견한 것이다. 도대체 어떤 실수를 했는지 한번 알아보도록 하겠다.

#Ajax data를 가로채보자

내가 가끔 즐겨하는 게임이 있다. 그것은 바로 넥슨에 있는 피파온라인4
이 사건이 일어냈을때쯤은 아마 추석연휴였던것같다. 정확히는 기억이 나지 않는다.

피파온라인은 웹페이지에서 이벤트를 참 많이하는 게임중 하나이다.
예를들어 주사위 굴리기, 아이템 소비해서 랜덤하게 뽑기 등..

시간이 오래되어 직접적인 코드는 볼순없지만 비슷한 구조로 되어있기때문에 현재 이벤트중인 페이지를 놓고 이야기를 해보겠다.

FIFA ONLINE4 이벤트 스크립트 일부분
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
var E181018AllInOne = new function () {

this.GetMyInfoForHeader = function (obj) {

this.GetMyInfo = function () {
if (DefaultCheck()) {
checkLoad();
$("#GetMyInfo").attr("onclick", "return false;");
setTimeout(function () {
$.ajax({
type: "GET",
contentType: "application/x-www-form-urlencoded; charset=UTF-8",
url: '/181018/AllInOneAjaxInfo',
dataType: 'json',
data: { "strMethod": "getMyInfo", rd: Math.random() },
success: function (data) {
if (data.retCode == 0) {
$("#divUseAblePoint").html(comma(data.n4TotalPoint - data.n4TotalUsePoint));
$("#divTodayPoint").html(comma(data.n4TodayPoint));

$("#strTodayPoint").html(comma(data.n4TodayPoint) + "P");

$(".coach_info").find(".mypoint").html(comma(data.n4TodayPoint));
$("#divTotalPoint").html(comma(data.n4TotalPoint));
$("#divWeekUseAblePoint").html(comma(data.n4ThisWeekRemainPoint));
$("#divWeekUsePoint").html(comma(data.n4ThisWeekUsePoint));

$("#strWeekPoint").html(comma(data.n4ThisWeekUsePoint) + "P");

$("#divTotalUseAblePoint").html(comma(data.n4TotalUsePoint - data.n4TotalChagePoint));
$("#divTotalUsePoint").html(comma(data.n4TotalUsePoint));

if (data.n4ThisWeekRemainPoint >= 100) {
$(".eventinfo2").removeClass("disable");
}
else {
$(".eventinfo2").addClass("disable");
}

if (data.n4TotalUsePoint - data.n4TotalChagePoint >= 150) {
$(".eventinfo3").removeClass("disable");
}
else {
$(".eventinfo3").addClass("disable");
}

$("#EventInfo1").html(data.strEventHtml1);
$("#EventInfo2").html(data.strEventHtml2);
$("#EventInfo3").html(data.strEventHtml3);

$(".group-a").hide();
$(".group-b").hide();
if (data.n1ShowType == 1) {
$(".group-a").show();
}
if (data.n1ShowType == 2) {
$(".group-b").show();
}

$(".coach_info").find(".date").html(data.n4CurrentWeek + "주차 " + data.strToday);

if (data.n4CurrentWeek > 3) {
$("#secEvent2").removeClass("before");
}
}
else if (data.retCode == 1) {
CommonEvent.Login();
}
else if (data.retCode == 100) {
ShowMessage(data.retMsg);
}
else if (data.retCode < 100) {
ShowMessage(GetMessage(data.retCode));
}
else {
ShowMessage("정의 되지 않은 오류가 발생 하였습니다.");
}

checkLoadClear();
$("#GetMyInfo").attr("onclick", "E181018AllInOne.GetMyInfo(); return false;");
},
error: function (xhr, status, exception) {
ShowMessage("죄송합니다.\n조회에 실패하였습니다.");
$("#GetMyInfo").attr("onclick", "E181018AllInOne.GetMyInfo(); return false;");
checkLoadClear();
}
});
}, 1000);
}
else {
checkEnd();
$("#GetMyInfo").attr("onclick", "E181018AllInOne.GetMyInfo(); return false;");
}
return false;
},

if (DefaultCheck()) {
$checkOn();
$(obj).attr("onclick", "return false;");
setTimeout(function () {
$.ajax({
type: "GET",
contentType: "application/x-www-form-urlencoded; charset=UTF-8",
url: '/181018/AllInOneAjaxInfo',
dataType: 'json',
data: { "strMethod": "getMyInfo", rd: Math.random() },
success: function (data) {
if (data.retCode == 0) {
if (location.href.toLowerCase().indexOf("/181018/allinone") > -1) { //현재 페이지가 올인원 페이지 라면
E181018AllInOne.GetMyInfo();
}

$("#strTodayPoint").html(comma(data.n4TodayPoint) + "P");
$("#strWeekPoint").html(comma(data.n4ThisWeekUsePoint) + "P");

$(".group-a").hide();
$(".group-b").hide();
if(data.n1ShowType == 1)
{
$(".group-a").show();
}
if (data.n1ShowType == 2)
{
$(".group-b").show();
}

}
else if (data.retCode == 1) {
CommonEvent.Login();
}
else if (data.retCode == 100) {
ShowMessage(data.retMsg);
}
else if (data.retCode < 100) {
ShowMessage(GetMessage(data.retCode));
}
else {
ShowMessage("정의 되지 않은 오류가 발생 하였습니다.");
}

$checkOff();
$(obj).attr("onclick", "E181018AllInOne.GetMyInfoForHeader(this); return false;");
},
error: function (xhr, status, exception) {
ShowMessage("죄송합니다.\n조회에 실패하였습니다.");
$(obj).attr("onclick", "E181018AllInOne.GetMyInfoForHeader(this); return false;");
$checkOff();
}
});
}, 1000);
}
else {
checkEnd();
$("#GetMyInfo").attr("onclick", "E181018AllInOne.GetMyInfo(); return false;");
}
return false;
},
this.ChangeConfirm = function (obj, type1, point, n4currentweek) {
if (DefaultCheck()) {
var ThisUseAblePoint = parseInt(uncomma($("#divUseAblePoint").html()));
var ThisWeekUsePoint = parseInt(uncomma($("#divWeekUseAblePoint").html()));
var ThisTotalUsePoint = parseInt(uncomma($("#divTotalUseAblePoint").html()));


if(type1 == 1)
{
$(".layer_random .layerContent .txt").html("<strong>5 포인트(P)</strong>를 사용하여<br/>뽑기를 진행하시겠습니까?");
$(".layer_random .layerContent .box .get_point .c").html("-5");
$(".layer_random .layerContent .box .get_point .r").html(comma(ThisUseAblePoint - 5));
$(".layer_random .layerContent .box .week_point .c").html(n4currentweek == 7 ? "+0" : "+5");
$(".layer_random .layerContent .box .week_point .r").html(n4currentweek == 7 ? comma(ThisWeekUsePoint) :comma(ThisWeekUsePoint + 5));
$(".layer_random .layerContent .box .total_point .c").html("+5");
$(".layer_random .layerContent .box .total_point .r").html(comma(ThisTotalUsePoint + 5));


layerPopupOpen('layer_random');
$(".layer_random").find(".btn_confirm").attr("onclick", "E181018AllInOne.ChangeGo(this, 1); return false;");
}
else if(type1 == 2)
{
$(".layer_random .layerContent .txt").html("<strong>30 포인트(P)</strong>를 사용하여<br/>뽑기를 진행하시겠습니까?");
$(".layer_random .layerContent .box .get_point .c").html("-30");
$(".layer_random .layerContent .box .get_point .r").html(comma(ThisUseAblePoint - 30));
$(".layer_random .layerContent .box .week_point .c").html(n4currentweek == 7 ? "+0" : "+30");
$(".layer_random .layerContent .box .week_point .r").html(n4currentweek == 7 ? comma(ThisWeekUsePoint) : comma(ThisWeekUsePoint + 30));
$(".layer_random .layerContent .box .total_point .c").html("+30");
$(".layer_random .layerContent .box .total_point .r").html(comma(ThisTotalUsePoint + 30));

layerPopupOpen('layer_random');
$(".layer_random").find(".btn_confirm").attr("onclick", "E181018AllInOne.ChangeGo(this, 2); return false;");
}
else if(type1 == 3 || type1 == 4) {
layerPopupOpen('layer_change');
$(".layer_change .layerContent .txt").html("<strong>" + point + "포인트(P)</strong>를 사용하여<br/> 주간 보상 교환을 진행하시겠습니까?");
$(".layer_change").find(".btn_confirm").attr("onclick", "E181018AllInOne.ChangeGo(this, " + type1 + "); return false;");
}
else {
layerPopupOpen('layer_change');
$(".layer_change .layerContent .txt").html("<strong>" + point + "</strong>포인트(P)를 사용하여<br/> 누적 보상 교환을 진행하시겠습니까?");
$(".layer_change").find(".btn_confirm").attr("onclick", "E181018AllInOne.ChangeGo(this, " + type1 + "); return false;");
}
}
}

사실 코드 내용보다는 구조적인것을 설명하기위해서 코드를 가져다가 붙여놓았다.

프론트단에서는 모듈형식으로 로직이 짜여져있다. 서버와 통신은 전부 Ajax(json)로 통신을 하고있는 구조이다.
서버에 포인트를 조회하라는 명령을 내리고 서버는 포인트를 조회해 Ajax(json)으로 넘겨주면 프론트에서 넘겨받은 데이터로 로직이 짜여있다.여기까지는 잘 되어있다.
그렇지만 방금전 국내 커뮤니티 뽐뿌 - 데이터 조작전 예시 를 봤으면 알겠지만 우리는 개발자도구로 이 모든것을 편집할 수가 있다.

HTML, CSS는 물론 JAVASCRIPT까지 모두 실시간으로 편집할 수가 있다.
그래서 나는 조회를 할때에 ajax통신을 하고난 후 그 결과의 data를 중간에서 가로채고 그 데이터를 변조하였다.

1
2
3
4
5
6
7
8
예를들어 이렇게 데이터를 받아온다고 가정했을때 중간에 그 데이터를 가로채서 변조하였다.
과정 ==>
data = {todayPoint:10, totalPoint:10} // 처음 받아온 값
data.todayPoint = 20;
data.totalPoint = 100;

결과 =>
data = {todayPoint:20, totalPoint:100} // 중간에 변조한 값

사실 Ajax통신한 결과값 데이터를 가로챈다는것은 매우 자연스러운일 일수도 있고 누구든지 쉽게 할 수 있는것이다.
나는 이러한 결과로 포인트가 적어서 활성화가 되지 않았던 버튼들이 활성화가 되었고 그 이후의 작업을 연장해 나갈 수가 있었다.

과연, 이러한 결과로 인해서 데이터 조작이 성공할까?

#넥슨의 실수

나는 가로챈 데이터를 통해서 어러 모듈에 얽여있는 잠금(?)들이 풀려나가서 Insert를 할수있는 환경을 만들어나갔다.
여태까지는 받아온 데이터를 동해서 웹페이지(View)쪽에서 어떤 변화가있고 데이터가 있을때와 없을때 어떻게 변화하는지 분석을 해보았다.
그 결과 이전에 없었던 Tag등을 발견할 수 있었고, 또 그 변화된 데이터로인해 이전에 실행되지 않았던 javascript모듈이 실행된 것을 알 수가 있었다.

그때 당시의 비슷한 HTML코드를 하나 첨부한다.
조작된 데이터로 받아온 List조작된 데이터로 받아온 List

1
onclick="...ChangeGo(this.x.y);" 부분에서 x부분이 아이템 고유번호였던것 같고 y는 이벤트 타입이였던것 같았다.

Ajax데이터를 한번 개로챘을뿐인데 이 많은 정보를 얻어낼 수가 있었다.

나는 핵심적인 데이터를 손에 얻었다. Item의 key값과 모듈이 움직이는 구조와 이전에 없었던 태그들을 생성해냈다. 이제 마지막단계인 Insert단계만 남았다. 물론 직접적인 Insert 쿼리를 날릴수는 없지만 약간의 편법을 사용해보려고했다.

이벤트 Item 5개를 모으면 새로운 아이템으로 교환해주는 시스템이 있었기 떄문이다.
이제 이 모든 값을 조합하여서 1개밖에 없었던 내 아이템을 HTML편집기를 사용하여 1개 -> 5개로 조작하였다. 그리고는 아이템교환을 신청했다.

아이템 교환 결과아이템 교환 결과
위의 사진을 보면 알겠지만 아래에 떡이 5개가있어야 아이템을 교환할 수가 있었다. 그렇지만 여러 데이터를 조작하고 약간의 머리를 굴려본 결과 이벤트에 참여가되었고 게임머니 500,000BP를 획득하는 어처구니없는 일이 발생하였다.

사실 될꺼라는 생각을 하지않고 시작하였다. 왜냐하면 서버측에서 나의 DB정보를 조회하고 이벤트에 참여시킬줄 알았다. 그런데 귀찮아서 개발을 안한건지 몰라서안한건지 알수는 없었지만 이벤트에 당첨되는 황당한결과를 만들어냈다. 글의 처음부분에 “#서버와 클라이언트의” 역할 에대해서 기술하였는데 방금전의 상황들은 서버와 클라이언트의 역할이 되지않고있었던 상황이었다. 나중에 소스코드를 들쳐보니 그냥 javascript로 Item의 Length를 검사하여 alert창만 띄우는 방식으로 처리가 되어있었다.

사실 이렇게 개발을하는것은 허접한 사이트나 정말 초급개발자가 해버리는 실수일 수밖에 없다. 그런데 넥슨쪽에서 이렇게 개발을 했다는것이 조금 실망으로 다가오기도 했다. 그러나 사람이 하는 일이라 누구나 실수는 있을수도있다. 나도 이번일을 통해서 진행중인 프로젝트 소스의 질을 높이는 시간이 되었다. 그리고 보안쪽에 공부를 하며 신경을 써야겠다는 생각이 참 많이드는 일들이었다.

이결과로 인해서 나는 넥슨쪽에 웹페이지의 취약점을 발견했다고 이메일을 보내는 것까지 조치를 취했다.
하루만에 넥슨에서 답장이 왔다.

#넥슨의 발빠른 조치

넥슨의 발빠른 조치넥슨의 발빠른 조치

조치는 생각보다 빠르게 되었다. 답장이 오기전 몇번더 테스트를 해봤는데 당일에 취약점은 패치가되었고 답장은 다음날에 왔다.
덕분에 3만원정도의 캐쉬를 선물받았다.. 용돈인가~_~?
사실 대단한 일은 아니지만 호기심으로 인해서 재미있는 경험을 해본 에피소드를 블로그에 담아 나누어봤다. 개발을 즐겁게 한다는것은 이런것이 아닐까 생각이 많이든다.

나는 호기심이 참 많아서 이상한(?)것을 좀 도전해보지만 그에따른 배움이 따라와서 기분은 좋다.
나의 소스코드의 질을 높이는 시간이 되었고 자신감이 붙는 시간이 되었다.

앞으로 즐기면서 개발하는 사람이 되도록 더더욱 생각이 든다. 즐겁게 개발하자..