← All reports

[6] Masonry fieldset legend passes isSubgrid() despite being excluded from parent grid

MediumWebCore renderingOOB

ca8c809

Medium — 자바스크립트 없이도 어떤 페이지에서든 도달 가능하며, layout state 내부에서 범위를 벗어난 grid line index를 만들어내고 도달 가능한 debug assertion까지 유발합니다. 다만 주변 소스에서 확인되는 두 소비 지점 모두 인덱싱 전에 clamp를 거치기 때문에, read/write primitive까지는 확인되지 않아 그 이상으로 severity가 올라가지는 않습니다.

CSS Grid Level 3의 masonry는 한 축을 따라 아이템을 배치하고, 다른 축에는 고정된 개수의 track을 둡니다. 이때 중첩된 grid는 스스로를 subgrid로 선언할 수 있는데, 이 경우 자신이 차지하는 영역에 대해 자체 track을 정의하는 대신 부모의 track line을 그대로 물려받습니다. 이번 diff에는 두 객체가 등장합니다. 하나는 grid container의 render-tree 객체인 RenderGrid이고, 다른 하나는 grid axis 위에서 masonry 배치를 수행하며 track 개수만큼 크기를 갖는 track별 running-position 벡터를 관리하는 helper인 GridMasonryLayout입니다. Subgrid 여부는 해당 자식이 실제로 부모 grid 안에서 하나의 span을 차지하고 있다는 전제를 깔고 있고, 배치 계산 역시 아이템이 요청한 span이 실제 존재하는 track 범위 안에 들어온다는 전제를 깔고 있습니다.

관전 포인트: 스크립트가 전혀 없는 페이지도 fieldset legend를 실제로는 배치된 적 없는 subgrid로 선언하거나, 1개짜리 track grid에 10개짜리 span을 요청함으로써 masonry 배치를 범위 밖 line index로 몰아넣을 수 있습니다. 그 결과는 debug 빌드에서의 assertion이고, release 빌드에서는 containment가 하위 clamp에 의존하게 되는 GridArea입니다.

commit 메시지는 두 가지 문제를 모두 명시하고 있습니다. 먼저 RenderGrid::isSubgrid()isExcludedFromNormalLayout()을 확인하지 않는다는 점입니다. 그래서 grid-template-rows: subgrid로 스타일링된 fieldset legend는 fieldset의 grid item 배치에서는 제외되면서도, 여전히 모든 subgrid 검사는 통과합니다. 이후 legend의 layout이 부모 grid에 자신의 span을 조회하면 legend를 찾지 못하는데, 이때 debug 빌드에서는 ASSERT(m_gridItemArea.contains(item))에 걸리고, release 빌드에서는 gridAreaForIndefiniteGridAxisItem에서 unsigned underflow가 발생합니다. 수정 코드는 제외된 element에 대해 isSubgrid()가 false를 반환하도록 하고, CSS Grid Level 3 §4.4에 따라 item span을 grid-axis track 개수로 clamp합니다.

Source/WebCore/rendering/RenderGrid.cpp

