티스토리 뷰

사용자 식별은 비즈니스의 중요한 역할을 담당합니다. 사용자를 식별함으로써 개인 맞춤형 화면 및 광고를 제공할 수 있고, 부정거래나 어뷰징 유저를 탐지할 수도 있습니다. 다만, 사용자 식별은 우리 생각만큼 쉽지 않습니다. 로그인을 하지 않고 서비스를 이용하는 경우 사용자의 로그인 정보를 확보할 수 없습니다. 사용자가 쿠키를 지웠거나 시크릿 모드로 접속한다면 쿠키의 도움을 받을 수도 없습니다. 전통적으로 사용자 식별은 쿠키에 의존하였고, 아직도 쿠키를 이용하는 경우가 많습니다. 하지만 점점 높아지는 개인정보 보호에 대한 요구와 이에 따르는 쿠키 정책 변경으로 인해 쿠키 외의 대안을 생각해 볼 필요가 있습니다.

 

"쿠키리스 시대의 도래...", 미디어오늘, 2023.08.19 기사 링크
"개인정보보호 소송에 시달리는 구글과 메타", 임팩트온, 2023.08.10 기사 링크

 

 

 Browser Fingerprint란 사용자의 브라우저에서 수집되는 여러 속성들을 의미합니다. 현대의 브라우저는 매우 많은 속성을 갖고 있으며 각 속성마다 복잡한 특징을 가지고 있습니다. 이를 결합하여 브라우저마다의 유니크한 식별자를 만들고자함이 browser fingerprinting 기술의 핵심입니다. Fingerprint라고 이름 붙인 것에서 유추할 수 있듯이 우리는 (1)기기마다 서로 다른 값을 갖게 되는 유일성과 (2)시간이 지나고 환경이 달라져도 변하지 않는 불변성을 기대할 수 있습니다.


 한가지 유의할 사항은 browser fingerprint 값에는 사용자가 능동적으로 입력하는 정보(ex. 로그인 아이디)는 포함하지 않는다는 것입니다. 이런 정보를 포함하게 되면 동일한 브라우저에 사용자가 다른 값을 입력하면 fingerprint가 달라지게 되어 불변의 식별자를 얻고자 하는 목적에서 어긋나게 됩니다.

 

browser fingerprint? device fingerprint?

 종종 browser fingerprint와 device fingerprint 두 용어가 혼용되어 사용되고 있습니다. 두 가지 모두 브라우저(디바이스)단에서 획득할 수 있는 정보를 모아 식별자를 만들고자 하는 목적에서 동일하지만, 디바이스 핑거프린트의 경우 주로 하드웨어 관련 정보(ex. cpu, 그래픽 카드 등)를 이용하고 브라우저 핑거프린트의 경우 브라우저에서 얻을 수 있는 정보(ex. local storage, plugin, 폰트 등)를 활용한다는 차이가 있습니다. 이 글에서는 (browser) fingerprint로 통일하여 기술하도록 하겠습니다.

 

fingerprint 속성 및 동작 원리

 Browser fingerprint는 Javascript의 여러 API를 호출하여 수집한 많은 속성들을 하나로 엮은 후 hash값을 생성하여 식별자로 사용합니다. 유명한 fingerprint library인 FingerprintJS에서는 30개가 넘는 브라우저 및 디바이스 속성을 수집하여 fingerprint 식별자 생성에 사용하고 있습니다. 수집하는 속성들은 브라우저 기술의 변화에 따라 상이할 수 있습니다. 예를 들어, 최근에 애플페이 관련 속성이 FingerprintJS에 새롭게 추가되었습니다.

 

 지금부터 자주 사용되는 fingerprint에 속성 세 가지를 예시 코드와 함께 살펴보도록 하겠습니다.

 

설치 폰트 정보

 수많은 폰트 중 어떤 것이 설치되어 있는지는 수많은 조합이 존재하기에 상당한 식별성을 갖는 정보입니다. 아래 코드처럼 브루트 포스 방식으로 설치된 폰트를 감지합니다. 특정 문자열을 렌더링 한 후 세 가지 기본 폰트와 비교하여 글자의 너비/높이에 차이가 있는지를 확인하는 방식으로 특정 폰트의 존재 여부를 확인합니다. 기본 폰트 설정 시 높넓이와 확인하려는 서체의 높넓이가 같으면 '설치된 서체가 아니다'라고 판단하는 원리입니다. 구체적인 방식은 fingerprint 라이브러리마다 조금씩 차이가 있을 수 있습니다.

// span tag 생성
const spansContainer = document.createElement('div')
spansContainer.style.setProperty('visibility', 'hidden', 'important')

