may 21, 2026 // 8 min read
hey!! :33
yandex maps shows online traffic light countdowns on driving routes - current signal, seconds until phase change, sometimes a full schedule. there is no public api for this. the whole thing lives inside libmaps-mobile.so, a ~60MB arm64 blob that ships with the android app
i wanted one thing: how many seconds until green, as a number i can poll from my own code
that turned into ida, jadx, frida, mitmproxy, a rooted pixel, sqlite exfil, scudo heap corruption, and mass 404s on urls that felt correct. here is how it went
the pipeline
end-to-end flow once everything works:
GET /mapkit2/driving/2.x/routewith hmac-signed request -> protobuf route- walk protowire to
/2/6/6/1/92/14/1-> extract traffic light IDs GET /mapkit2/traffic_lights/2.x/traffic_light?id=...-> live signal + countdown
step 1 was the first thing i got working. step 3 took a week of wrong turns
x-yruntime-signature
every request to proxy.mob.maps.yandex.net needs three auth pieces:
X-YMapKit-Api-Keyheader (hardcoded in the apk java layer)User-Agentmatching the real app formatx-yruntime-signature- hmac-sha1 computed per-request
the signature was the first real ida dive. mitmproxy showed it changing between requests (381e788c..., 0f50095b..., 34edb7c5...), so its not static
traced it through the binary:
sub_19878F4 (GET wrapper)
-> sub_19879E4 (generic signer)
-> sub_17FAA48 (OpenSSL hmac)
sub_19879E4 at 0x1987a90..0x1987c28:
- extract User-Agent from headers
- concatenate:
ua + method - append
" "(space) - extract URL path: find
"//", find"/"after host, substr to end - append path+query
- append body (empty for GET)
- hmac-sha1 with 16-byte key at
0x6005C2
final format:
hmac-sha1(key, "{UserAgent}{Method} {PathWithQuery}{Body}")
key bytes: 80 0d ff bd 77 13 a6 3c 72 d2 83 17 36 7c a5 46
verified against captured mitmproxy traffic - signature matched exactly on first try after getting the format right. getting the format right took... a while. i kept assuming ua + " " + Method + Path when its actually ua + Method + " " + Path. the space is between method and path, not between UA and method
func computeSignature(userAgent, method, fullURL string, body []byte) string {
pathWithQuery := extractPath(fullURL)
mac := hmac.New(sha1.New, hmacKey)
mac.Write([]byte(userAgent))
mac.Write([]byte(method))
mac.Write([]byte(" "))
mac.Write([]byte(pathWithQuery))
mac.Write(body)
return hex.EncodeToString(mac.Sum(nil))
}
route protobuf and traffic light ids
GET /mapkit2/driving/2.x/route returns a protobuf blob. traffic light IDs are buried deep inside it, using proto2-style extension fields with high field numbers
wire path to traffic lights:
/2 response wrapper
/2/6 route data
/2/6/6 route sections (repeated)
/2/6/6/1 section metadata
/92 RawSectionMetadata (extension field)
/92/14 RawTrafficLights
/92/14/1 repeated RawTrafficLight
field 1 (varint) = segment_index
field 2 (string) = traffic light ID
yes, field 92. proto2 extensions. the route proto is undocumented and massive - i didnt bother reconstructing the full schema, just walked the wire format with protowire.ConsumeTag until i hit the fields i needed
ids look like "16873831539725666398" - uint64 as string. segment_index is the position on the route polyline
java bindings confirmed the structure:
0x2395b70: DrivingRouteBinding.getTrafficLights__
0x23c467c: DrivingRawSectionMetadata.getTrafficLights__
this part worked on first try. 10 traffic lights on a test route through moscow (kremlin -> bolshoi theatre). cool. now just need to get their live signal state... right?
datacollect is not traffic lights
first time mitmproxy showed me anything traffic-related from the real app:
POST https://proxy.mob.maps.yandex.net/mapkit2/datacollect/2.x/traffic
content-type: application/x-protobuf
got excited for maybe fifteen seconds
ida + log strings made it clear: this is telemetry upload. crowdsourced traffic data going to yandex, not live signal data coming from yandex. sub_242E8C4 builds the actual live TL request and logs:
Traffic lights. GET /traffic_light request duration:
but it only hardcodes the path tail "traffic_light" plus query param "id". the host + prefix come from runtime config, not from a friendly string in rodata
if your mitm shows datacollect traffic - congrats, you are sniffing something. just not the thing you want
the 404 festival
while mitm stayed quiet i tried brute-forcing path prefixes from go:
/traffic_light 404
/mapkit2/traffic_light 404
/mapkit2/navigation/traffic_light 404
/mapkit2/navigation/2.x/traffic_light 404
/mapkit2/traffic_lights/traffic_light 404
hits=0 404=5
ida says "traffic_light" exists as a string. server says no. both are correct - the missing piece is the service prefix /mapkit2/traffic_lights/2.x/ which comes from mobile config, not from the binarys string table
why mitm stayed empty
expected workflow: install mitmproxy CA on rooted emulator, start driving navigation in moscow, filter for traffic_light, write blog post, touch grass
on my device mitm was often completely empty for mapkit traffic. not "no traffic_light requests" - literally zero requests. when something appeared it was datacollect again
turns out the online TL channel has multiple gates beyond "user started navigation"
frida hooks on startTrafficLightsChannel (sub_28AB300) eventually showed me:
route has 2 TL but none online (+40=0)
emptyOnline: 1
restSetup/restGet/restPoll: 0
watch+320=0 after teardown
translation:
- route has traffic lights as geometry
- none have the online flag (byte at offset
+40on 48-byte stride entries in native route TL array) - controller tears down, never opens network
- empty mitm doesnt mean SSL pinning. it means the feature never fired
java-side gates too: experiment flag online_traffic_lights, user setting online_traffic_lights_enabled (default false in decompiled settings xml)
you can patch +40 from frida and force-start the channel. you can also get Scudo ERROR: corrupted chunk header and SIGSEGV. i did both
frida and jadx
tried frida to force the channel open and sniff the url. mid-function arm64 hooks were unstable, memory patches gave heap corruption (Scudo ERROR: corrupted chunk header), and frida 17 attach vs spawn is its own comedy show. got some diagnostic logs explaining why the channel didnt start but never captured the actual url. not worth the effort in hindsight
jadx gave me java JNI stubs and settings flags. no endpoint urls - those live in native + cached config, not in the java layer
sqlite exfil
root + adb. not frida. not mitm. a file:
adb shell su -c 'cat /data/data/ru.yandex.yandexmaps/files/mapkit/config.sqlite'
table items, key mapkit2_ETAG, ~44KB protobuf blob. strings on it:
https://proxy.mob.maps.yandex.net:443/mapkit2/traffic_lights/2.x/
wss://proxy.mob.maps.yandex.net:443/mapkit2/traffic_lights/2.x/
full REST endpoint:
GET .../mapkit2/traffic_lights/2.x/traffic_light?id=4480630292856301996,...
200. protobuf body. live countdown data
the url was never in the binary. it was never in mitmproxy. it was sitting in yandexs own config cache on disk, downloaded at app startup, stored in sqlite
i wrote a shell script (scripts/dump-mapkit-endpoints.sh) to automate exfil:
adb shell "su -c 'cat $db'" > config.sqlite
strings mapkit2_ETAG.bin | rg 'https://|wss://'
i laughed. tiredly
ida: the mobile_config dead end
tried to find the url in ida before resorting to sqlite. the traffic lights config is registered as a protobuf extension:
yandex.maps.proto.mobile_config.mapkit2.traffic_lights.Config
found the deserializer at sub_28CBA8C. it reads these fields:
traffic_lights_visibility_distance
traffic_lights_extra_expiration_interval
traffic_lights_update_expired_tolerance
traffic_lights_countdown_update_interval
traffic_lights_route_position_update_interval
traffic_lights_metric_log_level
traffic_lights_use_websocket
lots of timers and distances. a websocket toggle. no url field. the url comes from a different config layer (MAPS_NAVIGATION service bucket) that maps service names to base urls. that mapping is what lives in config.sqlite
403 because User-Agent
after sqlite gave me the url, route requests worked but traffic_light returned 403 Forbidden
the hmac signs over the User-Agent string. if the ua in the header doesnt match what was signed, the server rejects it. specifically:
// 403
"ru.yandex.yandexmaps/29.1.1... android/12"
// 200
"ru.yandex.yandexmaps/29.1.1... android/12 (Google; sdk_gphone64_arm64; en_US)"
same path, same key, same ids. trim the parenthetical suffix from ua, get Forbidden. the suffix comes from the emulator device model - sdk_gphone64_arm64 because i started on an emulator before switching to a real pixel
protobuf field 150
REST response is not flat repeated TrafficLightMetadata. its wrapped in GeoObject containers:
field 2 TrafficLightBatch
field 6 repeated TrafficLightGeoObject
field 1 TrafficLightGeoWrapper
field 150 TrafficLightMetadata <- yes, 150
field 1 id (string)
field 2 repeated TrafficLightPhase
field 1 start_ms (int64)
field 2 signal (enum)
field 3 duration
field 3 arrow (enum)
field 4 phase_end_ms (int64)
field 5 section_type (enum)
field 150 is a proto2 extension on GeoObject - yandex uses proto2 extensions everywhere for metadata attachments. the native deserializer sub_242E128 parses this, same code path for REST and WS responses
seconds_left isnt a direct field. the server sends phase timestamps (absolute milliseconds) and phase_end_ms. client computes remaining time locally:
endMs := phase.StartMs + phase.Duration.DurationMs
secondsLeft := float64(endMs - now.UnixMilli()) / 1000.0
sometimes one phase, sometimes a full schedule with multiple phases. backend decides
test route through moscow intersections (from=37.590715,55.731200 to=37.593916,55.731304):
route TL ids: 14
live countdowns: 4
4480630292856301996 YELLOW ~90s
12734559995319121258 YELLOW ~35s
11116607414502371361 YELLOW ~39s
2125325660141373375 GREEN ~17s
14 lights on the route geometry. 4 with live online data right now. the other 10 exist as positions but have no telemetry. backend decision, not parser bug. live_count varies by time of day and intersection
what works now
curl "https://tlightning.cuddles.rs/api/v1/route_tl?from=37.590715,55.731200&to=37.593916,55.731304"
{
"count": 14,
"live_count": 4,
"traffic_lights": [
{
"id": "4480630292856301996",
"segment_index": 2,
"signal": "YELLOW",
"seconds_left": 89.998,
"live": true
},
{
"id": "2125325660141373375",
"segment_index": 19,
"signal": "GREEN",
"seconds_left": 17.393,
"live": true
},
{
"id": "8358578665424419692",
"segment_index": 0,
"live": false
}
]
}
go server with fiber/fasthttp, swagger UI at /swagger. live: false means the TL exists on the route but has no online countdown from the backend right now
websocket client is written (pkg/client/ws.go) but unverified - path is in config.sqlite, protobuf schema is reconstructed, client mirrors TrafficLightsChannelImpl. maybe later
tools used
| tool | what it gave me |
|---|---|
| ida pro | hmac algorithm, function signatures, proto field names, config deserializer, some branch offsets for frida |
| jadx | java jni stubs, settings flags, experiment names |
| frida | why mitm was empty (+40 online flag, channel gates), controller lifecycle |
| mitmproxy | captured headers (ua, api key, signature) for one tile request. nothing else useful |
| adb + sqlite | the actual endpoint urls |
| protowire | raw proto walking without schema for route response |
the cheapest "network sniffer" was reading yandexs own config cache off disk
some conclusions idk
wanted seconds_left as json. got a native RE arc with scudo crashes and the realization that the url i needed was cached in sqlite all along, while i was bruteforcing paths and patching arm64 memory
mitm stayed empty for reasons that were half ssl pinning, half "channel never started because online flag byte was zero". frida diagnosed the second part. sqlite fixed the url. ida told me datacollect was a red herring
maybe next: a live map of moscow traffic lights that works independently of yandex. the data is there, 4 out of 14 lights on a short route have online telemetry right now. scrape enough routes and you get city-wide coverage
lights still red when youre late anyway
mrow :3