Log Stash

as an Industrial Personnel

프로그래밍/삽질

급여명세서 복호화하기

SavvyTuna 2017. 2. 7. 00:38

1. 발단

급여명세서를 복호화한다니 조금 이상하게 들릴 수 있겠지만, 사연은 이렇다. 2016년까진 급여명세서가 각자 회사 메일로 발송되어서 별다른 절차 없이 링크 하나만 누르면 볼 수 있었다. 그러다 2017년에 ERP 시스템이 바뀌었는지 1월달 급여명세서 메일을 받아보니 마치 통신사 요금명세서처럼 첨부된 html 파일을 다운받아 ‘인터넷 익스플로러’에서 열어서 ‘액티브 엑스’컨트롤을 설치하고, 설정된 비밀번호를 입력하면 급여 명세서를 보여주는 방식으로 바뀌게 되었다.


물론 내가 주로 사용하는 회사 컴퓨터나 집에 있는 데스크톱에는 전부 윈도우가 설치되어 있기 때문에 급여명세서를 보자면 볼 수는 있겠지만, 굳이 익스플로러를 찾아서 열고 거기에 엑티브 엑스 컨트롤을 설치해서 보는 것은 사실 나게엔 매우 귀찮은 일이다. 게다가 종종 카페에 가거나 할 땐 데스크톱을 들고 갈 수는 없으니 맥북을 가져가는데 맥에선 당연히 급여명세서를 볼 수 없고. 그래서 ‘아 이거 어떻게 IE랑 ActiveX 없이 볼 수 있는 방법 없을까’ 하는 생각으로 html 파일을 다운받아 에디터에서 열어보았다.


2. HTML파일

우선 제일 먼저 급여명세서 정보를 외부 서버에서 받아오는지 아닌지부터 확인해야 했다. 비밀번호를 입력했을 때 이걸 서버로 보내 응답으로 급여명세서를 받아오는 방식이면 내가 할 수 있는 게 없을테니까. html 파일을 열어 살펴보니 다행히(?)도 암호화된 html 코드 자체를 내부 문자열로 가지고 있었고, 비밀번호를 입력하면 실행되는 자바스크립트 함수가 하나 실행되는데, 그곳에서 암호화 액티브엑스 컨트롤에 접근해 문자열을 복호화, 그 내용을 DOM에 추가하는 방식으로 급여 명세서를 보여주고 있었다.


아래는 첨부된 html 파일에 있는 js 코드 조각이다.

function ViewPayPaper(){ 
     var cliperText; 
     var plainText; 
     var pwd; 
     var CAPICOM_SECRET_PASSWORD = 0; 
     if (document.frm.pwd.value.length != 7) 
     { 
         alert('���й�ȣ�� �ùٸ��� �ʽ��ϴ�.'); 
         document.frm.pwd.value = ''; 
         document.frm.pwd.focus(); 
         return; 
     } 
     cliperText = document.frm._viewData.value; 
     document.capicom.SetSecret(document.frm.pwd.value, CAPICOM_SECRET_PASSWORD); 
     try 
     { 
         document.capicom.Decrypt(cliperText) 
     } 
     catch(err) 
     { 
         alert('���й�ȣ�� ��ġ���� �ʽ��ϴ�.'); 
         document.frm.pwd.value = ''; 
         document.frm.pwd.focus(); 
         return; 
     } 
     plainText = document.capicom.Content; 
     document.write(plainText); 
}

document에서 전에 본적 없는 capicom 객체에 있는 각종 함수나 변수들 (SetSecret, Decrypt, Content) 을 사용하고 있는 것을 보고, html 코드에서 ‘capicom’이라는 문자열을 검색해서 찾아보니 아래와 같은 object 노드가 선언되어 있었다.


<object id='capicom' classid='CLSID:A440BD76-CFE1-4D46-AB1F-15F238437A3D' codebase='http://asp.duzonerp.co.kr/CodeBase/capicom.cab#Version=2,1,0,2'  width='0' height='0' ></object>

<form name='frm' id='frm' action=''> 
       <input type='hidden' name='_viewData' value='MILT/AYJKwYBBAGCN1gDoILT7TCC0kGCisGAQQBgjdYAwGggtPZMILT1QIDAgA... (후략)