// 폰트 감지에 사용할 문자열. M, W를 사용하면 최대 width를 사용할 수 있음
const testString = 'mmMwWLliI0O&1'
// 텍스트 사이즈. 클수록 잘 감지됨
const textSize = '48px'

// 확인할 폰트와 비교할 기본 폰트
const baseFonts = ['monospace', 'sans-serif', 'serif']

// 설치 여부를 확인할 폰트 리스트
const fontList = [
  'sans-serif-thin', 'ARNO PRO', 'Agency FB', 'Arabic Typesetting', ..., 'Univers CE 55 Medium', 'Vrinda','ZWAdobeF',
]

const h = document.getElementsByTagName("body")[0];

// 문자열을 렌더링할 span 태그 생성
const s = document.createElement("span")
s.style.fontSize = textSize
s.textContent = testString
const defaultWidth = {}
const defaultHeight = {}
for (const index in baseFonts) {
    s.style.fontFamily = baseFonts[index];
    h.appendChild(s);
    defaultWidth[baseFonts[index]] = s.offsetWidth;
    defaultHeight[baseFonts[index]] = s.offsetHeight;
    h.removeChild(s);
}

const detect = (font) => {
    let detected = false;
    for (const index in baseFonts) {
        s.style.fontFamily = font + ',' + baseFonts[index]; // 감지할 폰트와 기본 폰트 설정
        h.appendChild(s);
        const matched = (s.offsetWidth != defaultWidth[baseFonts[index]] || s.offsetHeight != defaultHeight[baseFonts[index]]);
        h.removeChild(s);
        detected = detected || matched;
    }
    return detected;
}

for(const font of fontList) {
    console.log(font, detect(font))
}

source 출처

스크린 정보

 수많은 인터넷 접속 기기들은 다양한 특징의 스크린을 사용합니다. 스크린 해상도, 터치 조작 지원여부 등의 스크린 관련 정보도 browser fingerprint에 자주 사용되는 속성입니다. screen, navigator 객체에 접근하면 필요한 정보를 간단히 가져올 수 있습니다.

// 스크린 해상도
const scr = screen

const parseDimension = (value) => parseInt(value) ? parseInt(value) : null
const dimensions = [parseDimension(scr.width), parseDimension(scr.height)]
dimensions.sort().reverse()

const n = navigator

// 터치 관련 정보 (maxTouchPoints, touchEvent, touchStart)
let maxTouchPoints = 0
let touchEvent
if (n.maxTouchPoints !== undefined) {
  maxTouchPoints = parseInt(n.maxTouchPoints)
} else if (n.msMaxTouchPoints !== undefined) {
  maxTouchPoints = n.msMaxTouchPoints
}
try {
  document.createEvent('TouchEvent')
  touchEvent = true
} catch {
  touchEvent = false
}
const touchStart = 'ontouchstart' in window

source 출처 1 (screen resolution)
source 출처 2 (touch support)

Canvas

 HTML5에서 새롭게 추가된 canvas 태그를 이용하여 우리는 화면에 텍스트나 이미지 등 여러 그래픽을 표현할 수 있습니다. canvas fingerprint는 여러 디바이스들이 동일한 캔버스 이미지를 그릴때 그래픽 카드, 그래픽 드라이버, 브라우저, 운영체제 등등의 여러 요소들에 의해 픽셀값이 민감하게 달라짐을 응용합니다. canvas 태그를 생성하여 이미지를 생성한 후 toDataURL 함수를 사용하여 이미지 픽셀의 인코딩 값을 fingerprint로 사용합니다.

canvas fingerprint에 사용되는 이미지들

// canvas 생성
const canvas = document.createElement('canvas');
const ctx = canvas.getContext("2d");
const txt = 'abz190#$%^@£éú';

ctx.fillStyle = "rgb(255,0,255)";
ctx.beginPath();
ctx.rect(20, 20, 150, 100);
ctx.fill();
ctx.stroke();
ctx.closePath();
ctx.beginPath();
ctx.fillStyle = "rgb(0,255,255)";
ctx.arc(50, 50, 50, 0, Math.PI * 2, true);
ctx.fill();
ctx.stroke();   
ctx.closePath();


ctx.textBaseline = "top";
ctx.font = '17px "Arial 17"';
ctx.textBaseline = "alphabetic";
ctx.fillStyle = "rgb(255,5,5)";
ctx.rotate(.03);
ctx.fillText(txt, 4, 17);
ctx.fillStyle = "rgb(155,255,5)";
ctx.shadowBlur=8;
ctx.shadowColor="red";
ctx.fillRect(20,12,100,5);

