Microsoft MVP성태의 닷넷 이야기
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일
 
(연관된 글이 1개 있습니다.)

ASP.NET - HttpCookieCollection을 다중 스레드에서 사용할 경우 무한 루프 현상

HttpCookieCollection에는 HashSet<string> 타입의 _keysAwaitingValidation 필드를 가지고 있습니다. 대부분의 Set 자료 구조가 그렇듯이 HashSet<T>도 hash 기반의 bucket과 연결 리스트로 구성되어 있습니다.

당연히, HttpCookieCollection은 thread-safe한 타입이 아니며, 그 내부에서 사용하고 있는 HashSet<T> 역시 thread-safe하지 않습니다. 따라서, 다중 스레드에서 HttpCookieCollection을 접근한다면 HttpCookieCollection은 물론이고 내부에서 필드로 사용된 타입들의 안정성 역시 보장할 수 없습니다.

그렇긴 한데, 사실 HttpCookieCollection의 경우 ASP.NET에서 사용 시 HttpContext.Current.Request나 HttpContext.Current.Response를 통해 사용하는 것이 보통입니다. 그리고 대부분의 경우 Request를 통해 접근한다면 이미 ASP.NET Pipeline 내부에서 설정이 된 상태로 나오기 때문에 조회는 빈번해도 add/remove는 되지 않으므로 다중 스레드에서 사용해도 안전하게 동작을 합니다.

하지만, asp.net 환경에 따라 HttpCookieCollection의 _keysAwaitingValidation에 삭제 연산이 실행될 때가 있습니다. 바로, web.config에 RequestValidationMode를 .NET 4.5 이상으로 설정한 경우입니다.

<httpRuntime requestValidationMode="4.5" />

HttpRuntimeSection.RequestValidationMode 속성 
; https://docs.microsoft.com/en-us/dotnet/api/system.web.configuration.httpruntimesection.requestvalidationmode

이렇게 되면, asp.net은 쿠키에 대한 검증을 하도록 다음과 같은 코드가 실행됩니다.

// System.Web.HttpRequest
internal void ValidateInputIfRequiredByConfig()
{
    HttpRuntimeSection httpRuntime = RuntimeConfig.GetConfig(this.Context).HttpRuntime;
    ...[생략]...
    Version requestValidationMode = httpRuntime.RequestValidationMode;
    if (requestValidationMode == VersionUtil.Framework00)
    {
        this._flags[-2147483648] = true;
    }
    else if (requestValidationMode >= VersionUtil.Framework40)
    {
        this.ValidateInput();
        if (requestValidationMode >= VersionUtil.Framework45)
        {
            this.EnableGranularRequestValidation();
        }
    }
}

internal void EnableGranularRequestValidation()
{
    this._flags[0x40000000] = true;
}

private bool GranularValidationEnabled => this._flags[0x40000000];

이후, GranularValidationEnabled 여부에 따라 쿠키 값을 검증하는 콜백 메서드를 설정하는 코드가 실행됩니다.

private void ValidateCookieCollection(HttpCookieCollection cc)
{
    if (this.GranularValidationEnabled)
    {
        cc.EnableGranularValidation((key, value) => this.ValidateString(value, key, RequestValidationSource.Cookies));
    }
    else
    {
        int count = cc.Count;
        for (int i = 0; i < count; i++)
        {
            string collectionKey = cc.GetKey(i);
            string str2 = cc.Get(i).Value;
            if (!string.IsNullOrEmpty(str2))
            {
                this.ValidateString(str2, collectionKey, RequestValidationSource.Cookies);
            }
        }
    }
}

왜 "Granular" 검증인지 이후의 코드를 보면 이해가 됩니다. 즉, requestValidationMode가 4.0 이하인 경우에는 무조건 모든 쿠키의 값을 검증하는 코드가 ValidateCookieCollection의 if else 문에 의해 실행됩니다. 하지만, requestValidationMode가 4.5 이상이면 HttpCookieCollection의 EnableGranularValidation 메서드가 실행되면서,

internal void EnableGranularValidation(ValidateStringCallback validationCallback)
{
    this._keysAwaitingValidation = new HashSet<string>(this.Keys.Cast<string>(), StringComparer.OrdinalIgnoreCase);
    this._validationCallback = validationCallback;
}

