Ad Blocker

A lightweight DNS-based ad blocker built in C++ that intercepts unwanted domains at the network level, no browser extensions needed. Fast, low-level, and efficient.

C++
Network
Code
Banner image

Why Should You Care?

Every time you visit a website, the browser sends bunch of DNS requests not just for the site it self but for ad servers, trackers, and analytics tools, Most people ignore this. After all, ads are just... ads. Right?
But that's not really true anymore.

Attackers can use ads for phishing URLs embedded in the ads, And it's not just shady websites major organizations including The New York Times, BBC, Spotify, Forbes, and the NFL have all been hit by malvertising attacks in recent years. more

How ads blocker work?

Most ad blockers live inside your browser as extensions, they work by sitting between the page and what page gets rendered, basically it will compare every request against a list of known ad and tracker domains, and blocking the one that match.

It works pretty well, but it has a fundamental limitation, it's all happening inside the browser, after the DNS lookup has already been made. and because it's in your browser someone in your network still will face these ads or phishing websites unless if they are using the same browser or extension.

So DNS based ad blocker works one level lower than that.

When you type a URL, the first thing your machine does is resolved the URL to an IP address and then your browser make the actual connection.

A DNS blocker intercepts that very first step Instead of letting the query for ads.doubleclick.net go out and get resolved, it just... drops it. Returns nothing, or a dead address. The browser never gets an IP to connect to, so no connection is ever made.

This approach has a few real advantages. It works across your entire machine or even your whole network not just one browser. It blocks ads in apps, not just websites. And since the request never goes out, there's no round trip, no wasted bandwidth, nothing phoning home.

Demo

For now the blocker runs on Windows, Linux support is on the way. Clone the repo, build it and run it

from the image above the Listener is running on 127.0.0.1:53 so let's open the DNS setting and set the DNS IP to 127.0.0.1 like so:

This runs locally on your machine by default. But if you want it to cover your entire network every device, every phone, every app you can set it up at the router level.

Go into your router's admin panel (usually at 192.168.1.1 or 192.168.0.1), find the DNS settings, and replace the primary DNS with the IP of the machine running the blocker. Now every device on your network routes its DNS requests through it, and they all get filtered without touching a single setting on any of them.

Now let's see a website without add blocker and see the effect:

as we can see the ads now let's see after:

as we can see there is no ads at all, and let's see the logs:

as we can see the DNS blocked a.nel.cloudflare.com which is used for ads. because of this we are not getting any ads.

Code

To understand the code first we need to understand couple of things, we need to understand the structure of the DNS, what we should response etc.

DNS

if you now the basics of DNS struct you can skip this section. Every DNS message, whether a query or a response, begins with a fixed 12-byte header.

  1. ID, a unique identifier the client assigns so it can match a response back to the right query.
  2. Flags, 16 bits that carry control bits telling you whether this is a query or a response, whether it was resolved successfully, and so on.
  3. QDCOUNT, how many questions are in the packet.
  4. ANCOUNT, how many answers are in the packet
  5. .NSCOUNT, when the server doesn't have the final answer, instead of failing it'll say "I don't know, but here are the name servers that do." This counts how many of those name server records are included.
  6. ARCOUNT, sometimes the response includes bonus records that weren't directly asked for but are useful. For example, if the server points you to ns1.example.net, it might also throw in its IP in the same packet so you don't have to go look it up separately. This counts those extra records.

