It’s the festive season of Diwali, and a newly launched website, Bling-Bling Crackers (a fictional site, inspired by platforms like Amazon and Flipkart), is offering huge discounts and free gifts for new users. To celebrate the festival of lights, Deepam Mart has launched a “Diwali Dhamaka” sale, offering ₹1 products, 50% off discounts, and free gifts like Diwali decorations and sweets for first-time users. The company Bling-Bling Crackers uses StoreD.
The evidence was a single Neo4j 5 backup of the shop’s customer graph, with no host logs. After restoring it into an Enterprise-edition container, the data showed two clusters of sign-up abuse aimed at the first-time-user offers. One registration IP produced a large batch of accounts, a second IP produced another, and one delivery address and one credit card were reused across a group of accounts. Every account on the shared card registered from the same IP, which ties the card, the address and the IP into one operation. The whole case came out of Cypher aggregations over Account and CreditCard nodes.
BZV2 header, then a zstd stream wrapping a tar of the store files → strip the 4-byte header, decompress with zstd, extract with tar to inspect the store. To query it, restore it into a running databaseAccount and CreditCard, joined by HAS_CREDITCARD. Account carries username, email, phone_number, created_at, register_ip_address and address. CreditCard carries card_number, cvc, expiry_date and holder_name → query with CypherGotcha:
neo4j-admin database loadon the Community image rejects block-format backups (“Block format detected … unavailable in this edition”). Use theneo4j:5-enterpriseimage. The restore also needs a writable, world-writable copy of the backup directory (the container runs as theneo4juser), and--from-pathmust point at the subfolder holding the file, not the root.
Unpack the backup first to confirm the format: drop the BZV2 header, decompress, and untar to see the store files.
tail -c +5 backup | zstd -dc > work/x/raw
tar -xf work/x/raw -C work/x/t
The packed record store cannot be read by hand, so restore it into an Enterprise container (see the gotcha above) and start the database. An unfiltered count of every node answers the question.
MATCH (n) RETURN count(n)
The schema has two labels, so the Q1 total splits into Account and CreditCard. Counting by label gives the Account figure and checks it against Q1: the two label counts sum to the total. There are slightly more accounts than cards, so some accounts have no card attached.
MATCH (n) RETURN labels(n) AS label, count(*) AS c
Filter Account nodes on register_ip_address for the given address and count them. The result is large enough that this one IP is clearly farming sign-ups, not a household behind a shared NAT.
MATCH (a:Account) WHERE a.register_ip_address='88.236.1.190' RETURN count(a)
Group Account by register_ip_address and keep groups with more than one member. Two IPs qualify: 88.236.1.190 with 22 accounts (Q3) and 149.116.170.185 with 16. Adding them gives 38 accounts that share a registration IP with another account. This count was reasoned from the wording, not read from a single field: usernames are unique, so “users” can only mean accounts here. HTB accepted it.
MATCH (a:Account) WITH a.register_ip_address AS ip, count(*) AS c WHERE c>1 RETURN count(ip), sum(c), max(c)
Gotcha: The question reads like a count of distinct people or distinct IPs, but neither fits. Counting repeating IPs gives 2, and counting a repeating cardholder name on one IP gives 1. The accepted figure is the number of accounts in the shared-IP groups.
Same grouping on the address property. One value appears on 11 accounts, which is the reused delivery address.
MATCH (a:Account) WITH a.address AS ad, count(*) AS c WHERE c>1 RETURN ad, c
Tip: The answer mask
**/**, ******** ****, ******* ******matches the address exactly as stored, slash and commas included.
Follow HAS_CREDITCARD from each account and count distinct accounts per card_number. One 15-digit number sits on 11 accounts, matching the size of the shared-address group from Q5.
MATCH (a:Account)-[:HAS_CREDITCARD]->(c:CreditCard) WITH c.card_number AS n, count(DISTINCT a) AS c WHERE c>1 RETURN n, c
Match the account by username and read created_at. The property is stored as an ISO timestamp with a T separator.
MATCH (a:Account {username:'obhandari'}) RETURN a.created_at, a.username
Gotcha: The stored value has a
Tbetween date and time. The answer maskYYYY-MM-DD HH:MM:SS.SSSSSSwants a space instead, with all six microsecond digits kept.
From Q6 we know the shared card. Group the accounts attached to it by register_ip_address. A single row comes back, 149.116.170.185, covering all 11 accounts. That is the second IP from Q4, so the card, the address and the IP are one operation.
MATCH (a:Account)-[:HAS_CREDITCARD]->(c:CreditCard {card_number:'371593995427734'}) RETURN a.register_ip_address, count(*)
| Phase | Action |
|---|---|
| Resource Development | Bulk registration of accounts from 88.236.1.190 (22 accounts) |
| Resource Development | Second batch from 149.116.170.185 (16 accounts) |
| Resource Development | 11 accounts from 149.116.170.185 share one delivery address and one credit card |
| Type | Value |
|---|---|
| IP (22 accounts) | 88.236.1[.]190 |
| IP (16 accounts, includes the shared-card group) | 149.116.170[.]185 |
| Address | 19/63, Krishnan Ganj, Danapur 441303 |
| Card number | 371593995427734 |
| Username (Q7) | obhandari |
| Technique | ID | Description |
|---|---|---|
| Establish Accounts | T1585 | Mass sign-ups sharing an IP, an address and a card to claim first-time-user offers |
Card data was stored as plain node properties. The CreditCard nodes expose card_number, cvc, expiry_date and holder_name directly. Anyone who obtains a backup of this database gets everything needed to use the cards. Tokenise card data with a payment provider, and never store the CVC at all.
Sign-up abuse is a graph problem and the schema already supports it. Shared register_ip_address, address and card_number values across Account nodes are the signals for multi-account abuse of first-time-user offers. A scheduled query that flags any of these values appearing on more than one account would catch it during the sale, not after.
Backups need a restore path before they are needed. The backup is in Neo4j’s block format, which Community edition cannot load, and the restore needed a writable directory and the right --from-path. Record the edition and version a backup requires alongside the backup itself, or an incident responder loses time at the restore step.
One operator, working from two IPs, registered dozens of accounts for the Diwali first-time-user offers, and eleven of them shared an IP, a delivery address and a single card. The database already held every link needed to flag it; nothing was querying for duplicates.