K사 추가단말서비스 가입안내 분석

사건의 발단

집에서 인터넷을 사용하던 어느날 갑자기 이런 화면이 떴다.
저건 어떻게 뭘로 체크하는걸까 궁금해졌다.

분석 시작

일단, 저 페이지는 https를 사용하는 페이지에서는 뜨지 않고, http를 사용하는 페이지에서만 떴다.

K사는 MITM(man in the middle attack)을 통해, 유저의 http 트래픽을 가로챈뒤, 이상한 코드가 담긴 내용을 response로 주고 있었다.
(개인정보로 보여질 수 있는 부분은 모두 삭제했다)

K사의 http response
1
2
3
4
5
6
7
<noscript>
<meta http-equiv="refresh"content="2;url=http://www.inven.co.kr/?"/>
</noscript>
<iframe id="f"frameborder="0"style="width:100%;height:100%"></iframe>
<script>
document.getElementById("f").src="http://210.91.57.208/tm/?a=CR&b=WIN&c=[CENSORED]&d=[CENSORED]&e=[CENSORED]&f=d3d3LmludmVuLmNvLmtyL2JvYXJkL21hcGxlLzIyOTk=&g=[CENSORED]&h="+Date.now()+"&y=0&z=0&x=1&w=2020-01-16&in=[CENSORED]&id=20200228"
</script>

위에서 iframe을 통해서 불러오는 코드는 gist(kimtruth/2af3ab6eb1e56b9344d7843f887f17fd)에서 전체 내용을 확인 가능합니다.

우선 함수가 각각 무슨 행위를 하는지 하나하나 알아보도록 하자

function f1
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
function f1() {
var m1 = "",
m2, m3, m4, m5, m6, m7, m8, m9 = 0,
ma = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
mb = di.p1.replace(/[^A-Za-z0-9\+\/\=]/g, "");
while (m9 < mb.length) {
m5 = ma.indexOf(mb.charAt(m9++));
m6 = ma.indexOf(mb.charAt(m9++));
m7 = ma.indexOf(mb.charAt(m9++));
m8 = ma.indexOf(mb.charAt(m9++));
m2 = (m5 << 2) | (m6 >> 4);
m3 = ((m6 & 15) << 4) | (m7 >> 2);
m4 = ((m7 & 3) << 6) | m8;
m1 = m1 + String.fromCharCode(m2);
if (m7 != 64) m1 = m1 + String.fromCharCode(m3);
if (m8 != 64) m1 = m1 + String.fromCharCode(m4)
}
parent.location.href = 'http://' + m1
}

f1 함수는 base64 decode 로직을 구현한 것이다. di.p1에는 유저가 원래 방문하고자 했던 주소인 www.inven.co.kr/board/maple/2299를 base64 encoding한 d3d3LmludmVuLmNvLmtyL2JvYXJkL21hcGxlLzIyOTk=값이 들어있었다. 아마도 K사가 해당 유저는 인터넷을 사용할 수 있다고 판단했을때 다시 유저가 요청했던 곳으로 보내 주기 위한 것 같았다.

f2 함수는 너무 길어 코드는 생략하고 설명을 적어보자면 MurmurHash3 32-bit 를 구현해 놓은 것이였다. 변수명이 바뀌어있지만 코드는 완전히 https://github.com/garycourt/murmurhash-js/blob/master/murmurhash3_gc.js 이 코드와 같았다.

first setTimeout
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
setTimeout(function() {
try {
var m1 = window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection,
m2 = {
optional: [{
RtpDataChannels: true
}]
},
m3 = {
iceServers: [{
urls: 'stun:stun.services.mozilla.com'
}]
},
m4 = new m1(m3, m2),
m5 = /([0-9]{1,3}(\.[0-9]{1,3}){3}|[a-f0-9]{1,4}(:[a-f0-9]{1,4}){7})/,
m6 = 1
} catch (e) {
di.I1 = 'NS';
return
}

function f1(p1) {
var m1 = m5.exec(p1)[1];
if (m1.match(/^(192\.168\.|169\.254\.|10\.|172\.(1[6-9]|2\d|3[01]))/)) {
di.I1 = m1;
m4.onicecandidate = null
} else if (m6) {
di.I1 = m1;
m6 = 0
}
}
m4.onicecandidate = function(p1) {
if (p1.candidate) f1(p1.candidate.candidate)
};
m4.createDataChannel('');
m4.createOffer(function(p1) {
m4.setLocalDescription(p1, function() {}, function() {})
}, function() {})
}, 0);