classidcodebase 속성들을 보아하니 아마 이 html 노드가 IE에서 액티브엑스 컨트롤을 설치하게 하는 주범인듯 싶다. ‘duzonerp’라는 이름을 보니 아마 두존이라는 이름의 erp 시스템을 사용하는것 같고. (사실 두존이라고 검색하면 아무것도 나오지 않는다. 왜 그런지 싶어서 더 찾아봤더니 두존이 아니라 ‘더존’ 시스템이라고 하더라.) 그 아래엔 바로 form 태그와, 그 안에 암호화된 급여명세서 문자열이 _viewData 라는 이름으로 들어 있었다.


아무튼, 파폭이나 크롬에서 자바스크립트 콘솔을 통해 document.capicom에 접근하면 ‘undefined’가 뜨는 반면에 IE에서만 저 객체에 접근이 가능함을 확인하고, 저걸 어떻게 대체할 수 있을지 생각해보기로 하면서, 일단 저 URL에 있는 cab파일을 다운받아 압축을 풀어 DLL 파일을 꺼내서 디스어셈블 툴에 끌어다 놓고 있었다.


3. CAPICOM

파일 받고 압축 풀고 이런 짓을 했던 이유가 capicom이란 액티브엑스 컨트롤을 난 저 '더존ERP' 시스템에서 독자적으로 만든 라이브러리로 생각했기 때문인데, 구글링 한 번만 했으면 저 삽질을 꼭 하지 않아도 됐었다.


사실 capicom은 마이크로소프트에서 만든 암호화(Cryptographic) API COM 컨트롤이다. 이걸 줄여서 CAPICOM 이라고 했던 것이고. 마이크로소프트에서 만든 라이브러리라 그런지 MSDN에 API가 친절하게 다 설명이 되어 있었다. 그래서 다행히도 dll을 까부수지 않아도 API가 어떤 모양으로 되어있고 어떤 상수들을 인자로 받는지 쉽게 알아낼 수 있었다.



<IE에서는 capicom 객체가 보인다>


IE에서 자바스크립트쪽에 브레이크 포인트를 걸어놓고 document.capicom.Algorithm 내부 값을 디버거에서 봤을 때 KeyLength 가 3 (CAPICOM_ENCRYPTION_KEY_LENGTH_128_BITS)이고 Name이 0 (CAPICOM_ENCRYPTION_ALGORITHM_RC2) 인것으로 보아, 급여명세서 문자열(_viewData)은 rc2 128bit로 암호화 되었음을 알 수 있었다.


여기까지 알아보고 각종 온라인 암, 복호화 툴에서 rc2, 128비트 옵션으로 세팅하고 _viewData문자열을 복호화 해보려고 시도해봤는데 왠지 모르게 잘 안되고 다들 이상한 결과들만 나왔다. 그래서 일단 저게 진짜 복호화 되는 문자열이 맞는지 재 확인해보기 위해 일단 C#으로 capicom api를 써서 복호화 해보는 루틴을 짜보기도 했는데 (당연히) 잘 되어서 고민에 빠졌었다.


Capicom에 대해서 구글링한 결과를 하나씩 보고 있었는데, 그러다 이 아티클을 발견하게 되었다. 아티클에 따르면, capicom으로 암호화된 데이터들은 그냥 평문에 대한 정보만 가지고 있는 게 아니라 복호화에 필요한 알고리즘 종류, 키 길이, 초기화 벡터(Initialization Vector), Salt 값들을 구조에 맞게 가지고 있다. 그러니 그냥 암호화된 문자열을 무작정 복호화 하려고 해도 안됐었던 것이다. 바이너리 구조를 표현할 때 ASN.1 이라는 표기법 중에 DER이라는 인코딩 방식을 쓰는데, 간단히 말하자면 blob에서 바이트 열에 대한 정보를 앞에 미리 써 놓는 방식중에 하나다. 예를 들어 02 02 00 80 이라 하면 앞에서 부터 각각 숫자 형이고, 길이가 2이고 값은 00 80 인 바이트 정보를 나타낸다. (자세한 표기법에 대해선 위키 참조)

그럼 저 _viewData가 진짜 ASN.1 포맷으로 이루어진 데이터인가? 를 검증하기 위해 온라인 툴을 찾아 암호화된 문자열들을 붙여넣기 해보았더니 제대로 잘 파싱 되었다.


<ASN.1로 파싱된 결과물이 아티클에서 설명한 구조대로 나온것을 볼 수 있다>


