Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,30 @@ This tool is built for IoT security curiosity, learning about obscure protocols,
> [!WARNING]
> **Hardware Warning:** Many infrared ESL tags store their firmware, address, and display data in volatile RAM to save cost and energy. If you remove the battery or let it fully discharge, the tag will lose all programming and become unresponsive ("dead"). It usually cannot be recovered without the original base station.

## Which tags work

TagTinker only transmits infrared, through the Flipper's IR LED. It works with infrared ESLs whose type code is in the app's profile table. The type code is digits 13 to 16 of the 17-character barcode, and the [image preparer](https://i12bp8.github.io/TagTinker/) lists the graphics types.

TagTinker has no way to drive ESLs that are updated over radio, whatever their barcode or NFC tag says. For example:

- SES-imagotag's VUSION access points talk to their labels over a [proprietary 2.4 GHz radio](https://www.ses-imagotag.com/wp-content/uploads/2023/01/VUSION_Datasheet_Retail_IoT_Connector_en.pdf), and in 2023 SES-imagotag [announced Bluetooth LE support](https://www.vusion.com/newsroom/ses-imagotag-expands-vusion-capabilities-to-bluetooth-based-iot-protocol) for the platform.
- Hanshow documents labels such as the [Stellar Pro-266](https://www.hanshow.com/en/resource/the-hanshow-esl:-a-stellar-solution-for-retail-transformation) and [Nebular Pro-346](https://www.hanshow.com/en/resource/elevating-the-museum-and-gallery-experience-with-hanshow-price-tags-unveiling-the-nebular-pro-346) as RF devices working at 2402 to 2480 MHz.

**What `+ Scan NFC` tells you**

The scan only reads the tag's NFC data. It cannot sense whether the display listens for infrared or radio.

| Message | What the Flipper found |
| --- | --- |
| Tag actions open | The NFC link carries an ID TagTinker decodes. `Show Tag Info` shows the model, or `Model: Unknown` when the type code is not in the profile table. |
| Likely radio tag | No decodable ID, and the NFC link points to `nfc.imagotag.com`, the host in the [public VUSION label dump](https://github.com/i12bp8/TagTinker/issues/51). The link alone does not prove the model. |
| Unrecognized tag | The chip was read, but its NFC data holds no ID TagTinker can decode. Everyday NFC cards land here too. For an infrared tag, try `+ Type Barcode`. |
| Unreadable chip | An NFC-A chip answered, but no page could be read. The scan reads only NTAG/Ultralight chips; other chip types give this or "Unrecognized tag", depending on how they answer. If the tag moved during the read, take it away and present it again. |
| Target list full | All 16 target slots are in use. Delete a saved tag first. |
| Nothing happens | No NFC-A chip answered. The tag may have no NFC chip, or one of a type the scan does not look for. |

Only test tags you own or are allowed to test.

## Features

- **TagTinker Flipper App:** High-performance, zero-allocation RLE streaming IR engine.
Expand Down
191 changes: 163 additions & 28 deletions nfc/tagtinker_nfc.c
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
/*
* TagTinker — ESL NFC tag decoder (implementation)
*
* ESL tags contain an NDEF URI whose last 10 characters
* encode the ESL ID using a custom base64 alphabet.
* This module decodes them into the 17-char barcode format
* expected by tagtinker_barcode_to_plid().
* Supported ESL tags carry an NDEF URI whose last path segment is a
* 10-character id in a custom base64 alphabet. This module walks the tag's TLV
* area, pulls out the URI, and decodes that id into the 17-character barcode
* format expected by tagtinker_barcode_to_plid().
*/

#include "tagtinker_nfc.h"
#include <string.h>
#include <strings.h>
#include <stdio.h>

/* Direct ASCII-to-index lookup table, -1 = not in alphabet */
static const int8_t CHAR_LUT[128] = {
Expand Down Expand Up @@ -48,8 +50,11 @@ static bool decode_tag(const char* tag, char barcode[18]) {
uint32_t val1 = decode_b64(tag + 5, 5);
uint32_t val2 = decode_b64(tag, 5);

char raw[20];
/* Each value is 30 bits, so it can reach 10 digits; give snprintf room for
* both fields plus the terminator instead of letting it truncate. */
char raw[24];
snprintf(raw, sizeof(raw), "%09lu%09lu", (unsigned long)val1, (unsigned long)val2);
if(strlen(raw) != 18) return false;

int lc = (raw[0] - '0') * 10 + (raw[1] - '0');
if(lc > 25) return false;
Expand All @@ -69,40 +74,170 @@ static bool decode_tag(const char* tag, char barcode[18]) {
return (cs % 10) == (barcode[16] - '0');
}

static bool extract_from_pages(const MfUltralightData* mfu, char barcode[18]) {
if(mfu->pages_read < 11) return false;
/* Read one byte of the TLV area, which starts at page 4. Returns false once
* the offset runs past the pages the poller actually managed to read. */
static bool tlv_byte(const MfUltralightData* mfu, uint32_t offset, uint8_t* out) {
uint32_t page = 4U + (offset / 4U);
if(page >= mfu->pages_read) return false;
*out = mfu->page[page].data[offset % 4U];
return true;
}

const uint8_t* p3 = mfu->page[3].data;
if(p3[0] != 0xE1) return false;
/* Locate the NDEF TLV (type 0x03), skipping NULL / lock-control /
* memory-control TLVs that factory-formatted NTAG21x tags put in front of it. */
static bool
find_ndef_tlv(const MfUltralightData* mfu, uint32_t* value_start, uint32_t* value_len) {
uint32_t offset = 0;
uint8_t type = 0;

while(tlv_byte(mfu, offset, &type)) {
offset++;
if(type == 0x00) continue; /* NULL TLV: no length and no value */
if(type == 0xFE) return false; /* terminator */

uint8_t len_byte = 0;
if(!tlv_byte(mfu, offset, &len_byte)) return false;
offset++;

uint32_t len = len_byte;
if(len_byte == 0xFF) { /* three-byte length form */
uint8_t hi = 0, lo = 0;
if(!tlv_byte(mfu, offset, &hi) || !tlv_byte(mfu, offset + 1, &lo)) return false;
offset += 2;
len = ((uint32_t)hi << 8) | lo;
}

if(type == 0x03) {
*value_start = offset;
*value_len = len;
return true;
}
offset += len;
}
return false;
}

const uint8_t* p4 = mfu->page[4].data;
if(p4[0] != 0x03) return false;
uint8_t ndef_len = p4[1];
if(ndef_len < 5) return false;
/* Extract the URI body and report the NDEF URI prefix code alongside it. */
static bool
extract_uri(const MfUltralightData* mfu, char* url, size_t url_size, uint8_t* prefix_code) {
if(!mfu || !url || url_size == 0) return false;
url[0] = '\0';

if(mfu->pages_read < 5) return false;
if(mfu->page[3].data[0] != 0xE1) return false; /* NDEF capability container */

uint32_t start = 0, value_len = 0;
if(!find_ndef_tlv(mfu, &start, &value_len)) return false;
if(value_len < 4) return false;

uint8_t header = 0, type_len = 0, payload_len = 0;
uint32_t offset = start;
if(!tlv_byte(mfu, offset++, &header)) return false;
if(!(header & 0x10)) return false; /* short record only */
if((header & 0x07) != 0x01) return false; /* TNF must be NFC Forum well-known */
if(!tlv_byte(mfu, offset++, &type_len)) return false;
if(!tlv_byte(mfu, offset++, &payload_len)) return false;

uint8_t id_len = 0;
if(header & 0x08) { /* IL flag: an ID length byte follows */
if(!tlv_byte(mfu, offset++, &id_len)) return false;
}

uint8_t flat[28];
for(int i = 0; i < 7; i++) {
memcpy(flat + i * 4, mfu->page[4 + i].data, 4);
if(type_len != 1) return false;
uint8_t type = 0;
if(!tlv_byte(mfu, offset, &type)) return false;
if(type != 0x55) return false; /* 'U' = URI record */
offset += type_len;
offset += id_len;

if(payload_len < 2) return false;

/* payload[0] is the URI prefix code ("https://" and friends); the body
* follows it. Copy the body only, bounded by the record length, the
* caller's buffer and the pages we actually read. */
uint8_t prefix = 0;
if(!tlv_byte(mfu, offset, &prefix)) return false;
if(prefix_code) *prefix_code = prefix;

size_t out = 0;
for(uint32_t i = 1; i < payload_len; i++) {
uint8_t c = 0;
if(!tlv_byte(mfu, offset + i, &c)) break;
if(c == 0x00 || c == 0xFE) break;
if(out + 1 >= url_size) break;
url[out++] = (char)c;
}
url[out] = '\0';
return out > 0;
}

int payload_end = 6 + (ndef_len - 4);
if(payload_end > 28) payload_end = 28;
bool tagtinker_nfc_extract_url(const MfUltralightData* mfu, char* url, size_t url_size) {
return extract_uri(mfu, url, url_size, NULL);
}

char url_body[40] = {0};
int j = 0;
for(int i = 6; i < payload_end && j < 39; i++) {
if(flat[i] == 0xFE) break;
url_body[j++] = (char)flat[i];
}
bool tagtinker_nfc_decode_url(const char* url, char barcode[18]) {
if(!url || !barcode) return false;
barcode[0] = '\0';

const char* last_slash = strrchr(url_body, '/');
const char* last_slash = strrchr(url, '/');
if(!last_slash) return false;

return decode_tag(last_slash + 1, barcode);
}

bool tagtinker_nfc_url_host_is(const char* url, const char* host) {
if(!url || !host || !*host) return false;

/* NDEF URI bodies normally have the scheme stripped into the prefix code,
* but a record written with prefix code 0x00 keeps it inline. A "://"
* after the first '/', '?' or '#' belongs to the path, query or fragment. */
const char* scheme_end = strstr(url, "://");
if(scheme_end && scheme_end < url + strcspn(url, "/?#")) url = scheme_end + 3;

size_t host_len = strlen(host);
if(strncasecmp(url, host, host_len) != 0) return false;

char next = url[host_len];
return next == '\0' || next == '/' || next == ':' || next == '?' || next == '#';
}

bool tagtinker_nfc_decode_barcode(const MfUltralightData* mfu_data, char barcode[18]) {
if(!mfu_data) return false;
if(!mfu_data || !barcode) return false;
barcode[0] = '\0';
return extract_from_pages(mfu_data, barcode);

char url[TAGTINKER_NFC_URL_LEN];
if(!tagtinker_nfc_extract_url(mfu_data, url, sizeof(url))) return false;
return tagtinker_nfc_decode_url(url, barcode);
}

TagTinkerNfcResult tagtinker_nfc_classify(const MfUltralightData* mfu_data, char barcode[18]) {
if(barcode) barcode[0] = '\0';

/* The firmware poller reports success with zero pages when a chip was
* activated but its first page read failed. Any NFC-A chip that is not an
* Ultralight/NTAG ends up here, as can a tag that moved during the read. */
if(!mfu_data || mfu_data->pages_read == 0) return TagTinkerNfcResultUnreadable;

char url[TAGTINKER_NFC_URL_LEN];
uint8_t prefix = 0;
if(!extract_uri(mfu_data, url, sizeof(url), &prefix)) {
return TagTinkerNfcResultUnrecognized;
}

if(barcode && tagtinker_nfc_decode_url(url, barcode)) return TagTinkerNfcResultDecoded;
if(barcode) barcode[0] = '\0';

/* The one public VUSION label dump (i12bp8/TagTinker#51) carries
* https://nfc.imagotag.com/<id>. Match that host exactly rather than any
* URL that happens to contain the word. Prefix codes 0x01/0x02 add "www."
* to the host, and other codes are not web links, so only accept
* http:// / https:// as prefix code 0x03 / 0x04 or written inline after
* prefix code 0x00. */
bool inline_web = prefix == 0x00 && (strncasecmp(url, "http://", 7) == 0 ||
strncasecmp(url, "https://", 8) == 0);
bool web_link = prefix == 0x03 || prefix == 0x04 || inline_web;
if(web_link && tagtinker_nfc_url_host_is(url, "nfc.imagotag.com")) {
return TagTinkerNfcResultImagotagLink;
}

return TagTinkerNfcResultUnrecognized;
}
44 changes: 42 additions & 2 deletions nfc/tagtinker_nfc.h
Original file line number Diff line number Diff line change
@@ -1,14 +1,54 @@
/*
* TagTinker — ESL NFC tag decoder
*
* Decodes NDEF URI from ESL Mifare Ultralight tags
* into the 17-character barcode format used by TagTinker.
* Reads the NDEF URI from a Mifare Ultralight / NTAG tag and decodes the
* ESL id in its last path segment into the 17-character barcode format used
* by TagTinker.
*/

#pragma once

#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
#include <nfc/protocols/mf_ultralight/mf_ultralight.h>

/* Longest NDEF URI body (scheme prefix excluded) we keep. */
#define TAGTINKER_NFC_URL_LEN 96

/* Extract the NDEF URI body (without the "https://" style prefix) from a tag.
* Walks the TLV area so lock/memory-control TLVs before the NDEF TLV are
* skipped, and reads as many pages as the record actually spans. */
bool tagtinker_nfc_extract_url(const MfUltralightData* mfu_data, char* url, size_t url_size);

/* Decode the ESL id in the URL's last path segment into a barcode. */
bool tagtinker_nfc_decode_url(const char* url, char barcode[18]);

/* True when the URL's host is `host` (case-insensitive). The URL may be an
* NDEF URI body without a scheme, or a full URL with one. The caller decides
* whether the stripped NDEF prefix code (for example "https://www.") changes
* the real host. */
bool tagtinker_nfc_url_host_is(const char* url, const char* host);

/* Convenience: extract the URL from the tag and decode it in one call. */
bool tagtinker_nfc_decode_barcode(const MfUltralightData* mfu_data, char barcode[18]);

/* What a completed read contains, from the app's point of view. None of these
* results identifies a vendor or a display technology; they only describe the
* NFC data. */
typedef enum {
/* A chip was activated but no page could be read: not an Ultralight/NTAG,
* or a tag that moved during the read. */
TagTinkerNfcResultUnreadable,
/* The NDEF URI carries an id TagTinker decodes; barcode is filled. */
TagTinkerNfcResultDecoded,
/* No decodable id, and the NDEF URI is an http:// or https:// link whose
* host is nfc.imagotag.com. */
TagTinkerNfcResultImagotagLink,
/* Readable, but no NDEF URI or no decodable id in it. */
TagTinkerNfcResultUnrecognized,
} TagTinkerNfcResult;

/* Classify a completed read. barcode is filled only for ..Decoded and is an
* empty string otherwise. */
TagTinkerNfcResult tagtinker_nfc_classify(const MfUltralightData* mfu_data, char barcode[18]);
Loading