[mono] mono-service 버그 패치

Mono 2019. 10. 14. 08:57 |

개요

mono-service 는 .NET Framework 로 작성된 윈도우 서비스(Windows Services)mono 환경에서 구동할 수 있는 도구이다. 윈도우 서비스는 일반적으로 GUI 가 없는 백그라운드로 동작하는 실행 바이너리로 윈도우 운영체제가 서비스를 안정적으로 동작하도록 지원해 준다.

mono-service 는 .NET Framework 로 컴파일된 바이너리 및 실행 파일을 AppDomain 을 생성한 후 로드한다. mono-service 가 하는 역할은 일반적으로 POSIX 가 정의하는 유닉스 시그널(Unix Signals) 를 받아 처리하기 위한 용도이다. 맥 또는 리눅스 운영체제는 윈도우 운영체제가 제공하는 서비스의 시작/중지 명령을 이해할 수 없기 때문에 SIGINT, SIGKILL 신호 등을 받아서 서비스를 중지하도록 해야 한다.

필자가 회사에서 담당하고 있는 오픈소스 프로젝트인 크레마(게임 데이터 개발 도구)는 전반적으로 플러그인 아키텍처로 여러 가지 기능을 제공한다. 크레마 서버는 다양한 운영체제에서 동작이 가능하도록 mono 에서 실행할 수 있는데, 맥의 launchctl 과 리눅스의 systemd 로 서비스를 제공하기 위해 mono-service 로 서비스 바이너리 파일을 호스팅하도록 한다.

문제 발생

일반적으로 .NET Framework 에서 AppDomain.CurrentDomain.BaseDirectory 속성의 반환되는 결과는 경로 마지막에 / 문자열 붙여준다. 반면 Environment.CurrentDirectory 속성은 마지막에 / 문자열을 붙이지 않는다.

간단한 아래의 콘솔 프로그램의 결과를 보면 쉽게 알 수 있다.

using System;

class MainClass {
  public static void Main (string[] args) {
    Console.WriteLine (AppDomain.CurrentDomain.BaseDirectory);
    Console.WriteLine (Environment.CurrentDirectory);
  }
}

// Results
// ...생략.../bin/debug/
// ...생략.../bin/debug

Path.GetDirectoryName와 조합하면 기대하지 않은 결과가 나올 수 있다. Path.GetDirectoryName/ 가 없는 경로의 마지막은 파일로 인식하여 그 부모의 디렉토리 이름까지의 경로를 반환하는데에서 발생한다.

Console.WriteLine (AppDomain.CurrentDomain.BaseDirectory);
Console.WriteLine (Path.GetDirectoryName(AppDomain.CurrentDomain.BaseDirectory));

Console.WriteLine (Environment.CurrentDirectory);
Console.WriteLine (Path.GetDirectoryName(Environment.CurrentDirectory));

// Results
// ...생략.../bin/debug/
// ...생략.../bin/debug
// ...생략.../bin/debug
// ...생략.../bin/

https://repl.it/@powerumc/BaseDirectory-vs-EnvironmentCurrentDirectory

mono-service 버그

처음 언급한 것처럼 mono-serviceAppDomain 을 생성하여 서비스를 실행한 바이너리를 로드한다. 여기에서 AppDomainApplicationBase 디렉토리를 Environment.CurrentDirectory 로 설정하는 바람에 서비스로 실행되는 컨텍스트에서 AppDomain.CurrentDomain.BaseDirectory 값이 기대한 값과 다르게 반환된다.

아래의 링크는 이런 문제를 해결하기 위해 Pull Request 를 보냈고, 정상적으로 메인 저장소에 머지가 되었다.

Fixed a bug in mono-service.cs by powerumc · Pull Request #17095 · mono/mono

아래의 코드는 간단하게 만든 mono-service 에서 구동할 예제 코드이다.

using System;
using System.ComponentModel;
using System.Configuration.Install;
using System.Diagnostics;
using System.IO;
using System.ServiceProcess;

