09 January 2013

BIND part 3: Full DLZ-backed domain


Not too long ago I gave a presentation at a tech conference regarding using DNS blacklisting/blackholes/sinkholes to identify and mitigate the effects of malware. Specifically, it was that presentation that sparked my last blog post on configuring BIND to use DLZs. After that presentation I received a phone call from an entity wanting to use DLZs for their entire domain, not just for their DNS blackhole. Since my DNS servers already have to do at least one database lookup for every DNS query, and since I already have instructions on how to build a DNS blackhole using DLZs, I thought it fitting to go ahead and extend that to the logical ending -- using DLZs for an entire domain.

Database Changes


Since I already have a working PostGreSQL, BIND and DLZ deployment in a virtual environment, I'm going to use that as my workspace. Using either "\d" from the psql CLI or looking at the output of a "pg_dump -s dnsbh" from the system CLI shows the following existing columns for the dns_records table:
  • id serial
  • zone text
  • host text
  • ttl integer
  • type text
  • data text
Where zone is the zone being queried, host indicates whether it's a single host or a wildcard for all subdomains, type indicates A/AAAA/MX/NS/etc and data indicates the destination IP address for that record.

To return "proper" responses for all queries related to a domain, there are some columns that need to be added:
  • mx_priority, the priority for MX records
  • contact, the responsible contact for the zone
  • update_serial, the serial number for the zone update
  • refresh, the amount of time slaves or secondary servers wait between update requests
  • retry, the amount of time slaves or secondary servers wait between update requests after a failed request
  • expiration, the amount of time before a record in the cache is considered stale
  • minimum, the minimum amount of time to keep the item in the cache
  • primary_ns, the DNS servers for the zone
To add these columns to the dns_records table, I'll issue the following at the psql CLI on the PostGreSQL master:
ALTER TABLE dns_records
ADD COLUMN mx_priority INTEGER,
ADD COLUMN contact CHARACTER VARYING (255),
ADD COLUMN update_serial INTEGER,
ADD COLUMN refresh INTEGER,
ADD COLUMN retry INTEGER,
ADD COLUMN expiration INTEGER,
ADD COLUMN minimum INTEGER,
ADD COLUMN primary_ns CHARACTER VARYING (255); 

Supporting the DLZ driver functions


The PostGreSQL DLZ driver expects some value (even if that value is "NULL") to be returned for the MX priority in its lookup() function as the third value returned, between 'type' and 'data'. This function performs the query defined by the seconds "SELECT" statement in the BIND configuration file. Those two things combined means that I need to add the mx_priority column to the SELECT query. I will post the entire BIND configuration later so don't worry just yet about editing the file, just be cognisant that it will have to happen.

The PostGreSQL DLZ driver needs to be able to query for SOA and NS records and it does this via the authority() function. The query for this function should return values for ttl, type, mx_priority, data, contact, update_serial, refresh, retry, expiration and minimum. This will get added after the existing final "SELECT" statement in the BIND configuration. There is a caveat to this - the query used by the lookup() function *CAN* be written to return SOA and NS records; if this is the case then the query for the authority() function can be an "empty query" and written as "{}" (note there are no spaces between the braces). I am specifically NOT letting my lookup() function pull NS and SOA records so a query is necessary for the authority() function.

The next item added to the BIND configuration is a query that returns values for the DLZ driver's allnodes() function. It will return values identical to the authority() function but the query doesn't differentiate between NS/SOA and other records so it will be nearly identical to the query written for the authority() function.

Finally, it is possible to add a query that supports the allowzonexfr() function and returns the zone and IP addresses of clients allowed to perform zone transfers for that zone. Note that if you want to allow zone transfers then you *must* support both the authority() and allowzonexfr() functions. Since part of the point of database replication and backing BIND with a database is to allow instant access to updates, I will NOT add a query to support this functionality. It is recommended against by the original bind-dlz team, for what I believe to be very good reasons, and I believe the proper way to handle those updates is via database replication to all secondary DNS servers. Using this model effectively removes the "master/slave" relationship and allows any DNS server in the organisation to act as a primary DNS server without having to change anything in the BIND configuration.

Editing the BIND configuration


1) Adding mx_priority for the lookup() function

{
  select
    ttl,
    type,
    mx_priority,
    case when lower(type)='txt' then '\"' || data || '\"' else data end
  from
    dns_records
  where
    zone = '$zone$' and
    host = '$record$' and
    not (type = 'SOA' or type = 'NS')}
}

2) Adding a query for the authority() function

{
  select
    ttl,
    type,
    data,
    primary_ns,
    contact,
    update_serial,
    refresh,
    retry,
    expiration,
    minimum
  from
    dns_records
  where
    zone = '$zone$' and
    (type = 'SOA' or type='NS')
}
3) Adding a query for the allnodes() function
{
  select
    ttl,
    type,
    host,
    mx_priority,
    data,
    contact,
    update_serial,
    refresh,
    retry,
    expiration,
    minimum
  from
    dns_records
  where
    zone = '$zone$'
}

Put all the queries together