f2 함수 바로 아래에 있는 첫번째 setTimeout블럭은 WebRTC를 이용한 private ip leak을 통해 /^(192\.168\.|169\.254\.|10\.|172\.(1[6-9]|2\d|3[01]))/즉, A, B, C class private ip 대역 인지 체크하고 di.I1 값에 유저의 private ip값을 넣는다.

second setTimeout
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
setTimeout(function() {
try {
var m1 = document.createElement('canvas');
var m2 = m1.getContext("webgl") || m1.getContext('experimental-webgl');
var m3 = m2.getExtension('WEBGL_debug_renderer_info');
di.I2 = m2.getParameter(m3.UNMASKED_RENDERER_WEBGL);
var m4, m5;
m4 = di.I2.indexOf("ANGLE (", 0);
if (m4 > -1) {
m4 += 7;
m5 = di.I2.indexOf("Direct", m4);
if (m5 > -1) {
di.I2 = di.I2.substr(m4, m5 - m4)
}
}
di.I2 = di.I2.replace(/\s/gi, '')
} catch (e) {
di.I2 = 'NS'
}
}, 0);

그 바로 아래에 있는 두번째 setTimeout블럭에서는 webgl을 이용하여 유저의 그래픽 카드 정보를 di.I2에 넣는다…
지금 이 글을 작성하고 있는 맥북에서는 아래와 같이 인텔 그래픽 카드 정보가 뜨는걸 확인할 수 있었다.

Example.js
1
2
3
4
5
6
> var m1 = document.createElement('canvas');
> var m2 = m1.getContext("webgl") || m1.getContext('experimental-webgl');
> var m3 = m2.getExtension('WEBGL_debug_renderer_info');
> m2.getParameter(m3.UNMASKED_RENDERER_WEBGL)

"Intel(R) Iris(TM) Plus Graphics 655"

그 아래에 있는 setTimeout블럭은 별 의미없는 canvas로 이미지를 그린뒤 이미지의 Base64값을 위에서 정의한 f2함수를 이용해 그 값을 di.I10에 넣는 것이였다.
또, 그 아래에 있는 setTimeout블럭 에서는 user-agent값을 체크하고 위에서 구했던 값들을 tms.dasGET method로 전달하고 있었다.

fourth setTimeout
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
m3.open('GET', 'tms.das?a=' + di.I3 + '&b=' + di.I5 + '&c=' + di.p2 + '&d=' + di.p3 + '&e=' + di.p4 + '&g=' + di.t1 + '&h=' + di.t2 + '&i=' + di.t3 + '&l=' + di.I1 + '&m=' + di.I7 + '&n=' + (di.I4 === 1 ? 'PV' : di.I6) + '&o=' + di.I8 + '&p=' + di.I2 + "&q=" + di.I9 + "&r=" + di.I10, true);
m3.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded;charset=euc-kr');
m3.onreadystatechange = function() {
if (m3.readyState == 4 && m3.status == 200) {
var m1 = m3.responseText.split(':');
if (m1[0] === '0') {
var of = document.getElementById("fa"); of .o.value = m1[2]; of .action = "nt/" + m1[1] + ".das";
if (di.p6 === 1) {
if (di.I3 === 'CR' && di.t4 >= 68) {
of .target = '_self'; of .submit()
} else of.submit()
} else {
window.open("", "N_POP", "width=" + m1[3] + "px,height=" + m1[4] + "px,left=0,top=0"); of .target = "N_POP"; of .submit();
f1()
}
} else {
f1()
}
}
};
m3.send()

근데 이때, 응답값을 기준으로 앞서 위에서 정의한 유저가 처음에 요청했던 곳으로 redirect 해주는 함수 f1를 호출할지, 다른 페이지로 보내버릴지 정한다.
다른페이지는 /tm/nt/newchada.das로 동일했다.

해당 코드는 gist(kimtruth/ae12cd79cf5a64bc07c5844d631b35f9)에서 전체 내용을 확인 가능합니다.

