표현식 인젝션

C# System.Linq.Dynamic.Core 표현식 인젝션

설명

System.Linq.Dynamic.Core는 문자열을 파싱해 LINQ 표현식 트리를 만듭니다. 요청에서 받은 문자열 전체를 Where, OrderBy, Select 또는 DynamicExpressionParser.ParseLambda의 표현식 인수로 전달하면 공격자가 데이터 값뿐 아니라 조건, 정렬, 투영과 같은 쿼리 구조를 바꿀 수 있습니다.

고정된 표현식의 @0, @1 자리표시자에 값을 전달하거나 라이브러리의 WhereInterpolated를 사용하는 것은 값과 표현식 구조를 분리합니다. 반대로 사용자 입력을 문자열 연결 또는 일반 보간 문자열로 표현식에 삽입하거나, 완성된 표현식 자체를 요청으로 받는 것은 파라미터화가 아닙니다.

이 문서의 버전별 참조는 System.Linq.Dynamic.Core 1.7.3을 기준으로 합니다. 1.3.0과 1.6.0에는 호출 가능한 메서드와 형식, object 메서드, OrderBy 표현식을 제한하는 보안 강화가 포함되었습니다. 이러한 제한은 과거의 코드 실행 및 리플렉션 접근 위험을 줄이지만, 공격자가 애플리케이션의 필터나 투영 정책을 선택하도록 허용해도 안전하다는 뜻은 아닙니다.

잠재적 영향

  • 의도한 필터나 테넌트 경계를 우회해 허가되지 않은 레코드를 조회할 수 있습니다.
  • 허용하지 않은 속성을 투영하거나 정렬 조건을 바꿔 민감한 데이터를 노출할 수 있습니다.
  • 복잡하거나 비용이 큰 표현식으로 데이터베이스 또는 애플리케이션 자원을 과도하게 사용할 수 있습니다.
  • 1.3.0 미만의 취약 버전에서는 CVE-2023-32571에 따른 임의 코드 및 명령 실행으로 이어질 수 있습니다. 1.6.0 미만에서는 CVE-2024-51417과 관련된 리플렉션 형식 및 정적 멤버 접근 위험이 있습니다.

해결 방법

  1. 가능하면 문자열 기반 Dynamic LINQ 대신 강타입 LINQ를 사용합니다.
  2. 클라이언트가 보내는 짧은 식별자를 서버가 소유한 Expression<Func<T, bool>> 또는 고정 문자열에 매핑합니다. 입력값 전체를 표현식으로 취급하지 않습니다.
  3. 값만 동적으로 전달해야 한다면 표현식은 고정하고 @0, @1 자리표시자 또는 WhereInterpolated를 사용합니다. 자리표시자는 속성명, 연산자, 정렬 방향 같은 구조를 파라미터화하지 않으므로 이러한 항목은 별도의 고정 맵으로 선택합니다.
  4. System.Linq.Dynamic.Core를 유지보수되는 최신 버전으로 업데이트합니다. AllowNewToEvaluateAnyType = false, AllowEqualsAndToStringMethodsOnObject = false, RestrictOrderByToPropertyOrField = true 등 제한적인 ParsingConfig 기본값을 완화하지 말고, 사용자 지정 형식 공급자에는 필요한 형식만 노출합니다.
  5. 최종 데이터 접근 권한은 표현식과 별도로 적용하고, 결과 수·쿼리 시간·복잡도에 제한을 둡니다.

예시

변경 전

csharp
using Microsoft.AspNetCore.Mvc;
using System.Linq;
using System.Linq.Dynamic.Core;

public sealed class User
{
    public bool IsActive { get; init; }
    public int Age { get; init; }
}

public sealed class SearchController
{
    public IQueryable<User> Search(
        [FromQuery] string predicate,
        IQueryable<User> users)
    {
        return users.Where(predicate);
    }
}

변경 후

csharp
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;

public sealed class User
{
    public bool IsActive { get; init; }
    public int Age { get; init; }
}

public sealed class SearchService
{
    private static readonly IReadOnlyDictionary<string, Expression<Func<User, bool>>> Filters =
        new Dictionary<string, Expression<Func<User, bool>>>(StringComparer.Ordinal)
        {
            ["active"] = user => user.IsActive,
            ["adult"] = user => user.Age >= 18
        };

    public IQueryable<User> Search(string filter, IQueryable<User> users)
    {
        if (!Filters.TryGetValue(filter, out var predicate))
        {
            throw new ArgumentException("Unsupported filter.", nameof(filter));
        }

        return users.Where(predicate);
    }
}

설명:

  • 변경 전: 요청의 predicate가 Dynamic LINQ 문법으로 그대로 파싱되므로 호출자가 조건 구조를 결정합니다.
  • 변경 후: 외부 입력은 고정된 키로만 사용되고, 실제 조건은 서버가 작성한 강타입 표현식에서 선택됩니다.

Dynamic LINQ를 유지해야 하고 값만 외부에서 받는 경우에는 users.Where("Age >= @0", minimumAge)처럼 표현식 구조를 고정합니다. minimumAge가 문자열이더라도 표현식 문법으로 다시 파싱되지 않고 @0 값으로 바인딩됩니다.

참조