namespace MonoServiceTest
{
    class Program
    {
        static void Main(string[] args)
        {
            ServiceBase.Run(new MonoServiceTest());
        }
    }

    public class MonoServiceTest : ServiceBase
    {
        protected override void OnStart(string[] args)
        {
            // Debugger.Launch();
            var baseDirectory = AppDomain.CurrentDomain.BaseDirectory;
            var directoryPath = Path.GetDirectoryName(baseDirectory);
            var logFilePath = Path.Combine(directoryPath, "log.txt");
            Console.WriteLine($"AppDomain.BaseDirectory = {baseDirectory}");
            Console.WriteLine($"DirectoryPath = {directoryPath}");
            Console.WriteLine($"LogFile Path = {logFilePath}");
        }
    }

    [RunInstaller(true)]
    public class MonoServiceInstaller : Installer
    {
        public MonoServiceInstaller()
        {
            this.Installers.AddRange(new Installer[]
            {
                new ServiceProcessInstaller
                {
                    Username = null,
                    Password = null,
                    Account = ServiceAccount.LocalSystem
                },
                new ServiceInstaller
                {
                    DisplayName = nameof(MonoServiceTest),
                    ServiceName = nameof(MonoServiceTest),
                    StartType = ServiceStartMode.Automatic
                }
            });
        }
    }
}

위의 코드를 컴파일 한 후 mono-service 로 실행하면 아래와 같이 잘못된 결과를 반환하는 것을 알 수 있다.

mono-service --no-daemon mono_service_test.exe

// Results
// AppDomain.BaseDirectory = ...생략.../bin/Debug
// DirectoryPath = ...생략.../bin
// LogFile Path = ...생략.../bin/log.txt

마무리

크로스 플랫폼을 지원하기 위해 기존 레거시를 .NET Core 로 전환하기엔 기술적인 부분과 운영적인 이슈도 있어서 mono-service 를 검토해 보았다. 그 중 기술적인 부분으로는 더 이상 WCF 서버는 .NET Core 에서 지원하지 않고, gRPC 사용을 권장하고 있다. 이를 위해 gRPC 를 WCF 와 대응되도록 호환 레이어를 만들어 전환하기 위해서 수 많은 테스트를 해야 하고 또 장애에 대응해야 한다.

기존 코드를 이식성이 좋은 mono 를 통해 여러 운영체제를 지원하고자 하였지만, mono-service 의 버그로 인해 버그를 조사하고 PR 를 보내기까지 많은 시간이 소요되었다.

위 버그 픽스 코드가 바로 릴리즈 되는 것은 아니기에 우선적으로 AppDomain.BaseDirectory 의 기대하지 않은 경로에 대해서도 올바르게 동작하도록 개발 중인 소스 코드를 수정해야 했다. 아마 차기 mono 릴리즈 버전에서는 이 문제가 수정될 것이니 가능하면 최신 버전의 mono 를 유지하는 것이 좋을 것 같다.

Posted by 땡초 POWERUMC

댓글을 달아 주세요

전 세계 인터넷은 OpenSSL의 중대한 버그로 난리다. 이 버그의 주요 요지는 특정 OpenSSL을 사용할 경우 메모리의 64KB를 획득할 수 있고, 이 버그로 서버 인증서의 비밀키(개인키)를 취득하여 서버로 오가는 모든 패킷을 취득할 수 있다. 라는 것인데, 이를 가리켜 Heartbleed(심장출혈) 버그라고 한다.

그 만큼 심각한 버그가 맞는데, 일각의 미디어에서 최악의 시나리오를 너무 일반화시키는 것이 아닌가 싶다.

필자가 보안 전문가는 아닌 만큼 잘못된 부분은 너그러이 지적해 주길 바란다.

- 사용자는 모두 패스워드를 변경해야 하나?

해도 되고 안해도 된다. 어차피 개인정보는 오픈소스. :)

농담이고, 취약성이 있는 OpenSSL을 사용하면 공격자는 클라이언트의 사용자 요청 데이터를 가로챌 수 있다.