const src = canvas.toDataURL(); // 이미지 픽셀의 Base64 인코딩 값
console.log(src)

 그 외에도 브라우저 종류 및 버전, 오디오 속성, 비디오 카드, ip 주소, 플러그인, cpu, geolocation 등 여러 정보를 수집하여 fingerprint로 사용할 수 있습니다. (참고 링크) 이렇게 수집된 fingerprint 속성들을 하나로 모아 해시값을 생성하여 식별자로 사용하면 됩니다. 위에서 알아본 폰트, 스크린, 캔버스 속성을 이용해 fingerprint 해시값을 만드는 예시는 아래 전체 코드를 확인해 보시기 바랍니다.

fingerprint 전체 코드 펼치기
    "use strict";
    (function () {
      let features = ''

      // get fonts
      const spansContainer = document.createElement('div')
      spansContainer.style.setProperty('visibility', 'hidden', 'important')

      const testString = 'mmMwWLliI0O&1'
      const textSize = '48px'

      const baseFonts = ['monospace', 'sans-serif', 'serif']

      const fontList = [
        'sans-serif-thin', 'ARNO PRO', 'Agency FB', 'Arabic Typesetting',
        'Arial Unicode MS', 'AvantGarde Bk BT', 'BankGothic Md BT', 'Batang',
        'Bitstream Vera Sans Mono', 'Calibri', 'Century', 'Century Gothic',
        'Clarendon', 'EUROSTILE', 'Franklin Gothic', 'Futura Bk BT', 'Futura Md BT',
        'GOTHAM', 'Gill Sans', 'HELV', 'Haettenschweiler', 'Helvetica Neue',
        'Humanst521 BT', 'Leelawadee', 'Letter Gothic', 'Levenim MT',
        'Lucida Bright', 'Lucida Sans', 'Menlo', 'MS Mincho',
        'MS Outlook', 'MS Reference Specialty', 'MS UI Gothic',
        'MT Extra', 'MYRIAD PRO', 'Marlett', 'Meiryo UI',
        'Microsoft Uighur', 'Minion Pro', 'Monotype Corsiva', 'PMingLiU',
        'Pristina', 'SCRIPTINA', 'Segoe UI Light', 'Serifa', 'SimHei', 'Small Fonts',
        'Staccato222 BT', 'TRAJAN PRO', 'Univers CE 55 Medium', 'Vrinda','ZWAdobeF',
      ]

      const h = document.getElementsByTagName("body")[0];

      const s = document.createElement("span")
      s.style.fontSize = textSize
      s.textContent = testString
      const defaultWidth = {}
      const defaultHeight = {}
      for (const index in baseFonts) {
          s.style.fontFamily = baseFonts[index];
          h.appendChild(s);
          defaultWidth[baseFonts[index]] = s.offsetWidth;
          defaultHeight[baseFonts[index]] = s.offsetHeight;
          h.removeChild(s);
      }

      const detect = (font) => {
          let detected = false;
          for (const index in baseFonts) {
              s.style.fontFamily = font + ',' + baseFonts[index];
              h.appendChild(s);
              const matched = (s.offsetWidth != defaultWidth[baseFonts[index]] || s.offsetHeight != defaultHeight[baseFonts[index]]);
              h.removeChild(s);
              detected = detected || matched;
          }
          return detected;
      }

      features += fontList.filter(detect).join(",")

      // get screen features
      const scr = screen

      const parseDimension = (value) => parseInt(value) ? parseInt(value) : null
      const dimensions = [parseDimension(scr.width), parseDimension(scr.height)]
      dimensions.sort().reverse()

      features += dimensions.join(",")

      const n = navigator

      let maxTouchPoints = 0
      let touchEvent
      if (n.maxTouchPoints !== undefined) {
        maxTouchPoints = parseInt(n.maxTouchPoints)
      } else if (n.msMaxTouchPoints !== undefined) {
        maxTouchPoints = n.msMaxTouchPoints
      }
      try {
        document.createEvent('TouchEvent')
        touchEvent = true
      } catch {
        touchEvent = false
      }
      const touchStart = 'ontouchstart' in window

      features += maxTouchPoints
      features += touchEvent
      features += touchStart

      // get canvas fingerprint
      const canvas = document.createElement('canvas');
      const ctx = canvas.getContext("2d");
      const txt = 'abz190#$%^@£éú';

      ctx.fillStyle = "rgb(255,0,255)";
      ctx.beginPath();
      ctx.rect(20, 20, 150, 100);
      ctx.fill();
      ctx.stroke();
      ctx.closePath();
      ctx.beginPath();
      ctx.fillStyle = "rgb(0,255,255)";
      ctx.arc(50, 50, 50, 0, Math.PI * 2, true);
      ctx.fill();
      ctx.stroke();   
      ctx.closePath();


      ctx.textBaseline = "top";
      ctx.font = '17px "Arial 17"';
      ctx.textBaseline = "alphabetic";
      ctx.fillStyle = "rgb(255,5,5)";
      ctx.rotate(.03);
      ctx.fillText(txt, 4, 17);
      ctx.fillStyle = "rgb(155,255,5)";
      ctx.shadowBlur=8;
      ctx.shadowColor="red";
      ctx.fillRect(20,12,100,5);

      features += canvas.toDataURL();

      // get hash
      // 출처 : https://stackoverflow.com/a/43383990
      function getHash(str, algo = "SHA-256") {
          let strBuf = new TextEncoder().encode(str);
          return crypto.subtle.digest(algo, strBuf)
            .then(hash => {
              window.hash = hash;
              // here hash is an arrayBuffer, 
              // so we'll connvert it to its hex version
              let result = '';
              const view = new DataView(hash);
              for (let i = 0; i < hash.byteLength; i += 4) {
                result += ('00000000' + view.getUint32(i).toString(16)).slice(-8);
              }
              return result;
            });
        }

      getHash(features)
        .then(hash => {
          console.log(hash); // ex)1f516091df3364592a2f6d63cd4bdk97b53044cab3dedf0
        });
    })()

 

 

 위의 코드를 직접 실행하기위해 간단히 웹 서버를 띄워볼 수도 있지만, 저는 Chrome의 스니펫(Snippet) 기능을 활용하였습니다. 아래 화면처럼 위 코드를 Snippet으로 저장한 후 실행하시면 console에 해시값이 출력되는 것을 간단히 확인할 수 있습니다.

 