쿠키 값 검증을 하는 _validationCallback의 설정과 함께 현재 HttpCookieCollection에 있는 쿠키의 Key 값들을 전부 _keysAwaitingValidation HashSet에 담아 놓습니다. 이후, 사용자 코드에서 쿠키를 사용하기 위해 접근할 때마다,

// System.Web.HttpCookieCollection
public HttpCookie Get(string name)
{
    HttpCookie cookie = (HttpCookie) base.BaseGet(name);
    if ((cookie == null) && (this._response != null))
    {
        cookie = new HttpCookie(name);
        this.AddCookie(cookie, true);
        this._response.OnCookieAdd(cookie);
    }
    if (cookie != null)
    {
        this.EnsureKeyValidated(name, cookie.Value);
    }
    return cookie;
}

private void EnsureKeyValidated(string key, string value)
{
    if ((this._keysAwaitingValidation != null) && this._keysAwaitingValidation.Contains(key))
    {
        if (!string.IsNullOrEmpty(value))
        {
            this._validationCallback(key, value);
        }
        this._keysAwaitingValidation.Remove(key);
    }
}

저렇게 쿠키의 값을 검증하는 코드가 실행되고, 일단 한번 검증이 되었으면 _keysAwaitingValidation HashSet에서 제거함으로써 나중에 또 HttpCookie.Get 등으로 접근했을 때 중복 검증하지 않도록 막아줍니다.

꽤나 똑똑하게 작성되어 있다는 것을 알 수 있는데요, 다시 정리하면 requestValidationMode가 4.5 이상일 때 asp.net은 요청으로 들어온 Cookie 값들을 개발자 코드에서 사용하는 항목에 한해 처음 접근 시에만 검증을 하고 있는 것입니다. 이쯤 되면 "Granular"라는 단어가 왜 사용되었는지를 알 수 있습니다.




이에 대한 부작용으로는, (물론 그렇게 사용해서는 안 되지만 무심코) 다중 스레드에서 HttpCookieCollection을 사용하게 되면 데이터 정합성이 깨질 확률이 발생하게 되었다는 점입니다. 즉, requestValidationMode == 4.0 이하일 때는 잘 동작하던 코드가 4.5로 올라가는 순간 _keysAwaitingValidation의 자료 구조가 예측할 수 없게 되어 버린 것입니다.

_keysAwaitingValidation의 자료 구조가 깨졌을 때 어떤 현상이 발생할 수 있는지 다음의 코드로 살짝 재현해 볼 수 있는데,

using System;
using System.Collections.Generic;
using System.Reflection;

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            HashSet<int> list = new HashSet<int>();
            list.Add(0);

            MakeCpuHigh(list); // 다중 스레드 접근으로 인한 데이터 정합성이 깨지는 것을 억지로 설정

            Console.WriteLine("CPU is running high");
            Console.WriteLine("Will never end...");

            for (int i = 0; i < 100; i++)
            {
                list.Contains(i);
            }
        }

        private static void MakeCpuHigh(HashSet<int> list)
        {
            Type hashType = list.GetType();

            FieldInfo fi = hashType.GetField("m_slots", BindingFlags.NonPublic | BindingFlags.Instance);
            Array slotArray = fi.GetValue(list) as Array;

            Array.Clear(slotArray, 0, 3);
        }
    }
}

위의 코드를 실행하면 list.Contains 호출 시 무한 루프를 도는 현상이 발생합니다.




참고로, 위의 현상은 실제로 고객사에 발생한 것인데 아직 그 원인이 HttpCookieCollection의 다중 스레드 접근으로 인한 것인지는 확인을 못했습니다. 단지, 메모리 덤프를 분석한 결과 저렇게 HashSet<T>.Contains 메서드 호출에서 무한 루프 현상이 발생했고 HashSet<T> 내부의 데이터 조사에서 무한 루프가 발생할 수밖에 없도록 연결 리스트의 값이 설정되어 있는 것을 발견할 수 있었습니다.

물론, 저 현상이 확률적으로 발생하는 것이기 때문에 그동안 문제의 원인이 어떤 것이었는지 파악할 동기가 없었을 것입니다.




[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]

[연관 글]






[최초 등록일: ]
[최종 수정일: 7/13/2021]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer at outlook.com

비밀번호