이쪽 코드는 딱히 중요한 내용은 없는데 그래도 가장 하이라이트 부분은 이 부분일 것 같다.

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
g4: [[192, 168, 0, 1], [172, 16, 0, 1], [172, 17, 0, 1], [172, 18, 0, 1], [172, 19, 0, 1], [172, 20, 0, 1], [172, 21, 0, 1], [172, 22, 0, 1], [172, 23, 0, 1], [172, 24, 0, 1], [172, 25, 0, 1], [172, 26, 0, 1], [172, 27, 0, 1], [172, 28, 0, 1], [172, 29, 0, 1], [172, 30, 0, 1], [172, 31, 0, 1]],
g5: null,
g6: null,
g7: 0,
// skiped...
f2: function() {
this.g5 = new WebSocket("ws://" + this.g6.join(".") + ":80"),
setTimeout(this.f3(this), 50)
},
// skiped...
f6: function(t, e) {
return function() {
if (!t.g7 && (ga || t.g2) && (t.g2 || t.g3)) {
var n;
n = window.XMLHttpRequest ? new XMLHttpRequest : new ActiveXObject("Microsoft.XMLHTTP"),
n.open("GET", "../tmi.das?c=" + g2 + "&o=" + g4 + "&q=" + t.g2 + "&r=" + t.g3, !0),
n.setRequestHeader("Content-Type", "application/x-www-form-urlencoded;charset=euc-kr"),
n.send(),
e && (t.g7 = 1)
}
}
},
s: function() {
this.f1() ? (setTimeout(this.f6(this, 0), 3e3),
this.g1 ? (this.g6 = this.g4[this.g1],
this.g2 = this.g6.join("."),
this.g6[3]++,
this.f4()) : (this.g1 = 0,
this.g6 = this.g4[this.g1],
this.f2())) : this.e(3)
},

WebSocket으로 192.168.x.1, 172.16.x.1등, 스캐닝을 하고 그 결과를 xhr로 K사에 전송하는 로직이 존재했다.
해당 로직은 코드만 남아있고 사용되고 있지는 않는듯 한데, 아마도.. WebRTC를 통해 private ip를 직접 얻어올 수 있었기에 사용하지 않는것 같다.

분석후기

K사는 MITM을 통해 유저의 private ip 정보, 그래픽 카드 정보 등을 수집하고있었고, 다른 사례들을 보니 코드는 매번 바뀌고 있는듯 하다.
근데 아무리 생각해도 내 정보 긁어가는게 찝찝해서 간단하게 막을 방법을 생각해보기로 했다.

[Chrome] 간단하게 막는법

adblock extension 을 활용해서간단히 특정 IP로의 요청을 차단할 수 있다.

사용자 지정 메뉴에서 URL로 광고 차단하기에 해당 IP를 입력하고 적용한다.

근데 여기서 문제가 하나 더 발생했다. 내가 요청한 url은 www.inven.co.kr/board/maple/2299였는데 meta 태그 안에 있는건 host까지 밖에 없다..
결국 직접 iframesrc에 base64 encoding 되어서 들어갔던 값을 이용해서 redirect 시켜주면 된다.

K사의 http response
1
2
3
4
5
6
7
<noscript>
<meta http-equiv="refresh"content="2;url=http://www.inven.co.kr/?"/>
</noscript>
<iframe id="f"frameborder="0"style="width:100%;height:100%"></iframe>
<script>
document.getElementById("f").src="http://210.91.57.208/tm/?a=CR&b=WIN&c=[CENSORED]&d=[CENSORED]&e=[CENSORED]&f=d3d3LmludmVuLmNvLmtyL2JvYXJkL21hcGxlLzIyOTk=&g=[CENSORED]&h="+Date.now()+"&y=0&z=0&x=1&w=2020-01-16&in=[CENSORED]&id=20200228"
</script>

이 작업에는 Tampermonkey 라는 extension을 사용했다.

코드는 아래와 같이 iframe#f가 존재하고 src가 K사의 그 IP를 포함하고 있다면 base64로 encoding되어 있던 url을 decoding하여 redirect 하게 했다.

bigK.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
// ==UserScript==
// @name Big K is watching you
// @namespace http://tampermonkey.net/
// @version 0.1
// @description try to take over the world!
// @author You
// @match http://*/*
// @grant none
// ==/UserScript==

function parseQuery(queryString) {
var query = {};
var pairs = (queryString[0] === '?' ? queryString.substr(1) : queryString).split('&');
for (var i = 0; i < pairs.length; i++) {
var pair = pairs[i].split('=');
query[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1] || '');
}
return query;
}

(function() {
'use strict';
if(document.querySelector('iframe#f').src.includes('210.91.57.208')) {
var url = new URL(document.querySelector('iframe#f').src)
location.href = 'http://' + atob(parseQuery(url.search).f)
}
})();

마무리 하며

이 글을 작성하는 시점에는 저런 추가단말서비스 가입 안내가 뜨지 않아서.. 분석해보고 싶은데 못해본 것들이 많습니다.
K사는 GET방식만 MITM을 하는지, GET방식의 요청이지만 custom header가 들어간 요청에도 MITM하여 제대로 동작 안하게 망가뜨리는지..

분석내용이 틀리거나, 이후 다시 가입안내가 뜨는 경우 내용 더 보완해서 작성해놓도록 하겠습니다.

Share