fingerprint 수집&처리 workflow

 이제 fingerprint 정보가 수집되고 처리되는 workflow를 살펴보도록 하겠습니다. 상기한 js 코드와 같은 fingerprint 수집 라이브러리를 서비스에 접속하는 클라이언트 브라우저에서 실행하여 workflow가 시작되고, 수집된 fingerprint 정보들을 브라우저가 웹 서버로 전송하면 비즈니스 목적에 따라 다양한 작업을 수행하게 됩니다. 예를 들어, fingerprint를 다른 식별자와 다시 mapping 하는 작업이 필요할 수 있습니다. 앞서 fingerprint가 유일하며 불변한다고 기대하지만 실제로 운영하다 보면 중복되고 변경되는 경우들이 자주 관찰되는데요. 이를 위해 별도의 ID 체계를 운영하면서 수집된 fingerprint를 이와 mapping 하는 작업이 필요할 수 있습니다.

 

 이에 대해서는 한계 및 예상되는 이슈 단락에서 다시 살펴보겠습니다. 또는 수집된 fingerprint의 속성 그 자체가 분석적인 가치가 있을 수 있습니다. 예를 들어, 폰트나 스크린 사이즈 정보를 분석하여 UI 화면 구성에 참고할 수 있습니다. 회원 정보나, 결제 데이터 등과 함께 이용될 수도 있습니다. 이를 위해 비즈니스 목적에 맞추어 fingerprint 속성값들을 정제하여 데이터베이스에 저장하고 관리할 필요가 있습니다.

 

한계 및 예상되는 이슈

Fingerprint 기술을 이용하게 되면서 맞닥뜨리는 한계점 및 예상되는 이슈를 알아보도록 하겠습니다.