댓글 작성자
 




... [61]  62  63  64  65  66  67  68  69  70  71  72  73  74  75  ...
NoWriterDateCnt.TitleFile(s)
12107정성태1/10/202010695.NET Framework: 877. C# - 프로세스의 모든 핸들을 열람 - 두 번째 이야기
12106정성태1/8/202012123VC++: 136. C++ - OSR Driver Loader와 같은 Legacy 커널 드라이버 설치 프로그램 제작 [1]
12105정성태1/8/202010775디버깅 기술: 153. C# - PEB를 조작해 로드된 DLL을 숨기는 방법
12104정성태1/7/202011469DDK: 9. 커널 메모리를 읽고 쓰는 NT Legacy driver와 C# 클라이언트 프로그램 [4]
12103정성태1/7/202014164DDK: 8. Visual Studio 2019 + WDK Legacy Driver 제작- Hello World 예제 [1]파일 다운로드2
12102정성태1/6/202011810디버깅 기술: 152. User 권한(Ring 3)의 프로그램에서 _ETHREAD 주소(및 커널 메모리를 읽을 수 있다면 _EPROCESS 주소) 구하는 방법
12101정성태1/5/202011143.NET Framework: 876. C# - PEB(Process Environment Block)를 통해 로드된 모듈 목록 열람
12100정성태1/3/20209161.NET Framework: 875. .NET 3.5 이하에서 IntPtr.Add 사용
12099정성태1/3/202011463디버깅 기술: 151. Windows 10 - Process Explorer로 확인한 Handle 정보를 windbg에서 조회 [1]
12098정성태1/2/202011058.NET Framework: 874. C# - 커널 구조체의 Offset 값을 하드 코딩하지 않고 사용하는 방법 [3]
12097정성태1/2/20209625디버깅 기술: 150. windbg - Wow64, x86, x64에서의 커널 구조체(예: TEB) 구조체 확인
12096정성태12/30/201911631디버깅 기술: 149. C# - DbgEng.dll을 이용한 간단한 디버거 제작 [1]
12095정성태12/27/201913042VC++: 135. C++ - string_view의 동작 방식
12094정성태12/26/201911193.NET Framework: 873. C# - 코드를 통해 PDB 심벌 파일 다운로드 방법
12093정성태12/26/201911200.NET Framework: 872. C# - 로딩된 Native DLL의 export 함수 목록 출력파일 다운로드1
12092정성태12/25/201910615디버깅 기술: 148. cdb.exe를 이용해 (ntdll.dll 등에 정의된) 커널 구조체 출력하는 방법
12091정성태12/25/201912127디버깅 기술: 147. pdb 파일을 다운로드하기 위한 symchk.exe 실행에 필요한 최소 파일 [1]
12090정성태12/24/201910818.NET Framework: 871. .NET AnyCPU로 빌드된 PE 헤더의 로딩 전/후 차이점 [1]파일 다운로드1
12089정성태12/23/201911512디버깅 기술: 146. gflags와 _CrtIsMemoryBlock을 이용한 Heap 메모리 손상 여부 체크
12088정성태12/23/201910496Linux: 28. Linux - 윈도우의 "Run as different user" 기능을 shell에서 실행하는 방법
12087정성태12/21/201910924디버깅 기술: 145. windbg/sos - Dictionary의 entries 배열 내용을 모두 덤프하는 방법 (do_hashtable.py) [1]
12086정성태12/20/201912995디버깅 기술: 144. windbg - Marshal.FreeHGlobal에서 발생한 덤프 분석 사례
12085정성태12/20/201910723오류 유형: 586. iisreset - The data is invalid. (2147942413, 8007000d) 오류 발생 - 두 번째 이야기 [1]
12084정성태12/19/201911376디버깅 기술: 143. windbg/sos - Hashtable의 buckets 배열 내용을 모두 덤프하는 방법 (do_hashtable.py) [1]
12083정성태12/17/201912620Linux: 27. linux - lldb를 이용한 .NET Core 응용 프로그램의 메모리 덤프 분석 방법 [2]
12082정성태12/17/201912472오류 유형: 585. lsof: WARNING: can't stat() fuse.gvfsd-fuse file system
... [61]  62  63  64  65  66  67  68  69  70  71  72  73  74  75  ...