아까 언급한 아티클에 따르면 가장 깊은 곳에 있는 시퀀스중에 있는 옥텟 스트링들이(각각 54, 68, 88 바이트부터 시작) 각각 IV, salt, 본문 이라고 한다. 앞으로 복호화 루틴을 작성할 때 이 위치를 오프셋으로 잡아 값들을 분리할 수 있으니 잘 기억해 두기로 하면서, 일단 C#으로 capicom없이 급여명세서를 복호화할 수 있는 프로그램을 만들어보도록 했다.


4. PayPaper (C#)

다른 언어가 아닌 C#을 선택한 이유는 일단 현재 일하면서 제일 자주 쓰는 언어이기도 하면서, 위에서 언급한 간단한 테스트 프로그램을 만들때도 사용했기도 했고, 암호화 라이브러리를 따로 설치하지 않아도 내장되어있는 언어라고 생각했기 때문이다. 게다가 C#으로 짜면 Mono로 맥에서도 돌릴 수 있으니 어떻게든 되지 않을까 하는 생각도 있었고. (하지만 결국 HTML파싱에 HtmlAgilityPack을 써서 외부 라이브러리 종속성이 생겨버렸다)


아래는 지원이 끊겨버린 CAPICOM API를 안 쓰고 RC2 128bit 암호화된 나의 급여 명세서를 복호화하는 프로그램이다.


// Program.cs

using HtmlAgilityPack;
using System;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;

namespace PayPaper
{
    // References

    // CAPICOM blob ASN : http://www.jensign.com/JavaScience/dotnet/DeriveBytes/
    // ASN DECODER : http://lapo.it/asn1js/#
    // ASN? : https://en.wikipedia.org/wiki/Abstract_Syntax_Notation_One

    class Program
    {
        static void Main(string[] args)
        {
            if (args.Length < 2)
            {
                Console.WriteLine("Usage");
                Console.WriteLine("PayPaper (key) (html file) [> (output redirection)]");
                return;
            }

            string key = args[0];
            string filename = args[1];

            try
            {
                HtmlDocument htm = new HtmlDocument();
                htm.Load(filename);

                HtmlNode ciperNode =
                    htm.DocumentNode.Descendants()
                    .Where(
                        node => node.Name.Equals("input")
                        && node.Attributes["name"] != null
                        && node.Attributes["name"].Value.Equals("_viewData")
                    ).ToList().First();

                string ciperText = ciperNode.Attributes["value"].Value;

                byte[] decryptedBytes = DecryptCapicomRC2(key, ciperText);

                Console.WriteLine(Encoding.Unicode.GetString(decryptedBytes));
            }
            catch (Exception e)
            {
                Console.WriteLine(e.Message);
            }
        }

        private static byte[] DecryptCapicomRC2(string key, string ciperText)
        {
            byte[] contentByte = Convert.FromBase64String(ciperText);

            byte[] IV = contentByte.Skip(56 + 2).Take(8).ToArray();
            byte[] salt = contentByte.Skip(66 + 2).Take(16).ToArray();
            byte[] ciperTextByte = contentByte.Skip(84 + 4).ToArray();

            RC2CryptoServiceProvider rc2CSP = new RC2CryptoServiceProvider();

            rc2CSP.Key = CHashSaltPswd(salt, new UnicodeEncoding().GetBytes(key)).Take(16).ToArray();
            rc2CSP.IV = IV;
            rc2CSP.Mode = CipherMode.CBC;
            rc2CSP.Padding = PaddingMode.Zeros;

            using (Stream stream = new MemoryStream(ciperTextByte))
            {
                using (CryptoStream cryptoStream = new CryptoStream(stream, rc2CSP.CreateDecryptor(), CryptoStreamMode.Read))
                {
                    try
                    {
                        byte[] decryptedBytes = new byte[ciperTextByte.Length];
                        cryptoStream.Read(decryptedBytes, 0, decryptedBytes.Length);

                        return decryptedBytes;
                    }
                    catch (Exception e)
                    {
                        Console.Write(e.Message);
                    }
                }
            }

            return new byte[0];
        }

        // generate salted key bytes
        private static byte[] CHashSaltPswd(byte[] salt, byte[] pswd)
        {
            SHA1 sha1 = SHA1.Create();
            CryptoStream cs = new CryptoStream(Stream.Null, sha1, CryptoStreamMode.Write);
            cs.Write(pswd, 0, pswd.Length);
            cs.Write(salt, 0, salt.Length);
            cs.FlushFinalBlock();
            return sha1.Hash;
        }
    }
}

RC2 암호화 알고리즘을 내가 짠 것도 아니고, API에 대한 설명도 MSDN에 아주 잘 나와 있으니 단순설명은 의미 없는 것 같아서 건너뛴다.


여기서 개인적으로 삽질한 부분은. 첫 번째로, 비밀번호에 salt를 쳐서 최종 key 값을 계산할 때 비밀번호 바이트 배열을 유니코드로 변환했었어야 했다는 점이다. (CHashSaltPswd 호출 부분) 암, 복호화 알고리즘들이 다들 그렇듯 바이트 하나만 달라져도 결과값이 완전히 깨져버리는데, CAPICOM 내부에서 blob들이 어떻게 처리되는지, 알고리즘 객체들이 어떻게 생성되는지 볼 수도 없었던 상황이라 여기서 시간 낭비를 좀 많이 했다 ..만 역시나 위의 아티클에 다 나와 있다. 결국 꼼꼼히 안 읽으면 이렇게 삽질을 하게 된다. 두 번째로는, 암호문의 빈 공간을 채워주는 (padding) 방식에 여러가지 방법이 있는데, 패딩 방식에 대한 플래그가 기본값인 PKCS#7으로 되어있었다. PKCS#7 표준 문법에 대해 잘 알고 있진 않았지만 (위의 아티클에서도 말했듯) CAPICOM의 blob 구조는 이것과 많이 다르다고 했던 게 생각나서 하나하나씩 플래그를 바꿔봤더니 PaddingMode.Zeros 에서 드디어 동작했다.


그리고 LINQ는 역시 편하다. 유니티에서도 좀 쓰고 싶다.


<.exe 파일을 맥에서 실행한 결과로 html이 복호화 되어 나온다>



당연히 닷넷 프레임워크를 기반으로 빌드했기 때문에 맥에서도 Mono를 통해 실행하면 똑같이 작동한다.


5. PayPaper (Node.js)

날이 지나고 잘 생각해보니 C# 프로그램(.exe)은 너무 접근성이 안 좋았다. 일단 ‘구현’만을 위해 익숙한 C#을 쓰긴 했지만 아무도 월급날에 급여명세서를 보기 위해 프로그램을 다운받아 (게다가 .dll까지 같이!) cmd에서 실행해서, command line 인자들을 넣으면서 output redirection을 통해 html파일을 만들어내고, 그걸 브라우저에서 다시 열 생각을 할 것 같지가 않았다. 나부터 아마 귀찮아서 ActiveX를 설치할것만 같은 그런 복잡함이었다.


그래서 그냥 웹으로 다시 만들기로 했다. 어차피 로직은 이미 짜놨고, 웬만한 메이저 프레임워크라면 암호화 라이브러리쯤은 있을테니. 그렇게 ‘장고로 만들지, 플라스크로 만들지, Node.js로 만들지’에 대한 찰나의 고민이 스쳐 지나갔는데, 결과적으론 그냥 노드로 짜는 걸로 했다.


장고나 플라스크, 그리고 그 라이브러리들의 언어인 파이썬에 대한 개인적인 감정은 없지만, 사실 위에서 C#으로 짜기 전에 간단히 파이썬으로 해보려고 했는데 환경 설정부터 뭐가 막 꼬여서… 지금 생각해보니 그냥 파이참 깔면 되겠지만, 그냥 이렇게 된 김에 오랜만에 노드나 다시 끄적여보자고 생각했다.


그렇게 아래와 같이 정말 간단한 웹 코드를 작성했다.


<!-- index.html -->
<html>
    <head>
        <title>pay paper</title>
    </head>
    <body>
        <h2>Pay paper Decrypter</h2>
        <form action="/decrypt" method="POST" encType="multipart/form-data">
            <input type="file" name="paper" accept=".htm">
            <input type="password" name="password" maxlength=7>
            <input type="submit" value="submit">
        </form>
    </body>
</html>
// index.js

var express = require('express')
var path = require('path')
var fileUpload = require('express-fileupload');

var fs = require('fs')
var jQuery = require("jquery")
var jsdom = require("jsdom")

var crypto = require('crypto')
var Iconv = require('iconv').Iconv

var app = express()

app.use(fileUpload());

app.get('/', function (req, res) {
    res.sendFile(path.join(__dirname + "/index.html"))
})

app.post('/decrypt', function (req, res) {

    if (!req.files.paper) {
        res.send("No file specified.")
        return;
    }
    if (!req.body.password) {
        res.send("No password specified.")
        return;
    }

    jsdom.env(
        req.files.paper.data.toString(),

        function (err, window) {

            var $ = jQuery(window)

            var encrypted = $("input[name*='_viewData']").attr("value")

            try {
                var decrypted = decryptPayPaper(req.body.password, encrypted)

                // hack: force replace 'EUC-KR' => 'UTF-8'
                decrypted = decrypted.replace('EUC-KR', 'UTF-8')

                // send response
                res.send(decrypted) 
            }
            catch(e) {
                // console.log(e.message)
                res.send(e.message)
            }
        }
    )
})

app.listen(process.env.PORT || 3000)

function decryptPayPaper (password, encrypted) {

    // read blob from base64 encoded string
    var blob = Buffer.from(encrypted, 'base64')

    // find Initialization Vector, Salt, Content from Encrypted blob
    // ref : http://www.jensign.com/JavaScience/dotnet/DeriveBytes/
    var IV = blob.slice(56 + 2, 56 + 2 + 8)
    var salt = blob.slice(66 + 2, 66 + 2 + 16)

    var content = blob.slice(84 + 4, blob.length)

    // convert password into UNICODE string
    var iconv = new Iconv('utf-8', 'UTF-16LE')
    password = Buffer.from(password)
    password = iconv.convert(password)

    var key = hashSaltPassword(salt, password)

    // decrypt
    var decipher = crypto.createDecipheriv('rc2-cbc', key, IV)
    var decrypted1 = decipher.update(content)
    var decrypted2 = decipher.final()

    var decrypted = Buffer.concat([decrypted1, decrypted2])

    // convert 'decrypted' to utf8 string, from utf-16 Little Endian
    var iconv = new Iconv('UTF-16LE', 'utf-8')
    var decryptedUtf8 = iconv.convert(decrypted).toString()

    return decryptedUtf8
}

function hashSaltPassword (salt, password) {

    const hash = crypto.createHash("SHA1")

    hash.update(password)
    hash.update(salt)

    var saltedKey = hash.digest().slice(0, 16)
    return saltedKey
}

일단 보안적인 측면은 생각하지 않고, C# 프로그램에서 짰던 것과 똑같은 로직으로 빨리 작성하는데 의의를 두도록 하자.


여기서도 마찬가지로 다른 설명은 딱히 필요 없을 것 같고, 삽질 포인트를 몇 가지 이야기하자면.. 첫번째로 crypto 라이브러리의 input, output이 죄다 binary가 아니라 utf8인코딩 된 문자열이라는 점. C# 프로젝트에서 했던 것처럼 Key값을 생성하려면 유니코드(utf16-LE)로 인코딩했다가 다시 utf8로 변환해서 크립토 라이브러리에 넣어줘야 했는데, 여기서 좀 삽질을 많이 했다. 바이트 단위로 하나하나 로그 찍어서 어떻게 찾긴 했지만, C#에선 안 했던 삽질이라 좀 막혀서 귀찮았었다. 두번째로는 Bufferslice를 통해서 바이트 조각들을 잘라내야 했는데 실수로 인텔리센스만 보고 subArray를 썼더니 타입이 바뀌어져서 크립토 라이브러리 내부에서 익셉션이 일어났던 것이다. 처음엔 IV의 length가 다르다길래 로그 찍어 봤더니 length는 똑같이 8로 제대로 나오는데 사실 내부 바이트 길이는 달랐었고..


어쨌거나 이렇게 삽질을 통해 결국엔 실행되는 버전이 만들어졌다.


<여기에 .htm파일과 비밀번호를 입력하고 제출하면..>


<이렇게 급여명세서가 나온다>


어쨌건 결과는 잘 나온다. 다른 사람들 것도 잘 되는지 확인하고 싶지만 (혹시나 이 명세서만 잘되는 스크립트를 작성한 게 아닌가 싶어서) 하필이면 비밀번호가 개인정보이기도 하고, 결과물도 사적인 물건이라 2월 월급날을 기대해 보기로 한다. heroku에 올려서 친구들이나 해보라고 할까.


앞으로 기회가 되면 북마클릿으로 만들어 볼까 생각도 하곤 있는데, 지금보다 더 불편해지면 하는 걸로..


6. 그래서

사실 글이 ‘RC2-CBC 128bit 알고리즘의 원리를 알아보자’는 측면에서 쓰인 글이 아니라 그냥 ‘이런 저런 짓을 하는데 이런 짓을 해봤다’라는 잡담에 가까운 글이라 딱히 정리할 것은 없다만 주말 내내 이거에 붙들려서 시간을 많이 투자한 게 아까워서 후기를 남겨본다.


소스는 Github에 [link]