서버시간 확인 방법과 그 정확도에 관한 고찰

2020. 8. 16. 22:27Research

안녕하세요. orangecalculator입니다. 

 

1. 개요

최근에 서울대학교의 수강신청이 있었습니다. 그런데, 그 전까지는 그러려니 했지만 이번에 수강신청을 하면서 서버시간 확인 서비스인 navyism과 한국표준과학연구원(KRISS)에서 제공하는 표준시간 확인 서비스 utck 중 어떤 서비스가 좋을까라는 생각을 문득 하게 되었습니다. 먼저 제 주변 친구들에게 확인해본 결과 어떤 것이 우세하게 선호되는 것 같지는 않았고, 대부분의 친구들이 두 서비스 중 하나를 선호하는 것 같았습니다. 저는 컴퓨터를 배운 입장에서 두 서비스의 질이 어떤지 궁금해졌습니다. 그래서 한번 코드를 뜯어보았습니다.

이 글에서는 navyism과 utck를 분석하기 위한 배경지식을 먼저 알아보겠습니다. 그 후에는 코드의 분석결과를 알아보고, 분석결과를 바탕으로 정확도에 대해 고찰해보도록 하겠습니다. 당부드릴 것은 완벽하게 확신하는 내용은 아니고, 추측도 포함되었음을 알려드립니다. 또한, 해당 글의 내용으로 인해 발생하는 수강신청 사고 등에 대해서는 책임져드리지 않음을 알려드립니다. 

 

2. navyism 분석

navyism은 인터넷 브라우저를 통해 제공되는 서비스입니다. 정확히는 웹사이트로 구현되어 있습니다. 웹사이트의 일반적인 구조는 일반적으로 서버에서 작동하는 백엔드와 인터넷 브라우저를 통해 실행되고 사용자와 상호작용하는 프론트엔드로 나뉩니다. 이 때, 저는 사용자의 입장이기 때문에 프론트엔드를 통해 해당 사이트의 방법을 추측해야 할 것입니다. 