공격자가 사용자의 패스워드를 가로채려면, 서버 인증서의 비밀키를 취득한 경우에 해당 된다. 하지만 heartbleed 버그는 인증서의 비밀키가 없이도 서버의 메모리 최대 64KB를 볼 수 있다. 모든 데이터를 가로채는 것은 아니고 64KB에 해당하는 찌꺼기(?) 영역인데, 이 영역에 마지막 클라이언트의 요청 데이터가 저장되어 있다.

그러므로 자주 방문하는 사이트면 비밀번호를 변경하는 것이 좋고, 1년 넘게 방문하지 않은 사이트는 변경하지 않아도 된다.

- 서버 운영자는 인증서를 모두 폐기해야 하나?

취약한 버전의 OpenSSL을 사용하고, 최근 서버를 restart 한 경우가 아니라면 거의 제로(0)에 가깝다.

공격자가 64KB 중에서 인증서의 비밀키를 훔치기 위해서는 대상 서버를 재가동하고 첫 번째 요청인 경우에 이 비밀키를 훔쳐갈 수 있는 가능성이 높아진다고 한다. 이는 Answering the Critical Question: Can You Get Private SSL Keys Using Heartbleed? 에서 실험한 결과이다.

- OpenSSL 업그레이드가 불가능할 경우

소스 코드를 보면 곳곳에 아래와 같이 #ifndef OPENSSL_NO_HEARTBEATS 지시자를 발견 할 수 있다. 그러므로 OpenSSL 을 OPENSSL_NO_HEARTBEATS 옵션과 함께 다시 컴파일 하면 heartbleed 취약성 버그를 해결할 수 있다.

#ifndef OPENSSL_NO_HEARTBEATS  
int  
tls1_process_heartbeat(SSL *s) {  
...  
...  
}  

int  
tls1_heartbeat(SSL *s) {  
...  
...  
}  
#endif  

- OpenSSL 코드 품질

Heartbleed 취약성 버그가 해결된 7e840163 커밋을 살펴보면, 아직도 여전히 코드 리뷰를 통해 이슈가 남아있다.

코드 측면에서 변수의 이름이 'payload 는 payload_length 가 되어야 하지 않느냐' 라는 의견이 있다. 그리고 padding 값이 16으로 초기화가 되었음에도 곳곳에 하드 코딩된 '16' 값을 찾아볼 수 있다.

가장 최신 커밋에는 코드 리뷰가 완료되었는 지 모르겠으나, 당시 취약성 버그로 상당히 급하게 코드를 수정한 것 같다는 느낌을 받을 수 있었다.

OpenSSL 디버깅

Heartbeat 프로토콜 Heartbeat network, Linux-HA에서 알 수 있듯이 클러스터링(clustering) 및 고가용성(high-availability linux)을 위해 서버끼리 주고 받는 메시지라고 한다. active, standby 서버 두 대 중 active 서버가 죽으면 standby 가 가동되어 장애를 최소화 하는데, 이 때 ‘죽었니 살았니’ 빼꼼 찔러보는 걸 heartbeat 라고 한다고 한다.

실제 필자의 클라우드 서버에서 테스트를 진행하려고 했으나 여건이 되지 않아 실제 환경과 유사하게 테스트는 하지 못했다.

먼저, github의 OpenSSL 소스 코드를 받고, 버그가 있는 tag 및 branch를 checkout 한다. 이어 디버그 모드로 컴파일을 하면 테스트 준비는 완료된다. 그리고 heartbeat 패킷을 보내줄 수 있는 github의 pacemaker 클라이언트 코드를 받는다.

호스팅된 openssl, lldb attaching

디버그 모드로 컴파일된 openssl 을 self-hosting으로 실행한다.

$ lldb openssl s_server -www  

그리고 openssl/ssl/t1_lib.c 소스 코드의 tls1_process_heartbeat 함수에 브레이크 포인트를 걸었다.

