bitracker
yandex maps traffic lights

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:

  1. GET /mapkit2/driving/2.x/route with hmac-signed request -> protobuf route
  2. walk protowire to /2/6/6/1/92/14/1 -> extract traffic light IDs
  3. 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-Key header (hardcoded in the apk java layer)
  • User-Agent matching the real app format
  • x-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:

  1. extract User-Agent from headers
  2. concatenate: ua + method
  3. append " " (space)
  4. extract URL path: find "//", find "/" after host, substr to end
  5. append path+query
  6. append body (empty for GET)
  7. 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 +40 on 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

toolwhat it gave me
ida prohmac algorithm, function signatures, proto field names, config deserializer, some branch offsets for frida
jadxjava jni stubs, settings flags, experiment names
fridawhy mitm was empty (+40 online flag, channel gates), controller lifecycle
mitmproxycaptured headers (ua, api key, signature) for one tile request. nothing else useful
adb + sqlitethe actual endpoint urls
protowireraw 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