Taking all of the above modifications into consideration, the new dlz section of my BIND configuration now looks like this (on my production machines the select statements aren't so separated, I just do it here for clarity):

dlz "postgres" {
  database "postgres 4
  {host=localhost port=5432 dbname=dnsbh user=dnsbh password='password_you_used'}
  {
    select
      zone
    from
      dns_records
    where
      zone = '$zone$'
  }
  {
    select
      ttl,
      type,
      mx_priority,
      case when lower(type)='txt' then '\"' || data || '\"' else data end
    from
      dns_records
    where
      zone = '$zone$' and
      host = '$record$' and
      not (type = 'SOA' or type = 'NS')
  }
  {
    select
      ttl,
      type,
      data,
      primary_ns,
      contact,
      update_serial,
      refresh,
      retry,
      expiration,
      minimum
    from
      dns_records
    where
      zone = '$zone$' and
      (type = 'SOA' or type='NS')
  }
  {
    select
      ttl,
      type,
      host,
      mx_priority,
      data,
      contact,
      update_serial,
      refresh,
      retry,
      expiration,
      minimum
    from
      dns_records
    where
      zone = '$zone$'
  }";
};

Adding domain data to the database


I decided to test with a very simple domain. At the minimum I wanted a primary nameserver, a mail server, a random machine and something to justify a CNAME record so I could perform the following lookups:
  • SOA
  • NS
  • A
  • CNAME
  • MX
  • PTR
To do that, I used the following IP:name combinations:
  • 10.10.10.103 -- ns1.demo.local
  • 10.10.10.112 -- mail.demo.local
I then decided to use imap.demo.local as a CNAME to mail.demo.local.

First, I'll add the SOA records for the demo.local zone and for reverse lookups for the 10.10.10.0/24 network:
INSERT INTO dns_records (zone, host, ttl, type, data, mx_priority, contact, update_serial, refresh, retry, expiration, minimum, primary_ns) VALUES ('demo.local', '@', '86400', 'SOA', NULL, NULL, 'hostmaster.demo.local.', '2013010801', '3600', '1800', '604800', '86400', 'ns1.domain.local.');

INSERT INTO dns_records (zone, host, ttl, type, data, mx_priority, contact, update_serial, refresh, retry, expiration, minimum, primary_ns) VALUES ('10.10.10.in-addr.arpa', '@', '86400', 'SOA', NULL, NULL, 'hostmaster.demo.local.', '2013010801', '3600', '1800', '604800', '86400', 'ns1.domain.local.');
The NS records for my zone and IP range:
INSERT INTO dns_records (zone, host, ttl, type, data, mx_priority, contact, update_serial, refresh, retry, expiration, minimum, primary_ns) VALUES ('demo.local', '@', '86400', 'NS', 'ns1.demo.local.', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);

INSERT INTO dns_records (zone, host, ttl, type, data, mx_priority, contact, update_serial, refresh, retry, expiration, minimum, primary_ns) VALUES ('10.10.10.in-addr.arpa', '@', '86400', 'NS', 'ns1.demo.local.', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
The MX record with a priority of 10 for mail.demo.local:
INSERT INTO dns_records (zone, host, ttl, type, data, mx_priority, contact, update_serial, refresh, retry, expiration, minimum, primary_ns) VALUES ('demo.local', '@', '300', 'MX', 'mail.demo.local.', '10', NULL, NULL, '3600', '1800', '604800', '86400', NULL);
The A records for ns1.demo.local (10.10.10.103) and mail.demo.local (10.10.10.112):
INSERT INTO dns_records (zone, host, ttl, type, data, mx_priority, contact, update_serial, refresh, retry, expiration, minimum, primary_ns) VALUES ('demo.local', 'ns1', '86400', 'A', '10.10.10.103', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);

INSERT INTO dns_records (zone, host, ttl, type, data, mx_priority, contact, update_serial, refresh, retry, expiration, minimum, primary_ns) VALUES ('demo.local', 'mail', '86400', 'A', '10.10.10.112', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
The  PTR records so reverse lookups work:
INSERT INTO dns_records (zone, host, ttl, type, data, mx_priority, contact, update_serial, refresh, retry, expiration, minimum, primary_ns) VALUES ('10.10.10.in-addr.arpa', '103', '86400', 'PTR', 'ns1.demo.local.', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);

INSERT INTO dns_records (zone, host, ttl, type, data, mx_priority, contact, update_serial, refresh, retry, expiration, minimum, primary_ns) VALUES ('10.10.10.in-addr.arpa', '112', '86400', 'PTR', 'mail.demo.local.', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
Finally, the CNAME to point imap.demo.local to mail.demo.local:
INSERT INTO dns_records (zone, host, ttl, type, data, mx_priority, contact, update_serial, refresh, retry, expiration, minimum, primary_ns) VALUES ('demo.local', 'imap', '86400', 'CNAME', 'mail.demo.local.', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
At this point the following lookups should work when performed against the DNS server:
  • dig soa demo.local
  • dig ns ns1.demo.local
  • dig mx demo.local
  • dig mail.demo.local
  • dig imap.demo.local
From here it's a trivial matter to add as many A records or CNAMEs and corresponding PTR records as necessary. My authoritative DNS servers handle over 60k IPs and I make manual changes every day so if I were backing them with DLZ I would develop a web-based management interface but for a small network (a /24 or smaller), with a single DNS administrator, manually interacting with the database really isn't too terribly prohibitive.

No comments:

Post a Comment

Note: only a member of this blog may post a comment.

A New Year, A New Lab -- libvirt and kvm

For years I have done the bulk of my personal projects with either virtualbox or VMWare Professional (all of the SANS courses use VMWare). R...