bool RenderGrid::isSubgrid(Style::GridTrackSizingDirection direction) const
{
// https://drafts.csswg.org/css-grid-2/#subgrid-listing
if (establishesIndependentFormattingContextIgnoringDisplayType(style()))
return false;
+ if (isExcludedFromNormalLayout())
+ return false;
if (!style().gridTemplateList(direction).subgrid)
return false;
auto* renderGrid = dynamicDowncast<RenderGrid>(parent());

Source/WebCore/rendering/GridMasonryLayout.cpp

GridArea GridMasonryLayout::gridAreaForIndefiniteGridAxisItem(const RenderBox& item)
{
 
- auto itemSpanLength = Style::GridPositionsResolver::spanSizeForAutoPlacedItem(item, gridAxisDirection());
+ auto itemSpanLength = std::min<unsigned>(Style::GridPositionsResolver::spanSizeForAutoPlacedItem(item, gridAxisDirection()), m_gridAxisTracksCount);
auto gridAxisLines = m_gridAxisTracksCount + 1;

LayoutTests/fast/css-grid-layout/masonry-subgrid-excluded-legend-crash.html

+<style>
+*:not(style):not(script) {
+ display: grid-lanes;
+ grid-template-rows: subgrid repeat(auto-fill, []) repeat(4, []);
+}
+</style>
+<fieldset><legend><div></div></legend></fieldset>

LayoutTests/fast/css-grid-layout/masonry-span-exceeds-grid-axis-tracks-crash.html

+.outer { display: grid-lanes; grid-template-rows: 10px; }
+.inner { display: grid-lanes; grid-template-rows: subgrid; }
+.item { grid-row: span 10; }
+<div class="outer"><div class="inner"><div class="item"></div></div></div>

RenderGrid::isSubgrid(Style::GridTrackSizingDirection)에 이른 시점의 if (isExcludedFromNormalLayout()) return false; guard가 추가되었습니다. 이 guard는 기존의 establishesIndependentFormattingContextIgnoringDisplayType(style()) 검사 다음, style().gridTemplateList(direction).subgrid 검사 이전에 삽입되었습니다. 이제 normal layout에서 제외된 renderer는 computed style이 grid-template-rows: subgrid를 지정하고 있더라도 subgrid 여부에 대해 false를 반환합니다.

GridMasonryLayout::gridAreaForIndefiniteGridAxisItem(const RenderBox&)std::min<unsigned>(..., m_gridAxisTracksCount)를 통해 resolve된 auto-placement span을 grid-axis track 개수로 clamp합니다. 이전에는 spanSizeForAutoPlacedItem()의 raw 결과 값을 그대로 사용했습니다.

부수적으로, 새로운 layout test 두 개와 각각의 -expected.txt 파일이 함께 추가되었습니다. masonry-subgrid-excluded-legend-crash.html은 universal selector에 display: grid-lanes; grid-template-rows: subgrid ...를 적용하여 <fieldset><legend> 체인이 excluded-legend 경로를 타도록 만들고, masonry-span-exceeds-grid-axis-tracks-crash.html은 row track이 하나뿐인 grid-lanes container 안에 grid-row: span 10을 선언한 아이템을 가진 중첩 grid-lanes subgrid를 둡니다.

선언된 설정만 보고 답하는 참여 여부 predicate가, 용량에 대한 bound 검사 없이 index 연산에 그대로 사용되는 외부 입력 span과 짝을 이루는 패턴입니다.

CSS Grid subgrid. grid-template-rows: subgrid(또는 -columns)로 선언된 중첩 grid container는 해당 축에서 자체 track을 정의하지 않습니다. 대신 부모 grid 안에서 자신이 차지하는 span에 대해 부모의 track line을 그대로 물려받습니다. 이는 자식이 실제로 부모 안에서 span을 차지하고 있다는 것, 즉 부모의 grid item 중 하나라는 전제를 깔고 있습니다.

Masonry / display: grid-lanes. CSS Grid Level 3 masonry는 한 축(masonry axis)을 따라 아이템을 배치하고, 다른 축(grid axis)에는 고정된 개수의 track을 둡니다. GridMasonryLayout::m_gridAxisTracksCount는 grid-axis track의 개수이고, m_runningPositions는 그 개수만큼 크기를 갖는 Vector<LayoutUnit>로, 각 track의 현재 채움 높이를 보관합니다.

Definite vs. indefinite grid-axis position. GridMasonryLayout::placeMasonryItems()는 각 아이템을 명시적인 grid-axis 배치가 있으면 gridAreaForDefiniteGridAxisItem()로, 그렇지 않으면 gridAreaForIndefiniteGridAxisItem()로 보냅니다. 후자는 아이템을 auto-place하고, 요청된 span과 사용 가능한 line으로부터 start line을 도출합니다.

spanSizeForAutoPlacedItem(). Style::GridPositionsResolver의 helper로, auto-place된 아이템이 몇 개의 track을 요청하는지 resolve합니다. 예를 들면 grid-row: span 10 같은 값입니다. 이 값은 author CSS에서 옵니다.

isExcludedFromNormalLayout(). RenderObject 레벨의 predicate로, 부모의 normal layout이 스스로 배치하지 않는 renderer를 표시합니다. fieldset의 <legend>가 대표적인 경우인데, fieldset renderer가 legend를 일반적인 in-flow 자식이 아니라 특별한 방식으로 위치시키기 때문입니다.

Grid::m_gridItemArea. 각 배치된 아이템을 자신의 영역에 매핑하는 HashMap<SingleThreadWeakRef<const RenderBox>, GridArea>입니다. Grid::gridItemArea()는 key가 존재한다고 assert하며, 그렇지 않을 경우 HashMap::get()의 default 값을 반환합니다. GridArea의 경우 default constructor가 이 값이 되는데, 이는 rowscolumns 양쪽 모두를 GridSpan::indefiniteGridSpan()으로 설정합니다.

GridSpan. start/end line 쌍이며, indefinite 상태와 translated-definite 상태가 구분됩니다. clamp()는 주어진 track 개수로 이 값을 제한합니다.

C++의 unsigned index 연산. 더 작은 unsigned에서 더 큰 unsigned를 빼면 음수가 되는 대신 2^N을 법으로 wrap됩니다. 그래서 음수 결과를 감지하려던 크기 비교가 전혀 작동하지 않게 됩니다.

여기에는 서로 독립적인 두 개의 invariant 누락이 있으며, 이번 commit은 두 가지를 모두 닫습니다. 각 test가 각각 하나씩을 exercise한다는 점이 이 둘이 하나의 버그를 두 번 본 것이 아니라 별개의 진입점이라는 가장 강력한 근거입니다.

첫 번째는 predicate와 실제 참여 여부 사이의 불일치입니다. isSubgrid()는 computed style과 독립적인 formatting context 검사만으로 답을 내렸습니다. fieldset <legend>는 fieldset의 normal layout에서 제외되어 일반적인 in-flow 자식이 아닌 특별한 layout 경로를 타는데도, 수정 전에는 여전히 나는 부모의 subgrid다라고 답할 수 있었습니다. Subgrid 여부는 renderer가 실제로 부모 grid 안에서 span을 차지하고 있을 때만 의미가 있습니다. 이렇게 제외된 legend의 subgrid 응답이 이후 어디로 흘러가는지는 추론의 영역입니다. RenderGrid::isSubgrid(direction)의 호출부나 fieldset/legend 배치 코드는 제공된 context에 포함되어 있지 않습니다. 다만 그럴듯한 소비 지점의 형태는 Grid::gridItemArea()를 통한 span 조회로 볼 수 있는데, 이 함수는 제공된 Grid.cpp / Grid.h / GridArea.h에서 확인되는 대로 ASSERT(m_gridItemArea.contains(item)); return m_gridItemArea.get(item);으로 구현되어 있습니다. legend가 이 map에 없는 경우, debug 빌드에서는 이 assertion에 걸리고, release 빌드에서는 indefinite span을 담은 default-constructed GridArea를 받아 마치 실제 배치인 것처럼 사용하게 됩니다.

두 번째는 author가 제어 가능한 span에 대한 bound 부재입니다. 첫 번째 문제와는 독립적으로, gridAreaForIndefiniteGridAxisItem()grid-row: span 10처럼 author CSS가 직접 결정하는 spanSizeForAutoPlacedItem() 값을 m_gridAxisTracksCount와 아무 관계도 맺지 않은 채 그대로 사용했습니다. 바로 다음 줄에서 auto gridAxisLines = m_gridAxisTracksCount + 1;을 계산하고, 배치 계산은 이 두 값으로부터 start/end line을 도출합니다. itemSpanLength를 소비하는 코드 본문은 제공된 소스에 포함되어 있지 않아, 정확한 실패 방식은 fix의 형태로부터 유추한 것이지 직접 확인된 것은 아닙니다. 다만 이 clamp가 무엇을 말해주는지는 명확합니다. grid-axis track 개수보다 큰 span이 수정 전에는 이를 수용할 수 없는 배치 계산까지 도달했다는 것입니다. 주변 코드가 unsigned로 작업한다는 점을 고려하면, 유력한 메커니즘은 span을 사용 가능한 line 개수에서 빼는 unsigned 뺄셈이 음수가 되는 대신 매우 큰 값으로 wrap되어, 범위를 벗어난 grid line index가 만들어지고 이것이 GridArea 생성으로 흘러 들어가는 것입니다.

도달 가능성은 명확하며 자바스크립트가 전혀 필요하지 않습니다. 두 regression test 모두 순수한 HTML과 CSS로만 구성되어 있습니다.

masonry-span-exceeds-grid-axis-tracks-crash.html을 보면, .outer10px row track 하나를 가진 display: grid-lanes이고, .inner는 중첩된 grid-lanesgrid-template-rows: subgrid에 의해 grid-axis track 개수를 부모로부터 물려받아 track이 1개가 됩니다. .item.inner의 자식이므로, 이를 배치하는 GridMasonryLayout 인스턴스는 .inner에 속하며 m_gridAxisTracksCount == 1, m_runningPositions는 크기 1로 동작합니다. .item은 grid-axis에 대한 명시적 위치 없이 grid-row: span 10을 선언하므로 gridAreaForIndefiniteGridAxisItem()이 실행됩니다. 수정 전에는 spanSizeForAutoPlacedItem()이 10을 반환하는 동안 gridAxisLines는 2였습니다.

masonry-subgrid-excluded-legend-crash.html을 보면, universal selector가 모든 element에 display: grid-lanes; grid-template-rows: subgrid repeat(auto-fill, []) repeat(4, [])를 부여하므로 <fieldset> 안의 <legend>도 subgrid로 계산됩니다. 수정 전 isSubgrid()는 style과 독립적인 formatting context 검사만 참조했기 때문에, legend가 normal layout에서 제외되어 있음에도 true를 반환했습니다. 이렇게 만들어진 indefinite area가 span 10 케이스와 동일한 span-대-capacity 연산 경로에 도달하는지는 제공된 소스만으로는 확인되지 않습니다. 두 경로가 각각 별도로 패치되었다는 점은, 적어도 이 둘이 서로 다른 경로라는 정황과 배치됩니다.

Stability 문제를 넘어서는 확장 가능성은 제공된 context로는 뒷받침되지 않습니다. 여기서 확인되는 경로들에서는 범위를 벗어난 line index가 guard 없는 raw memory access로 소비되지 않습니다. Grid::insert()m_maxRows / m_maxColumns가 설정되어 있을 때 clampedArea.rows / .columns를 이 값들로 clamp하고, GridMasonryLayout::updateRunningPositions()m_runningPositions[line] 로드 이전에 gridAxisSpan.clamp(m_runningPositions.size())를 호출합니다. 만약 이 GridArea를 소비하는 다른 어떤 지점이 동등한 clamp 없이 Vector를 인덱싱한다면, wrap된 line 값이 범위를 크게 벗어난 인덱스를 만들어낼 가능성이 있습니다. 다만 제공된 소스에서는 그런 소비 지점이 확인되지 않으므로, 이는 아직 실현되지 않은 예상 시나리오로 남습니다. insert()에서 도달하는 Grid::ensureGridSize() 역시 clamp되지 않은 거대한 row count에 대해 m_grid.grow(maximumRowSize)를 시도할 수 있는데, 이는 controlled corruption보다는 allocation 실패로 드러날 가능성이 있습니다.

이 vulnerability는 renderer의 layout engine 내부에서 memory-safety에 인접한 두 가지 invariant를 약화시킵니다. 하나는 isSubgrid()에 답하는 renderer가 실제로 부모 grid의 참여자여야 한다는 것이고, 다른 하나는 resolve된 item span이 grid-axis track 개수로 bound되어야 한다는 것입니다. 공격자가 원론적으로 얻을 수 있는 것은 author가 영향을 미친, masonry 배치를 거쳐 전파되는 범위 밖 grid line index입니다. 그 결과는 debug 빌드에서의 assertion 실패이고, release 빌드에서는 containment가 배치 계산의 정확성이 아니라 하위 clamp에 의존하게 되는 GridArea입니다. 현실적으로 이는 스크립팅이 전혀 필요 없이 어떤 웹 페이지에서든 도달 가능한 layout-correctness 및 renderer-stability 문제에 해당합니다.

이 두 hunk는 눈여겨볼 만한 defence-in-depth 조합을 보여줍니다. 하나는 predicate를 고쳐 선언된 style이 아니라 실제 layout 참여 여부를 반영하도록 하고, 다른 하나는 산술 소비 지점을 강화하여 어떤 경로로 도달하든 clamp가 걸리도록 합니다. predicate fix만 있었다면 legend 경로만 닫혔을 것이고, clamp만 있었다면 span 10 경로만 닫혔을 것입니다. 저자는 두 fix를 함께 반영하면서 각각에 대한 regression test도 함께 추가했습니다. display: grid-lanes는 아직 새로운 코드이며, 아직 안정화 중인 영역의 특징적인 신호를 보여줍니다. updateRunningPositions()에는 실제로 동작하는 clamp()가 있고, 그 clamp가 불필요하다는 것을 전제로 하는 ASSERT가 함께 걸려 있습니다. 또한 Grid::gridItemArea()는 map 멤버십을 assert하면서도, release 빌드에서는 조용히 default-constructed된 indefinite GridArea로 fallback합니다. ASSERT가 invariant를 문서화하고 있지만 release 빌드에서는 그럴듯해 보이는 default 값으로 이를 덮어버리는 지점마다, predicate mismatch가 crash 대신 silent bad state로 전환될 여지가 남아 있습니다.

지금까지의 스타일 가이드에 따라 영문 섹션을 한국어로 재작성하겠습니다.