신뢰하기 힘든 유일성과 불변성

 혹자는 은탄환은 없다(No Silver Bullet)는 말을 자주 합니다. 특히 엔지니어링의 세계는 이 말을 더 자주 상기하게 되는데요. 기기마다 유일하고 불변할 것으로 보이던, 마치 우리의 지문처럼 사용할 수 있을 것 같은 fingerprint가 실제로는 기대와 다른 모습을 자주 보입니다.

 

 PC나 laptop에 비해 기기 종류가 많지 않은 모바일의 경우 fingerprint가 동일한 경우가 자주 관찰됩니다(유일성 이슈). 또한, 동일한 기기라도 수집되는 속성 중에 변동이 있는 경우 fingerprint가 바뀔 수 있습니다(불변성 이슈). 듀얼 모니터를 사용하신다면 각 모니터에 브라우저를 띄워놓고 위의 fingerprint 코드를 실행하면 서로 다른 값이 생성되는 것을 확인할 수 있습니다. 스크린 사이즈가 다르기 때문입니다. 이러한 단점을 보안하기 위해서는 불변성을 자주 해치는(변동이 심한) 속성을 파악하여 fingerprint 수집시 제외하는 것이 좋습니다. 

 

 Fingerprint의 유일성을 높이기 위해 새로운 속성을 연구해 볼 필요도 있습니다. 상용 fingerprint 솔루션들은 각자 나름의 속성을 고안하여 식별성을 제고하고 있습니다. 디바이스 배터리 정보, 마우스 움직임 패턴, 웹페이지 로딩시간 등 현재도 새로운 fingerprint 속성들이 연구되고 입습니다. 이런 트렌드를 잘 모니터링하여 새로운 fingerprint를 개발하는 것도 흥미로운 도전과제가 되겠습니다.


 결론적으로 fingerprint는 100% 완벽할 수 없다는 한계를 인지하고 활용하는 것이 바람직합니다. Fingerprint를 결제 서비스에서 식별자로 사용할 수 없겠지만, 마케팅 용도로 사용한다면 좋은 적용 예시라고 할 수 있겠습니다.

 

<참고문헌>

[1]Ł. Olejnik, G. Acar, C. Castelluccia, and C. Diaz, “The leaking battery - a privacy analysis of the HTML5 Battery Status API”, 2016
[2]W. Fuhl, N. Sanamrad, and E. Kasneci, “The gaze and mouse signal as additional source for user fingerprints in browser applications”, 2021
[3]T.Wu,Y.Song,F.Zhang,S.Gao,andB.Chen,“Mysiteknows where you are: a novel browser fingerprint to track user posi- tion”, 2021

 

Performance 이슈

 앞서 소개한 폰트 fingerprint의 경우 브루트 포스 방식이기에 적지 않은 시간이 필요합니다. Canvas를 이용하는 경우도 이미지를 화면에 그리는 시간이 필요합니다. 이렇듯 fingerprint에 사용되는 다양한 속성들 중 식별성이 높을수록 퍼포먼스에 영향을 미칠 가능성이 큽니다. 만약 fingerprint 라이브러리가 속성을 수집하는 도중에 사용자가 페이지를 이동한다면 이 트래픽에서의 사용자 식별이 불가능하게 됩니다.

 

 이런 경우에 어떻게 대비해야 할까요? 이전에 생성한 fingerprint를 쿠키에 담아두어 유실된 경우 활용할 수 있습니다. 또는 서버에서 이후 분석시점에서 후처리하는 방식도 있습니다. 예를 들어, Database에 fingerprint와 session ID를 함께 보관하고 있다면, session ID를 기준으로 해당 유저의 fingerprint를 찾아 유실된 트래픽에서 활용할 수 있을 것 입니다.

 

JavaScript 의존성

개인정보 보호 등의 이유로 Javascript를 막아두는 사용자들이 점차 증가하고 있습니다. 이를 위한 Chrome Extensions도 존재합니다.

 

 하지만 위의 코드에서 보셨듯이 fingerprint 기술은 Javascript API에 의존하고 있습니다. 따라서 사용자가 Javascript를 막는다면 fingerprint는 간단히 무효화됩니다. 이런 경우, Javascript가 전혀 필요없이 css의 media query를 이용하여 사용자 브라우저의 속성을 가져올 수 있는 CSS fingerprint라는 기술이 있습니다.(참고링크) 아래 코드는 @media query를 이용해 터치 스크린 여부를 확인하여 서버로 전송하는 CSS fingerprint 예시입니다. 다만, CSS fingerprint를 Javascript 기반 fingerprint의 대안으로 사용하기엔 부족한 점이 많습니다. 사용자가 Javascript를 막는 등의 특수한 상황에서 보조적으로 활용할 가치는 있습니다.

.pointer {
  background-image: url('/some/url/pointer=none');
}

// Coarse (touchscreen)
@media (any-pointer: coarse) {
  .pointer {
    background-image: url('/some/url/pointer=coarse');
  }
}

// Fine (mouse)
@media (any-pointer: fine) {
  .pointer {
    background-image: url('/some/url/pointer=fine');
  }
}

source 출처

 

 

지금까지 Browser Fingerprint에 대한 소개와 이를 실제로 현업에 활용할 시 만날 수 있는 이슈들, 그리고 이에 대한 해결책을 이야기해보았습니다. 이 글이 여러분들의 이해를 돕고 실제 서비스에 적용시 조금이나마 도움이 되길 바랍니다.😊

댓글