(lldb) br list  
Current breakpoints:  
1: name = 'tls1_process_heartbeat', locations = 1, resolved = 1, hit count = 3  
  1.1: where = openssl`tls1_process_heartbeat + 21 at t1_lib.c:2484, address = 0x000aff55, resolved, hit count = 3  

이제 pacemarker 를 통해 heartbeat를 보냈다.

$ ./heartbleed.py -p 4433 -t 100000 localhost  

t1_lib.c 로컬 변수

다음의 코드 중 &s->s3->rrec.data[0]는 incoming 데이터가 포함 된다.

int  
tls1_process_heartbeat(SSL *s)  
    {  
    unsigned char *p = &s->s3->rrec.data[0], *pl;  
    unsigned short hbtype;  
    unsigned int payload;  
    unsigned int padding = 16; /* Use minimum padding */  

함수의 매개변수로 SSL *s 구조체의 s3 구조체의 데이터는 다음과 같다. s3->rrec가 클라이언트에서 보낸 데이터가 되겠다. 이 구조체는 다음과 같은 값을 가지고 있다.

(lldb) e *s->s3  
(ssl3_state_st) $58 = {  
  flags = 0  
  delay_buf_pop_ret = 0  
  read_sequence = ""  
  read_mac_secret_size = 0  
  read_mac_secret = ""  
  write_sequence = ""  
  write_mac_secret_size = 0  
  write_mac_secret = ""  
  server_random = "SN\x91ki��E\x82V\x01%E\v[t�7�\x91\x88\x9e[�\x8af\x95\x92iU"  
  client_random = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"  
  need_empty_fragments = 0  
  empty_fragment_done = 0  
  init_extra = 0  
  rbuf = (buf = "\x16\x03\x01\x18\x03\x01", len = 17736, offset = 11, left = 0)  
  wbuf = (buf = "", len = 17584, offset = 12, left = 0)  
  rrec = (type = 24, length = 3, off = 0, data = "\x01��|\x03\x01BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", input = "\x01��|\x03\x01BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", comp = 0x00000000, epoch = 0, seq_num = "")  
  wrec = (type = 22, length = 9, off = 0, data = "\x0e", input = "\x0e", comp = 0x00000000, epoch = 0, seq_num = "")  
  alert_fragment = ""  
  alert_fragment_len = 0  
  handshake_fragment = ""  
  handshake_fragment_len = 0  
  wnum = 0  
  wpend_tot = 4  
  wpend_type = 22  
  wpend_ret = 4  
  wpend_buf = 0x0236c800 "\x0e"  
  handshake_buffer = 0x00000000  
  handshake_dgst = 0x007071e0  
  change_cipher_spec = 0  
  warn_alert = 0  
  fatal_alert = 0  
  alert_dispatch = 0  
  send_alert = ""  
  renegotiate = 0  
  total_renegotiations = 0  
  num_renegotiations = 0  
  in_read_app_data = 0  
  client_opaque_prf_input = 0x00000000  
  client_opaque_prf_input_len = 0  
  server_opaque_prf_input = 0x00000000  
  server_opaque_prf_input_len = 0  
  tmp = {
    cert_verify_md = ""
    finish_md = ""
    finish_md_len = 0
    peer_finish_md = ""
    peer_finish_md_len = 0
    message_size = 124
    message_type = 1
    new_cipher = 0x002ca8d0
    dh = 0x00000000
    ecdh = 0x00706dc0
    next_state = 8576
    reuse_message = 0
    cert_req = 0
    ctype_num = 0
    ctype = ""
    ca_names = 0x00000000
    use_rsa_tmp = 0
    key_block_length = 0
    key_block = 0x00000000
    new_sym_enc = 0x00000000
    new_hash = 0x00000000
    new_mac_pkey_type = 0
    new_mac_secret_size = 0
    new_compression = 0x00000000
    cert_request = 0  
  }  
  previous_client_finished = ""  
  previous_client_finished_len = '\0'  
  previous_server_finished = ""  
  previous_server_finished_len = '\0'  
  send_connection_binding = 0  
  next_proto_neg_seen = 0  
}  

openssl 코드에서 2491: n2s(p, payload);가 클라이언트에서 요청한 payload 인데 이 값은 다음과 같다.

(lldb) fr v payload  
(unsigned int) payload = 65517  

실제 요청된 값과 길이를 체크하지 않은 채 아래와 같이 바로 메모리를 할당하게 되는데

   2505          * message type, plus 2 bytes payload length, plus
   2506          * payload, plus padding
   2507          */  
-> 2508         buffer = OPENSSL_malloc(1 + 2 + payload + padding);
   2509         bp = buffer;
   2510
   2511         /* Enter response type, length and copy payload */  

아래의 코드의 함수가 실행되면서, 위에서 할당된 메모리의 65536 bytes (=1+2+payload+padding) 를 buffer에 쓰면서 클라이언트로 64KB 의 over-read 된 메모리의 데이터까지 클라이언트에 response 된다.

   2516         /* Random padding */
   2517         RAND_pseudo_bytes(bp, padding);
   2518  
-> 2519         r = ssl3_write_bytes(s, TLS1_RT_HEARTBEAT, buffer, 3 + payload + padding);
   2520
   2521         if (r >= 0 && s->msg_callback)
   2522             s->msg_callback(1, s->version, TLS1_RT_HEARTBEAT,  
(lldb) fr v
(SSL *) s = 0x00469c10
(unsigned char *) p = 0x012f8a0b "|\x03\x01BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
(unsigned char *) pl = 0x012f8a0b "|\x03\x01BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
(unsigned short) hbtype = 1
(unsigned int) payload = 65517
(unsigned int) padding = 16
(unsigned char *) buffer = 0x01301600 "\x02��|\x03\x01BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
(unsigned char *) bp = 0x01301603 "|\x03\x01BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
(int) r = -1

메모리가 확보되는 buffer = OPENSSL_malloc(...)는 함수부 선언의 unsigned char *p = &s->s3->rrec.data[0]&p 메모리 위치 근처에(&p 주소보다 더 높은 주소) 확보가 된다. 위에서 &p는 incoming 데이터가 있다고 언급했다.

그러므로 heartbeat의 response의 값은 가장 최근에 남아 있는 incoming 데이터, 즉 클라이언트 요청 데이터의 찌꺼기가 남아있는데, over-read 버그로 인해 이 영역의 사용자 요청 데이터가 전송되게 된다.

이렇게 공격자가 훔친 데이터는 아래와 같이 클라이언트 요청 정보가 포함된다. 일반적으로 클라이언트가 서버로 요청하는 정보는 HTTP 프로토콜에 포함되는 URI, Header, Cookie 등을 가로챌 수 있다.

아래는 64KB 범위 안에서 클라이언트가 보낸 incoming 찌꺼기가 남은 64KB 메모리 값의 일부분이다.

(lldb) m r p --count 250  
0x0237300b: 7c 03 01 42 42 42 42 42 42 42 42 42 42 42 42 42  |..BBBBBBBBBBBBB  
0x0237301b: 42 42 42 42 42 42 42 42 42 42 42 42 42 42 42 42  BBBBBBBBBBBBBBBB  
0x0237302b: 42 42 42 00 00 4e c0 30 c0 28 c0 14 00 9f 00 6b  BBB..N�0�(�....k  
0x0237303b: 00 39 00 88 c0 32 c0 2e c0 2a c0 26 c0 0f c0 05  .9..�2�.�*�&�.�.  
0x0237304b: 00 9d 00 3d 00 35 00 84 c0 12 00 16 c0 0d c0 03  ...=.5..�...�.�.  
0x0237305b: 00 0a c0 2f c0 27 c0 13 00 9e 00 67 00 33 00 45  ..�/�'�....g.3.E  
0x0237306b: c0 31 c0 2d c0 29 c0 25 c0 0e c0 04 00 9c 00 3c  �1�-�)�%�.�....<  
0x0237307b: 00 2f 00 41 01 00 00 05 00 0f 00 01 01 2f 32 30  ./.A........./20  
0x0237308b: 31 30 30 31 30 31 20 46 69 72 65 66 6f 78 2f 32  100101 Firefox/2  
0x0237309b: 38 2e 30 0d 0a 41 63 63 65 70 74 3a 20 74 65 78  8.0..Accept: tex  
0x023730ab: 74 2f 68 74 6d 6c 2c 61 70 70 6c 69 63 61 74 69  t/html,applicati  
0x023730bb: 6f 6e 2f 78 68 74 6d 6c 2b 78 6d 6c 2c 61 70 70  on/xhtml+xml,app  
0x023730cb: 6c 69 63 61 74 69 6f 6e 2f 78 6d 6c 3b 71 3d 30  lication/xml;q=0  
0x023730db: 2e 39 2c 2a 2f 2a 3b 71 3d 30 2e 38 0d 0a 41 63  .9,*/*;q=0.8..Ac  
0x023730eb: 63 65 70 74 2d 4c 61 6e 67 75 61 67 65 3a 20 6b  cept-Language: k  
0x023730fb: 6f 2d 6b 72 2c 6b 6f 3b 71 3d                    o-kr,ko;q=


Posted by 땡초 POWERUMC

댓글을 달아 주세요

현재 사용하는 크롬 브라우저 버전은 32.0.1700.77. 이 버전에서 팝업창에서 스크롤바가 비활성화되는 버그가 있다. 이 버그는 윈도우 운영체제에서 발생한다. 필자가 사용하는 맥OS 버전의 크롬에서는 발생하지 않는다.

버그 환경 재연 방법

The Pro Shop 웹 사이트에서 상품 상세 페이지에서 ‘Size Chart’ 링크를 클릭하여 팝업창을 열어 마우스 휠로 스크롤이 잘 되는 것을 확인할 수 있다. 하지만 클릭을 하거나 드래그를 하면 가로/세로 스크롤바가 비활성화된 것처럼 반응하지 않는다. ( 테스트를 하려면 이 링크를 클릭하세요 )


[그림1] Size Chart 링크의 팝업창에서 스크롤바가 비활성화 되어 있음. (활성화 되어야 정상)


[그림2] 스크롤바가 비활성화 된 상태


문제 원인

이 버그에 대해 토론되는 스레드를 통해 답을 얻을 수 있다. 스레드 이슈의 버그 332797 번은 윈도우 운영체제에서 Aura 테마에서 발생한다. Chromium 소스 코드의 Side by Side Diff: ui/events/win/events_win.cc에서 수정이 되었다.


[그림3] 버그의 원인 및 버그 픽스 커밋


[그림4] Chromium Canary 빌드에서 쿠팡 웹사이트 또한 비활성화된 스크롤바가 정상적으로 활성화가 됨


(윈도우 테마를 고전 테마로 변경하면 크롬 브라우저의 버그가 나타나지 않음)

아직 최신 크롬 브라우저에는 반영이 되지 않은 상태이며, Chromium Canary 버전을 사용하면 버그가 수정이 되어있다. 크로미움 카나리어 빌드(Chromium Canary Build) 버전은 기존의 크롬이나 크로미움 브라우저와 다른 폴더에 중복 설치가 가능한 버전이다.

현재 크롬 브라우저 버전에서는 아직 이 버그의 해결 방법은 없다. 따라서 이 문제가 발생한다는 보고를 받았다면 차기 업데이트 버전을 기다려겠다.

Posted by 땡초 POWERUMC

댓글을 달아 주세요

  1. icar... 2016.05.27 23:48 Address Modify/Delete Reply

    안녕하세요ㅜㅜ 혹시 스마트폰의 크롬앱에도 이렇게 스크롤바 작동하도록 하는 방법은 없을까요?? 크롬어플로 인터넷을 하는데 설정창에서는 스크롤바가 터치로 땡겨지거든요 그런데 블로그같은 인터넷창(웹페이지)에서는 스크롤바 끌어내리기/올리기가 안되고 그냥 터치할 때 보여지기만 해요ㅠㅠ 혹시 이걸 고치고 싶다면 뭘 배워야하나요ㅠㅠ정말 이런 언어?쪽은 문외한인지라...실낱같은 희망 붙들고 몇자 적어 도움을 구해봅니다ㅠㅠ