Flags

  1. QR, 0 means it's a query, 1 means it's a response.
  2. Opcode, defines the type of query. 0 is a standard query, 1 is an inverse query, 2 is a status request.
  3. AA (Authoritative Answer), set to 1 in a response if the server actually owns the domain being queried.
  4. TC (Truncation) set to 1 if the message was too large and got cut off, which usually happens when the response exceeds 512 bytes over UDP.
  5. RD (Recursion Desired), set by the client to tell the server "go figure this out for me if you don't know the answer."
  6. RA (Recursion Available), set by the server to say "yes, I support recursion."
  7. Z , reserved bits, must be 0. Though bit 9 is now used for DNSSEC Authentic Data and bit 10 for Checking Disabled
  8. RCODE (Response Code), the result of the query. 0 means no error, 1 is a format error, 2 is a server failure, 3 is NXDOMAIN (domain doesn't exist), 4 is not implemented, and 5 is refused.

Question Section

The Question section contains the parameters of the query. While the header allows for multiple questions (QDCOUNT), in practice, almost all DNS queries contain exactly one question. it contains the following

  1. QNAME, variable size, the domain name being queried.
  2. QTYPE, 16 bits, The type of query (e.g., A=1, NS=2, MX=15, AAAA=28).
  3. QCLASS, 16 bits, The class of query (usually IN=1 for Internet).

Domain Name Encoding (QNAME)

Domain names are not stored as simple strings. Instead, they are a sequence of labels. Each label consists of a length octet followed by that many characters. The name ends with a null byte (0x00).

Example: www.google.com is encoded as:

code
1[03] w w w [06] g o o g l e [03] c o m [00]

Message Compression

To save space, DNS uses a compression scheme for domain names. If a domain name (or a suffix of it) has already appeared in the message, it can be replaced by a pointer.

it's pointer (16) bit first two bit are 11 , the remaing 14 bit's represent an unsigned integer that specifies an offset from the beginning of the DNS message. When a DNS parser encounters a byte sequence starting with 11 (binary), it interprets the following 14 bits as an offset. It then jumps to that offset in the message and continues parsing the domain name from there. This process can involve following multiple pointers if a name is compressed in stages.

Resource Record Structure

The Answer, Authority, and Additional sections all use the same Resource Record format.

  1. NAME, the domain name this record belongs to, encoded using DNS compression to save space.
  2. TYPE, what kind of record this is, for example A for IPv4, AAAA for IPv6, or CNAME for an alias.
  3. CLASS, almost always IN (value 1), meaning internet.
  4. TTL, how many seconds this record can be cached before it needs to be looked up again.
  5. RDLENGTH, the length in bytes of the data that follows.
  6. RDATA, the actual payload. For an A record this is the 4-byte IPv4 address, for AAAA it's the 16-byte IPv6 address, and so on.

DNS parser

For this project we only need the header and the domain name that's enough to intercept a query and decide whether to block it. But I went ahead and implemented a full DNS parser anyway, covering the complete packet structure with all the fields and full validation checks. It's more than the blocker strictly needs, but it's a solid implementation if you want to extend it later.

Inside the DNS::Parser namespace there are 5 classes, each responsible for one piece of the packet. The MessageParser class ties it all together with just two static methods, parse() to turn raw bytes into a Message, and encode() to turn a Message back into raw bytes ready to send.

All the shared enums and error types live separately in DNS:: namespace inside include/parser/common.hpp, things like QType, QClass, RCode, and OpCode that are used across all the classes.

Name, handles encoding and decoding of domain names, including DNS compression.

parser.hpp
1 class Name { 2 public: 3 // decode: handles both cases internally 4 // → plain labels: reads normally 5 // → 0xC0 pointer: jumps and follows, caller doesn't need to know 6 static std::expected<std::string, Error> 7 decode(const uint8_t* data, size_t len, size_t&offset) noexcept; 8 9 // encode: handles both cases internally 10 // → no table: writes plain labels 11 // → table given: writes pointer if name was seen before 12 static std::expected<std::vector<uint8_t>, Error> 13 encode(const std::string& name, 14 std::unordered_map<std::string, uint16_t>* table, 15 uint16_t baseOffset) noexcept; 16 };

Header, 12 bytes header we talked about above, this class parse all the flags, counters, IDs, with some getters and setters methods as well as the serialize method which covert all the flags, IDs, etc to raw bytes to send to a client.

parser.hpp
1 class Header { 2 public: 3 uint16_t getRawFlags() const noexcept; 4 void setId(const uint16_t& id) noexcept { id_ = id;} 5 const uint16_t& getId() const noexcept { return id_;} 6 7 void setQr(const bool& qr) noexcept { qr_ = qr;} 8 bool isQr() const noexcept { return qr_;} 9 10 ........................ 11 ........................ 12 ........................ 13 void setQuestions(const uint16_t& qdcount) noexcept {qdcount_=qdcount;}; 14 const uint16_t& getQuestions() const noexcept { return qdcount_;} 15 16 void setAnswers(const uint16_t& ancount) noexcept {ancount_=ancount;}; 17 const uint16_t& getAnswers() const noexcept { return ancount_;} 18 19 void setAuthorities(const uint16_t& nscount) noexcept {nscount_=nscount;}; 20 const uint16_t& getAuthorities() const noexcept { return nscount_;} 21 22 void setAdditionals(const uint16_t& arcount) noexcept {arcount_=arcount;}; 23 const uint16_t& getAdditionals() const noexcept { return arcount_;} 24 25 static std::expected<Header,Error> decode(const uint8_t*buffer,size_t len); 26 27 std::expected<std::vector<uint8_t>, Error> encode() const noexcept ; 28 29 const void print() const noexcept; 30 private: 31 uint16_t id_; 32 bool qr_; 33 OpCode opcode_; 34 bool aa_; 35 bool tc_; 36 bool rd_; 37 bool ra_; 38 bool ad_; 39 bool cd_; 40 RCode rcode_; 41 42 uint16_t qdcount_; // number of questions 43 uint16_t ancount_; // number of answer RRs 44 uint16_t nscount_; // number of authority RRs 45 uint16_t arcount_; // number of additional RRs 46 }; 47 48

Question, holds the domain name being asked about, the record type (A, AAAA, etc.), and the class (almost always IN). One query usually has exactly one question.

parser.hpp
1 class Question{ 2 public: 3 bool isA() const { return qtype_ == QType::A; } 4 bool isAAAA() const { return qtype_ == QType::AAAA; } 5 bool isAny() const { return qtype_ == QType::ANY; } 6 7 void setName(const std::string&name) noexcept {qname_=name;}; 8 const std::string& getName() const noexcept {return qname_;}; 9 10 void setQtype(const QType& type) noexcept {qtype_ = type;}; 11 const QType& getType() const noexcept { return qtype_ ;}; 12 13 void setQclass(const QClass& qclass) noexcept {qclass_ = qclass;}; 14 const QClass& getClass() const noexcept { return qclass_ ;}; 15 void print() const noexcept; 16 static std::expected<Question,Error> decode(const uint8_t* data, size_t len, size_t& offset) noexcept; 17 std::expected<std::vector<uint8_t>, Error> 18 encode(std::unordered_map<std::string, uint16_t>* table, 19 uint16_t baseOffset) const noexcept ; 20 private: 21 std::string qname_; 22 QType qtype_{QType::A}; 23 QClass qclass_{QClass::IN_}; 24 };

ResourceRecord, represents a single answer entry. It holds the name, type, TTL, and the raw rdata which for an A record would be the 4-byte IPv4 address.

parser.hpp
1 class ResourceRecord { 2 public: 3 4 const std::string& getName() const noexcept { return name_; } 5 const QType& getType() const noexcept { return type_; } 6 const QClass& getRclass() const noexcept { return rclass_; } 7 const uint32_t& getTtl() const noexcept { return ttl_; } 8 const uint16_t& getRdlength() const noexcept { return rdlength_; } 9 const std::vector<uint8_t>& getRdata() const noexcept { return rdata_; } 10 11 // Setters 12 void setName (const std::string& name) noexcept { name_ = name; } 13 void setType (const QType& type) noexcept { type_ = type; } 14 void setRclass (const QClass& rclass) noexcept { rclass_ = rclass; } 15 void setTtl (const uint32_t& ttl) noexcept { ttl_ = ttl; } 16 void setRdlength(const uint16_t& rdlength) noexcept { rdlength_ = rdlength; } 17 void setRdata (const std::vector<uint8_t>& rdata) noexcept { rdata_ = rdata; } 18 19 static std::expected<ResourceRecord, Error> 20 decode(const uint8_t* data, size_t len, size_t& offset) noexcept; 21 22 std::expected<std::vector<uint8_t>, Error> 23 encode(std::unordered_map<std::string, uint16_t>* table, 24 uint16_t baseOffset) const noexcept ; 25 private: 26 std::string name_; // owner name e.g. "google.com" 27 QType type_; // record type e.g. QType::A 28 QClass rclass_; // almost always QClass::IN 29 uint32_t ttl_; // seconds until expiry 30 uint16_t rdlength_; // byte length of rdata 31 std::vector<uint8_t> rdata_; // raw record data 32 33 };

Message the container that holds everything together. One Message is one DNS packet, it has a header and four vectors: questions, answers, authority records, and additional records.

parser.hpp
1 class Message{ 2 public: 3 // Getters 4 Header& getHeader() noexcept { return header_; } 5 const std::vector<Question>& getQuestions() const noexcept { return questions_; } 6 const std::vector<ResourceRecord>& getAnswers() const noexcept { return answers_; } 7 const std::vector<ResourceRecord>& getAuthority() const noexcept { return authority_; } 8 const std::vector<ResourceRecord>& getAdditional() const noexcept { return additional_; } 9 10 // Setters 11 void setHeader (const Header& header) noexcept { header_ = header; } 12 void setQuestions (const std::vector<Question>& questions) noexcept { questions_ = questions; } 13 void setAnswers (const std::vector<ResourceRecord>& answers) noexcept { answers_ = answers; } 14 void setAuthority (const std::vector<ResourceRecord>& authority) noexcept { authority_ = authority; } 15 void setAdditional(const std::vector<ResourceRecord>& additional) noexcept { additional_ = additional; } 16 17 void addQuestion (const Question& q) { questions_.push_back(q); } 18 void addAnswer (const ResourceRecord& rr) { answers_.push_back(rr); } 19 void addAuthority (const ResourceRecord& rr) { authority_.push_back(rr); } 20 void addAdditional(const ResourceRecord& rr) { additional_.push_back(rr); } 21 private: 22 Header header_; 23 std::vector<Question> questions_; 24 std::vector<ResourceRecord> answers_; 25 std::vector<ResourceRecord> authority_; 26 std::vector<ResourceRecord> additional_; 27 };

Server

inside the /include/server/server.hpp there is the core logic for the server inside the namespace DNS::Server we have listener class and the configuration struct.

Listener class

The Listener class itself has four public methods:

  1. init(), sets up Winsock, creates two UDP sockets, one to receive queries from clients and one to talk to the upstream resolver, then binds everything together.
  2. run(), enters the main loop, calling handleQuery() continuously. It never returns under normal operation.
  3. loadBlocklist(), takes a list of file paths, reads them line by line, and loads every domain into an unordered_set for fast lookups.
  4. handleQuery(), the heart of it. It waits for an incoming UDP packet, parses it into a DNS::Parser::Message, checks the domain, and decides what to do with it.

This is where everything happens. Every incoming DNS query goes through this function, and by the end of it the domain is either blocked or forwarded. The rest of the functions are straightforward init() sets up the sockets, loadBlocklist() reads files line by line, forward() sends the query upstream and pipes the response back. The real logic is here.

1. Receive

server.cpp
1const int received = recvfrom(socket_, reinterpret_cast<char *>(buf), sizeof(buf), 0, ...) 2 3if (received == SOCKET_ERROR) 4 return DNS::Error::SERVER_RECV_FAIL; 5 6 // A valid DNS message requires at least a 12-byte header plus 1 byte of question data. 7 if (received < 13) 8 return DNS::Error::PARSE_TOO_SHORT; 9

The function blocks here waiting for a UDP packet to arrive. Once one does, recvfrom fills buf with the raw bytes and client with the sender's address so we know where to send the reply back. If the packet is shorter than 13 bytes it gets dropped immediately anything less than a 12-byte header plus 1 byte of question data isn't a valid DNS message.

2. Parse

server.cpp
1std::expected<DNS::Parser::Message, DNS::Error> result = parser.parse(buf, received); 2if (!result.has_value()) 3 return result.error();

The raw bytes get handed to the parser and decoded into a structured Message. If the packet is malformed it gets rejected here we never forward garbage upstream.

3. Blocklist check

server.cpp
1for (const auto& q : result.value().getQuestions()) { 2 std::println(GREEN "[QUERY] {} asked for: {} (type {})" RESET, 3 inet_ntoa(client.sin_addr), q.getName(), static_cast<uint16_t>(q.getType())); 4 5 // 4. Blocklist check 6 // search() walks up the label hierarchy, so blocking "ads.example.com" 7 // also catches "sub.ads.example.com". 8 if (search(q.getName())) { 9

For each question in the message we call search() on the domain name. As mentioned earlier, search() walks up the label hierarchy so blocking ads.example.com also catches sub.ads.example.com.

4. Build the blocked response

server.cpp
1result.value().getHeader().setQr(true); 2result.value().getHeader().setRa(true); 3 4result.value().getHeader().setAuthorities(0); 5result.value().getHeader().setAdditionals(0); 6

If the domain is blocked we modify the same parsed message in place rather than building a new one. We flip QR to 1 to mark it as a response, set RA to mirror a real resolver, and clear the authority and additional sections since they're meaningless in a blocked response.

For the actual answer there are two cases. If the query type is HTTPS (type 65) we return ANCOUNT=0 with no answer record fabricating a valid HTTPS record is complex and browsers handle an empty response cleanly. For everything else we return a null-route answer:

server.cpp
1if (q.getType() == DNS::QType::HTTPS) { 2 // HTTPS records (type 65) carry rich metadata: ALPN lists, ECH keys, 3 // address hints, etc. Fabricating a structurally valid HTTPS RR is not 4 // feasible , a browser receiving a malformed one will retry and log errors. 5 // Responding with ANCOUNT=0 and NOERROR is the cleanest option: 6 // "no HTTPS record exists" , browsers accept it silently and fall back 7 // to a plain A/AAAA lookup, which we will also intercept. 8 result.value().getHeader().setAnswers(0); 9} else { 10 // For all other record types we return a null-route answer: 11 // A → 0.0.0.0 (4 zero bytes) 12 // AAAA → :: (16 zero bytes) 13 // Other types still receive 4 zero bytes; clients that do not 14 // understand the type will discard the rdata. 15 // TTL=0 prevents the null record from being cached, so the block 16 // takes effect immediately if the domain is later removed from the list. 17 DNS::Parser::ResourceRecord rr; 18 rr.setName (q.getName()); 19 rr.setType (q.getType()); 20 rr.setRclass (q.getClass()); 21 rr.setTtl (0); 22 const uint16_t rdlen = (q.getType() == DNS::QType::AAAA) ? 16 : 4; 23 rr.setRdlength(rdlen); 24 rr.setRdata(std::vector<uint8_t>(rdlen, 0x00)); 25 result.value().setAnswers({rr}); 26 result.value().getHeader().setAnswers(1); 27}

An A record gets 0.0.0.0 (4 zero bytes), AAAA gets :: (16 zero bytes). TTL is set to 0 so the null record doesn't get cached — if the domain is later removed from the blocklist the change takes effect immediately.

5. Encode and send

server.cpp
1auto encoded = DNS::Parser::MessageParser::encode(result.value()); 2

The modified message gets encoded back into raw bytes and sent back to the client via sendto. There's a sanity check before sending to make sure the encoded size fits within a single UDP datagram.

6. Forward

server.cpp
1if (auto err = forward(buf, received, client); err != Error::OK) 2

If the domain isn't blocked we skip all of the above and forward the original raw bytes straight to the upstream resolver, then pipe the response back to the client.

How the search is done?

My current implementation is very simple. At startup the entire blocklist gets loaded into an unordered_set in memory rather than reading from the file on every query. The reason is simple a DNS query needs to be resolved in milliseconds, and hitting the disk on every single request would add latency. With everything already in memory each lookup is O(1), meaning it doesn't matter if the list has 1,000 domains or 100,000, the lookup time stays the same.

Then for each incoming query it walks up the domain labels one by one until it finds a match or runs out:

server.cpp
1bool Listener::search(const std::string& domain) noexcept { 2 std::string current = domain; 3 stripSchema(current); 4 stripPathAndQuery(current); 5 std::transform(current.begin(), current.end(), current.begin(), 6 [](unsigned char c){ return std::tolower(c); }); 7 while (true) { 8 if (blocklist_.contains(current)) 9 return true; 10 size_t dot = current.find('.'); 11 if (dot == std::string::npos) 12 return false; 13 current = current.substr(dot + 1); 14 } 15}

First we strip the schema like https:// and the path/query so we're left with just the domain like google.com. Then we lowercase the whole thing. Now the while loop starts current holds the full domain at first, and we check if blocklist_ contains it. If yes, return true (blocked). If not, find looks for the next . dot in current if no dot is found, we've reached the last segment with nothing left to check so return false. If a dot is found, we chop everything before it so current becomes the parent domain, e.g. sub.evil.google.com → evil.google.com → google.com → com, checking the blocklist at each level until we either find a match or run out of segments.

Now the question is when does this approach stop working?

The main case is when the blocklist gets very large. If you're talking hundreds of MB of domains, loading all of it into RAM upfront becomes wasteful. At that point you'd want to move to a database or an on-disk index that you query selectively rather than holding everything in memory. Another case is live updates right now adding or removing a domain requires a restart. If you need changes to take effect without downtime, or if multiple processes need to share the same list, a database or a memory-mapped file would be the cleaner solution.

For this project the unordered_set is the right call. But down the road, a lightweight database or binary search over a sorted file would be the natural next step.

Published 1 day ago