navyism 서비스의 현재 모습(광고 부분은 제가 넣었습니다)(출처: https://time.navyism.com/?host=sugang.snu.ac.kr)

현재 프론트엔드는 기본적인 표현을 나타내는 HTML과 좀 더 다채롭게 꾸며주는 CSS, 그리고 오늘 논의에서 가장 중요한 javascript로 이루어져 있습니다. 이 때, 위의 스크린샷을 보면, 해당 스크린샷은 이 글을 쓰는 시간에 서비스의 현재 모습을 캡쳐한 것입니다. 위처럼 시간을 알려주고 있습니다. 물론 1초 간격으로 시간을 최신화시켜 보여줍니다. 어것이 핵심입니다. 사이트의 내용이 지속적으로 변합니다. 여기서 동적인 웹사이트를 구현하는 javascript를 사용하였을 것이라 추측할 수 있습니다. 확인 결과 해당 시간을 표시하는 동작이 스크립트를 통해 구현되어 있었습니다. 바로 한번 확인해보았습니다. 

 

해당 부분은 show라는 함수를 통해 구현되어 있었습니다.

 

함수의 파라미터는 timeGap이라는 파라미터로, 특별한 값이 문자열 같은 걸로 전달되는 경우도 있어 보이지만 대부분은 정수로 전달되는 것으로 보입니다. 전달된 값은 이름 그대로 서버 시간과 현재 해당 코드를 실행 중인 컴퓨터 시간 사이의 시간의 차이를 초단위로 나타낸 것으로 보입니다.

function show(timeGap)
{
	//...
    
	var thisTime	= timeGap + time();
    
	//...
    
	if(lang == "en")
	{
	//...
	}
	else
	{
		if((t[4] == '29' || t[4] == '59') && !document.getElementById("nowarn_check").checked)
		{
			document.getElementById('time_area').innerHTML	= "<font color=#cc0000>" + t[0] + "년 " + t[1] + "월 " + t[2] + "일" + time_sep + t[3] + "시 " + t[4] + "분 " + t[5] + "초</font>";
		}
		else
		{
			document.getElementById('time_area').innerHTML	= t[0] + "년 " + t[1] + "월 " + t[2] + "일" + time_sep + t[3] + "시 " + t[4] + "분 " + t[5] + "초";
		}
	}
    
	if(isFirstKick < 10)
	{
		isFirstKick++;

		var now		= new Date();

		setTimeout("show(" + timeGap + ")", 100 - now.getMilliseconds());
	}
	else
	{
		setTimeout("show(" + timeGap + ")", 100);
	}

	//...
}

 

시간 차이인 timeGap의 값을 time 함수의 결과에 더해서 서버시간을 계산하고 이 값을 thisTime이라는 변수에 저장합니다. 이 시간을 웹페이지에 표시해주면 되겠습니다.

	var thisTime	= timeGap + time();

 

이를 표시하기 위해 아주 긴 if-else 구문이 시작됩니다. 대부분의 시간은 맨 끝의 else문이 실행되는 것으로 보입니다. 대부분의 시간은 그냥 몇년 몇월 몇일 몇시 몇분 몇초까지만 보여주는데, 이 형태는 아래의 else문에서 사용하는 형태입니다. 

	if(lang == "en")
	{
	//...
	}
	else
	{
		if((t[4] == '29' || t[4] == '59') && !document.getElementById("nowarn_check").checked)
		{
			document.getElementById('time_area').innerHTML	= "<font color=#cc0000>" + t[0] + "년 " + t[1] + "월 " + t[2] + "일" + time_sep + t[3] + "시 " + t[4] + "분 " + t[5] + "초</font>";
		}
		else
		{
			document.getElementById('time_area').innerHTML	= t[0] + "년 " + t[1] + "월 " + t[2] + "일" + time_sep + t[3] + "시 " + t[4] + "분 " + t[5] + "초";
		}
	}

 

아래와 같이 확인해보면 정말로 time_area라는 id를 가지는 태그의 값이 업데이트되고 있는 것을 html 문서에서 확인할 수 있습니다. 이미지에서와 같이 크롬 브라우저에서 해당 스크립트가 작동되는 모습을 스크린샷으로 찍어보았습니다. 코드에서 확인한 것과 같이 time_area라는 id를 가진 div 태그가 보라색으로 빛나면서 시간이 최신화되고 있는 모습입니다. 

<!-- 해당 태그가 업데이트의 대상이 되는 시간 표시 태그 -->
<div id="time_area">2020년 08월 19일 08시 34분 10초</div>

크롬브라우저 시간 표시 태그 업데이트

시간을 지속적으로 최신화시켜주어야 하기 때문에, 지속적으로 함수가 실행되도록 해주어야 하겠습니다. 여러가지 구현이 있을 수 있겠습니다. while문으로 계속 루프를 돌 수도 있겠습니다. 성능을 위해 중간에 sleep을 걸어줄 수도 있겠습니다. 다만 이 코드를 개발하신 분은 이러한 방법이 아니라, 0.1초 정도 뒤에 다시 해당 함수를 실행하도록 하는 방법을 사용하신 것 같습니다. 이를 위해 javascript에서 제공하는 함수인 setTimeout을 이용해 같은 파라미터인 timeGap을 이용해 함수를 실행하도록 하셨습니다. 이를 위한 코드가 아래와 같이 나타나있는 것으로 보입니다.

	if(isFirstKick < 10)
	{
		isFirstKick++;

		var now		= new Date();

		setTimeout("show(" + timeGap + ")", 100 - now.getMilliseconds());
	}
	else
	{
		setTimeout("show(" + timeGap + ")", 100);
	}

 

그래서 이 show 함수를 적절한 파라미터와 함께 한번만 실행시켜주면 알아서 지속적으로 실행이 됩니다. 이 시작 부분은 어떻게 되어있을까요? 아래와 같이 html의 스크립트에 따로 적혀있었습니다.

<script>
	show(1597793026 - time() - 1);
</script>

 

위와 같이 html 문서 자체에 서버 시간을 먼저 불러와서 적어놓고 해당 문서를 사용자에게 주는 것으로 보입니다. 사실 해당 스크립트 태그를 다른 스크립트 태그에서 따로 삽입한 것이 아닐까도 생각해보았습니다. 그런데 이는 아닌 것으로 보였습니다. 왜냐하면 받아오기만 하고 해당 html 문서를 실행하지 않더라도 위와 같은 부분은 그대로 남아있었기 때문입니다. 아래와 같이 받아오면 되겠습니다.

#!/usr/bin/env python3
#FetchNavyismSNU.py

import urllib.request

navyismurl = urllib.request.urlopen("https://time.navyism.com/?host=sugang.snu.ac.kr")

navyismhtml = navyismurl.read()

navyismurl.close()

navyismfile = open("navyismsnu.html", "wb")
navyismfile.write(navyismhtml)
navyismfile.close()

 

여기서 하나 더 덧붙이자면 위의 time 함수는 시간을 반환하는 함수입니다. 해당 함수는 컴퓨터 로컬 시간을 초단위로 받아오는 함수입니다. 그런데 이 함수는 사실 내장함수는 아닙니다. 찾아보니 이 웹사이트를 개발하신 분이 php에 익숙하셨던 것인지, 아니면 php로 개발된 것을 번역하신 것인지는 잘 모르겠습니다만, 이 time 함수는 php에서 제공하는 내장함수 원형을 그대로 사용하는 것으로 보입니다. 

 

3. utck 분석

utck는 한국표준과학연구원(KRISS)에서 제공하는 프로그램으로 컴퓨터 시간을 KRISS 서버 시간과 비교하고1 동기화시키는 기능을 제공합니다. 아래 스크린샷은 utck 프로그램의 모습입니다. 비교 버튼은 컴퓨터 시간과 서버 시간을 비교하는 기능을 제공하고 동기 버튼은 여기서 더 나아가 컴퓨터 시간과 서버 시간을 동기화시키는 기능을 제공합니다. 

UTCK 프로그램의 스크린샷

해당 프로그램이 얼마나 정확한지 알아보기 위해 해당 프로그램의 내부를 분석하여 어떤 식으로 작동하는지 알아보았습니다. 먼저 해당 프로그램은 실행시키면 되는 프로그램이라서 컴파일 언어로 작성되었다고 볼 수 있겠습니다. 그래서 기계어로 이루어져있고 이를 분석하기 위해 기계어로 구성된 내부를 들여다보아야 합니다. 사실 저는 분석할 때 IDA Freeware를 사용합니다. 돈 없는 학생이라 기계어를 자동으로 번역까지 해서 C로 보여주는 기능이 있긴 하지만 쓸 수가 없습니다. 그래서 기계어로 설명해드리겠습니다.

 

위 프로그램도 어쨌든 서버와 통신을 해서 정보를 받아와야 하므로, 운영체제의 네트워크 모듈을 사용할 것입니다. 역시나 utck 주요기능 페이지에는 이와 같은 내용이 설명되어 있었습니다. KRISS의 설명에 따르면 SNTP 프로토콜을 이용해 시간 정보를 받아온다고 합니다.

utck 주요기능 설명 페이지

 

이 부분을 확인해보기 위해 Process Monitor라는 프로그램을 사용하였습니다. 해당 프로그램은 윈도우 제작사인 마이크로소프트에서도 문서를 제공하는 프로그램입니다. utck라는 이름으로 시작하는 프로세스를 검색해서 시스템콜들을 확인해보았습니다. 그 결과 아래와 같이 비교 버튼을 누르면 아래와 같은 로그가 잡히는 것을 볼 수 있었습니다. 

Process Monitor로 utck의 동작을 확인

그 내용을 좀 더 자세히 알아보기 위해 Microsoft Network Monitor를 사용하여 패킷의 내용을 확인해보았습니다. 먼저 필터를 UDP 포트를 이용해 설정한 후 패킷들을 잡아서 내용을 보았습니다. 아래와 같이 패킷은 2개를 보냈고, 2번째 패킷에 대해서는 응답이 오는 것을 확인할 수 있었습니다. 

Microsoft Network Monitor를 이용해 utck의 패킷 내용 확인

지금까지 종합해보면 SNTP 프로토콜이라는 것을 이용하여 시간을 받아온다는 것입니다. 사실 이정도만으로도 충분하지만, 저는 프로그램 내부까지 뜯어보았기 때문에 이를 소개하겠습니다. 다만, 기계어는 최소한으로만 보여드리겠습니다. 또, 짧은 wrapper 함수들은 C 언어로 번역해놓았으니 이를 보여드리겠습니다.

 

먼저 사용된 시스템콜을 보여드리겠습니다. 제가 관심을 가지는 사용된 시스템콜은 네트워킹과 관련된 시스템콜밖에 없었고, 사실 이와 관련된 시스템콜들은 쓰임새가 정형화되어 있습니다. 프로토타입만 보여드리자면 다음과 같습니다.

//reference: https://docs.microsoft.com/en-us/windows/win32/api/winsock2/nf-winsock2-socket
//winsock2.h
SOCKET WSAAPI socket(
  int af,
  int type,
  int protocol
);

//reference: https://docs.microsoft.com/en-us/windows/win32/api/winsock/nf-winsock-gethostbyname
//winsock.h
hostent * gethostbyname(
  const char *name
);

//reference: https://docs.microsoft.com/en-us/windows/win32/api/winsock2/nf-winsock2-send
//winsock2.h
int WSAAPI send(
  SOCKET     s,
  const char *buf,
  int        len,
  int        flags
);

//reference: https://docs.microsoft.com/en-us/windows/win32/api/winsock2/nf-winsock2-select
//winsock2.h
int WSAAPI select(
  int           nfds,
  fd_set        *readfds,
  fd_set        *writefds,
  fd_set        *exceptfds,
  const timeval *timeout
);

//reference: https://docs.microsoft.com/en-us/windows/win32/api/winsock/nf-winsock-recv
//winsock2.h
int recv(
  SOCKET s,
  char   *buf,
  int    len,
  int    flags
);

 

위 함수만이 아니고 여러 다른 함수들도 다 써야 완전한 통신이 되겠지만, 분석에 필요한 것은 이 정도입니다. 정리하면 소켓을 열어서 시간을 달라고 패킷을 보내고, 응답을 받는 것입니다. 

 

위 함수들에 대해서 모두 wrapper를 사용하는 것으로 확인되었습니다. 이 wrapper들의 동작을 바탕으로 분석을 해보았습니다. 

 

먼저 socket함수를 이용해 udp 소켓을 여는 것으로 확인되었습니다.

//at 0x401C00
int opensocket(SOCKET *PSOCKET){
    *PSOCKET = socket(AF_INET, SOCK_DGRAM, NULL);
    return (*PSOCKET == -1);
}

 

그 다음 해당 소켓에 대한 설정을 해주기 위해 다음과 같이 해당 소켓을 설정해주는 wrapper를 사용합니다. 해당 wrapper에서는 gethostbyname이라는 함수를 사용합니다. 해당 함수는 도메인 네임을 DNS 서버를 통해 ip로 바꾸어주는 역할을 합니다. 그 이후에는 ip를 얻어내어 소켓의 통신 주소를 설정하는데 사용합니다.

//at 0x401C20
int socketconfig(char *cp, u_short hostshort)

//...

at 0x401C62
push    esi             ; name
call    gethostbyname
test    eax, eax
jz      short loc_401C8D

 

그 이후에는 sntp 프로토콜에 맞는 패킷을 만들고 보내서 정보를 요청합니다.

//at 0x401CC0
int requestsntp(SOCKET *PSOCKET, const char *buf, int len){
    return (send(*PSOCKET, buf, len, NULL) != -1);
}

 

요청을 한 후에는 해당 서버에서 응답을 보내왔는지 확인을 합니다.

//at 0x401D30
int isalive(SOCKET *PSOCKET, int *Pvalid, long msec){
    int res;
    
    fd_set readfds;
    readfds.fd_count = 1;
    readfds.fd_array[0] = *PSOCKET;
    
    timeval timeout;
    MILLISECONDTOTIMEVAL(msec, &timeout);
    //macro behavior description at reference
    //reference: https://stackoverflow.com/questions/1294885/convert-milliseconds-to-seconds-in-c
    
    res = select(NULL, &readfds, NULL, NULL, &timeout);
    if(res == -1) return 0;
    else{
        *Pvalid = (res != 0);
        return 1;
    }
}

 

서버에서 성공적으로 응답하였다면 아래와 같은 함수로 해당 데이터를 받아옵니다.

//at 0x401CF0
int recvsntp(SOCKET *PSOCKET, char *buf, int len){
    return recv(*PSOCKET, buf, len, NULL);
}

 

이와 같은 wrapper를 이용하여 서버의 도메인 이름을 주고 시간을 받아오는 큰 함수가 있는 것으로 확인되었습니다. 제가 살펴본 곳만 써드리겠습니다. 그런데 다 이해할 필요는 없고 이런 과정을 통해서 결국 서버시간을 받아오더랍니다. 물론 실패하면 에러를 반환합니다.

//at 0x401DF0
int getservertime(char *cp, int, u_short hostshort)

//at 0x401E16
loc_401E16:
mov     ecx, ebx
call    sub_401C00
test    eax, eax
jz      loc_40201E

//at 0x401E25
mov     eax, dword ptr [esp+94h+hostshort]
mov     ecx, [esp+94h+cp]
push    eax             ; hostshort
push    ecx             ; cp
mov     ecx, ebx
call    sub_401C20
test    eax, eax
jz      loc_401EF3

//at 0x401E77
lea     eax, [esp+94h+buf]
mov     [esp+94h+var_4C], ecx
push    30h             ; len
push    eax             ; buf
mov     ecx, ebx
mov     [esp+9Ch+var_48], edx
call    sub_401CC0
test    eax, eax
jz      short loc_401EF3

//at 0x401E91
mov     ecx, [esi+4]
lea     edx, [esp+94h+var_80]
push    ecx
push    edx
mov     ecx, ebx
call    sub_401D30
test    eax, eax
jz      loc_401FFF

//at 0x401ED9
xor     eax, eax
lea     edi, [esp+94h+var_44]
push    44h             ; len
rep stosd
lea     ecx, [esp+98h+var_44]
push    ecx             ; buf
mov     ecx, ebx
call    sub_401CF0
test    eax, eax
jnz     short loc_401F24

 

 

그렇다면 이 함수를 부르는 부분을 찾으면 될 것입니다. 이 부분에서 드디어 제가 원하는 정보를 찾을 수 있었습니다. 잭팟입니다. 여기서는 저희가 원하는 값이 코드에 들어있는 것이 아니고 함수의 호출 과정을 따라서 쭉 인자가 전달되는 것으로 보였습니다. 그래서 코드 안에서 원하는 정보를 얻지는 못하고 x32dbp라는 프로그램을 이용해 아래 부분의 함수 호출부에 break를 걸고 확인해보았습니다. 그랬더니 함수 인자로 time.kriss.re.kr과 time2.kriss.re.kr를 각각 주는 것을 확인하였습니다. 

//at 0x408A40
//function start

//at 0x408AC1
mov     eax, [edi+28h]
lea     ecx, [esp+174h+var_138]
push    7Bh             ; hostshort
push    ecx             ; int
push    eax             ; cp
lea     ecx, [esp+180h+var_D8]
mov     byte ptr [esp+180h+var_4], 1
call    sub_401DF0
test    eax, eax
jz      short loc_408AED

//at 0x408AED
loc_408AED:
mov     eax, [edi+2Ch]
lea     edx, [esp+174h+var_138]
push    7Bh             ; hostshort
push    edx             ; int
push    eax             ; cp
lea     ecx, [esp+180h+var_D8]
call    sub_401DF0
test    eax, eax
jz      short loc_408B11

//...

x32dbg로 utck의 내부 확인 1
x32dbg로 utck의 내부 확인 1

 

결국 종합하면 utck는 time.kriss.re.kr 서버에 시간정보를 요청하고 이 요청이 실패할 경우에는 time2.kriss.re.kr 서버에 시간정보를 요청합니다. 나름 긴 과정이었습니다.

 

4. 고찰

 

navyism의 경우 애초에 초단위로 정보를 주기 때문에 오차는 최소단위의 절반인 0.5초까지로 볼 수 있습니다. 

 

utck의 경우는 좀 더 생각해볼 것이 있습니다. 사실 KRISS에서는 오차가 0.5초까지라고 제시하였습니다. 그런데 제가 생각하기로는 이 값은 굉장히 보수적인 값입니다. 애초에 이 프로토콜은 밀리초단위로 정보를 주기 때문입니다. 이를 관찰해보기 위해 sntp의 간단한 동작을 아래와 같이 파이썬을 통해 확인해보았습니다. 밀리초까지 알려주는 것을 확인할 수 있었습니다. 

 

import socket
import struct
import sys
import time
import datetime

TIME1970 = 2208988800

def try_sntp_client(NTP_SERVER, timeout=None):
    client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    client.sendto(b'\x1b' + b'\x00' * 47, (NTP_SERVER, 123))
    time_send = time.time()
    if timeout != None:
        client.settimeout(timeout)
    data, address = client.recvfrom(1024)
    time_recv = time.time()
    if len(data) > 0:
        print(f"Response received from: {address}")
    timedat = struct.unpack("!2I", data[40:48])
    timestamp = timedat[0] - TIME1970 + timedat[1] / (2 ** 32)
    print(f"\tServer Time: {datetime.datetime.fromtimestamp(timestamp)}")
    print(f"\tLocal  Time: {datetime.datetime.fromtimestamp((time_send + time_recv) / 2)}")
    print(f"\tSend-Recv Gap: {time_recv - time_send}")

if __name__ == '__main__':
    try:
        received = False
        try_sntp_client("time.kriss.re.kr", 1)
        received = True
    except Exception as e:
        print(str(e))
    if not received:
        try:
            try_sntp_client("time2.kriss.re.kr", 1)
        except Exception as e:
            print(str(e))

#reference: https://stackoverflow.com/questions/39466780/simple-sntp-python-script

 

결과는 어떨까요? 밀리초 단위의 정보도 확인할 수 있었습니다. 또 여기서 하나 더 확인할 수 있도록 제가 출력한 것이 있습니다. 바로 요청을 보내고 응답을 받을 때까지의 시간입니다. 아래와 같이 6(ms)가 걸린 것을 확인할 수 있었습니다. 여기서부터는 제 추측이지만, 서버에서는 요청이 도착하면 그 때 자신의 시간을 얻어서 패킷에 실어 보낼 것입니다. 따라서 요청을 보낸 후부터 6(ms) 후에 응답을 받는 사이의 서버 시간을 받게 될 것입니다. 즉, 보수적으로 오차를 잡아도 이 경우에는 6(ms)가 최대 오차라는 것이 제 생각입니다. 게다가, 요청을 보내고 응답을 받는 데 패킷의 전달 시간이 비슷할 것이라고 생각하기 때문에, 위의 코드에서도 그랬지만, 두 시간 사이의 시간을 Local Time으로 출력하였습니다. 그렇게 하면 최대 오차가 절반인 3(ms)까지 줄어들 수 있음을 의미합니다. 즉, 굉장히 정확하다고 볼 수 있습니다. 

timed out
Response received from: ('210.98.16.101', 123)
        Server Time: 2020-08-19 17:35:16.636651
        Local  Time: 2020-08-19 17:35:17.523097
        Send-Recv Gap: 0.005927085876464844

 

5. 결론

 

navyism측 서버에서 1초 단위로 정보를 보내주므로 설계의 한계로 인해 최대 오차를 0.5초까지 생각해야 할 것입니다. 반면 utck는 자신의 인터넷 환경에 따라 다르겠지만 수백 ms에서 수 ms까지 오차가 줄어들 수 있다고 생각합니다. 

 

제가 이러한 분석을 시도해본건 수강신청에 어떤 것이 좋은지 궁금해서였습니다. 그런데 몇일간 생각을 해보니 사람의 반응속도가 있는데 이 정도 정확도에서 이러한 분석이 의미가 있을까라는 생각도 들지만, 어쩌면 차이가 날 수도 있을 것 같습니다. 

 

감사합니다.