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.

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
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.
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.
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.
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.
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.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
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:
1[03] w w w [06] g o o g l e [03] c o m [00]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.
The Answer, Authority, and Additional sections all use the same Resource Record format.
IN (value 1), meaning internet.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.
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.
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
48Question, 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.
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.
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.
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 };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.
The Listener class itself has four public methods:
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.run(), enters the main loop, calling handleQuery() continuously. It never returns under normal operation.loadBlocklist(), takes a list of file paths, reads them line by line, and loads every domain into an unordered_set for fast lookups.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
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;
9The 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
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
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())) {
9For 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
1result.value().getHeader().setQr(true);
2result.value().getHeader().setRa(true);
3
4result.value().getHeader().setAuthorities(0);
5result.value().getHeader().setAdditionals(0);
6If 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:
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
1auto encoded = DNS::Parser::MessageParser::encode(result.value());
2The 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
1if (auto err = forward(buf, received, client); err != Error::OK)
2If 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.
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:
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.
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