Sitemap
EternalR00ted

EternalR00ted

Security Researcher

Hunt Diary #1: Following a ScreenConnect Phish into GitHub

What started with a fake DocuSign download led me to a public repository containing phishing code, a web shell, and encrypted PowerShell loaders.

32 min readOct 8, 2026

--

Backstory

One day I got an email to my personal account claiming to be from my insurance company, asking me to sign an updated privacy policy. A quick look at the URL made it obvious to me that it was phishing. Any typical person would delete it.

I am not a typical person. My security nerdiness kicked in and I wanted to see what they were trying to do.

I booted up a VM in the cloud and visited the link. The page wanted me to download a “DocuSign” application from Google Drive, except the file was called ScreenConnect.exe. That was worth a closer look. In the page source, I found a call to a Telegram bot and a request to ipify, the public IP lookup service.

That was the original email’s landing page. The repository copy I examined later used different IP-information services.

My first thought was that clicking Download would notify the operator and remote access would follow. That was my first read, not something I had confirmed. A notification did not mean the installer had run. As I found out later, the code I reviewed could try to notify the operator before anyone even clicked the button.

By the time I went back to investigate further, the Drive link was no longer available. But I still had somewhere to start. The Telegram request.

Prior Reporting and Scope

Before we get into the hunt, there is some earlier work worth pointing out. I started with an email I received, but I was not the first person to come across this activity. Stormshield covered related DocuSign-themed pages on July 10, 2026. When I compared its current indicator tables with my saved observations, 12 of my 13 domains were there. The exception was laboratoires-azbane[.]com.

Socket also published a report on July 22, 2026 about GitHub Actions abuse. It described the same 43.228.157[.]68 address and architecture-specific download paths that I found in the workflows.

What I am sharing here is the route I took through the afrisellers snapshot, the offline token comparison, the historical ShellPanel capture I found, and the October follow-up observations. Those later captures do not show that the sites ran continuously since July. With that context out of the way, let’s start with the Telegram request.

So the hunt began.

Recon

I had a few options for looking for pages that used Telegram bots: Netlas, FOFA, and URLscan. I went with URLscan because I had used it before. I could look through saved screenshots, the rendered DOM, and the requests the browser made during a scan.

That was what I needed for this part of the hunt. I wanted to see what the browser had captured, not just whether a page existed. The server-side activity was a different story. URLscan would not show everything happening there, and that distinction came up again later.

Here is the query I used:

domain:api.telegram.org AND NOT page.domain:api.telegram.org AND date:>now-7d

Here is how I broke that query down.

domain:api.telegram.org looks for scans with a recorded request to Telegram’s API domain.

AND NOT page.domain:api.telegram.org leaves out results where Telegram’s API domain is the main page itself. I wanted other pages reaching out to it.

AND date:>now-7d limits the results to the previous seven days relative to when the query runs. I wanted recent observations and a manageable amount of material to work through.

I was casting a wider net here, not searching for the specific bot from my email. The results could include unrelated activity, so appearing in the same search was not enough to put everything into one campaign.

The query gave me 177 results to work through.

The Hunt

You will be happy to know we are not going to go through every result. Let’s focus on the DocuSign-themed pages.

Fake DocuSign, a ScreenConnect Download

The pages were using DocuSign branding to make the download look familiar. Documents to sign are not exactly unusual, which made the lure worth a closer look.

I narrowed the set down to 13 domains with DocuSign-themed pages under /ftx/. Their effective URLs had a similar tokenized pattern. Some names looked like business sites, but I could not tell how those pages got there or who controlled them.

These were the pages I found through the Telegram API search. The earlier reports helped with the comparison, but I will keep their findings separate from what I saw in the pages and saved scans.

A phishing email was one possible way visitors reached these pages, but I did not know the entry point for every one. Several download links were unavailable when I checked. One page still offered a file.

On October 1, the page I checked on beservicesit[.]mx offered a file named ScreenConnect.ClientSetup.msi. The browser displayed a warning that it could harm the device.

Press enter or click to view image in full size
The page on beservicesit[.]mx during my October 1 check, displaying the ScreenConnect installer filename.
Press enter or click to view image in full size
The browser warning displayed during my check.

I suspected the download was meant to give someone remote access. I had not identified the ScreenConnect instance or relay address, though, and I had not verified an installation or watched an operator connect. The filename and browser warning only took me so far.

The Breakthrough

I put this aside for a little while to focus on a few other things. When I came back, a sentence on one of the DocuSign pages stood out: “Open the downloaded attachment on your computer.” We had come across similar wording at work in a separate, more targeted case. A Google dork had helped us find other pages then, so I tried the same thing here. It brought up two more sites for which I did not find existing URLscan results at the time.

I kept those two as additional leads based on the wording. They are not part of the 13-domain token comparison below.

Press enter or click to view image in full size

The repeated wording made me wonder whether a copy of the source was public. So I searched the phrase on GitHub andddd here it is.

Press enter or click to view image in full size

The search led me to a repo called afrisellers. Inside it was an /ftx/ folder containing a phishing kit that looked very similar to some of the DocuSign pages I had already found.

Now I had code to look at. That was a useful lead, but it did not mean GitHub was hosting the live pages or that every site came from this exact repo.

The name also matched a B2B marketplace. I could not establish how that business related to the GitHub account. The application source and dependencies showed a Laravel application, but the default README was not going to tell me who owned it.

Inside /ftx/, I opened the images folder. These looked familiar:

Press enter or click to view image in full size
Press enter or click to view image in full size

The same images also appeared on some of the phishing pages I had been looking at.

Press enter or click to view image in full size

My suspicion grew stronger when I opened page.html. The layout matched, the wording matched, and even the “© 2025 DocuSign All rights reserved” footer was the same.

I started this trying to understand a ScreenConnect phish. Now I had source code to work through, so I kept poking around the repo.

Inside the Kit

Now let’s get into the folder layout.

These were the files that stood out to me.

I started with index.php, which handles the entry requests and token checks. It looks up the visitor’s IP organization through IPinfo and rejects requests when the result matches certain provider names. That is not a guaranteed cloud-VM or scanner block. It depends on what the lookup returns.

It also defines two hard-coded key values. One is used for the token’s HMAC, while the other is used to create an encrypted redir value. They contain the same assigned text, but the code handles them differently. The HMAC uses the literal string, while the AES call receives the result of hex2bin().

Here is the odd part: the encrypted redir value contains a dummy URL. This copy checks that the parameter exists, but never decrypts it to decide what to download. The actual download location is hard-coded in page.html.

pdf.html is the loading page. Its browser script collects device and browser details, requests IP and location data from ipapi.co, and attempts a Telegram notification without waiting for a download-button click.

page.html contains the DocuSign lure, the external ZIP link, and the download-button handler.

notify.php attempts a server-side Telegram notification when requested. A request to this endpoint does not prove a download completed. The file also returns a status labelled sent without checking whether Telegram accepted the message.

cid.php contains the Telegram bot and chat configuration. The destination configured here matches the one written into the browser-side code in this copy of the kit.

.htaccess routes requests that do not match an existing file or directory back to index.php. That explains the tokenized URLs, but it does not make the token checks apply to every existing file in the folder.

Before the Download

To follow what happens before the download, I went through the entry route. The code creates a token and stores it with the visitor’s IP, User-Agent, and a timestamp. When the tokenized request comes back, it checks that record, the HMAC, and a maximum age of 300 seconds. If the checks pass, it removes the token record. It also requires the redir parameter before continuing.

Next, it increments counter.txt and attempts a server-side Telegram visit notification. Nobody has clicked the download button at this point. That is why I would not read the counter as a total of downloads or infections.

The source checks for selected macOS, iPhone, and Android User-Agent strings and redirects matching requests to an error page. It also calculates a Windows flag, but does not use that flag as a strict Windows-only allowlist.

For requests that continue to the lure, index.php serves pdf.html and inserts a timer that replaces the document with page.html after 5,000 milliseconds. Some comments say three seconds, but the actual timer says five.

The loading page then does its own browser-side IP and device collection and attempts another Telegram notification. So my original idea that the operator only got notified after a download click was too narrow. This copy can try to report the visit before that happens.

The Download Button

Clicking the button in page.html first attempts a POST to the configured external notify.php URL. After that request finishes or fails, the code schedules navigation to the ZIP link using a 500-millisecond timer.

The order matters here. The script waits for that notification request first, then starts the half-second timer. It also sends the request to another domain. Finding a local notify.php in the repo did not tell me what code was running at the external address.

The download URL written into this copy pointed to E-sign_Document-ERDDS.zip on an R2 host. The archive did not tell me what was inside that external ZIP. I kept that reference separate from the ScreenConnect filenames I had seen on other pages.

There was also a download.php helper for serving local files, but the reviewed page.html did not call it. The empty $injection value in index.php was another reason not to describe this snapshot as automatically forcing a download when the page loaded.

This was also a useful difference to compare with Stormshield’s published example, which includes a download on page load. I did not find that behavior in the repo copy. The samples differ, but I could not turn that into a confirmed version history.

Same URL Pattern, Different Pages

The captured pages did not all look the same, but their effective URLs followed this general pattern:

/ftx/<6 letters or digits>-<10 digits>-<12 hex characters>/?redir=...

I went back to mncacompany[.]com, conciergesaudia[.]com, and amepiequipamentos[.]com[.]br on October 4.

The new captures gave me a clearer visual comparison with the download page in the repository.

The captures for mncacompany[.]com and conciergesaudia[.]com showed the laptop illustration, attachment-opening prompt, and download button. The capture for amepiequipamentos[.]com[.]br showed the prompt and button too, but its images appeared as placeholders.

Press enter or click to view image in full size
mncacompany[.]com in a URLscan result submitted on September 27, 2026.
Press enter or click to view image in full size
mncacompany[.]com in the October 4, 2026 follow-up scan, showing the download-page layout.

Checking the Tokens

During the final review, I went back to the URLs. Matching images and wording was one thing. Could the code in this repo also reproduce the signatures in those tokens?

To check that, an assisted offline Python comparison used the HMAC key and calculation from the repo’s index.php. The input is the six-character prefix, a hyphen, and the timestamp. The key is used as literal text, and the calculation keeps the first 12 hexadecimal characters of the HMAC-SHA256 result.

All 16 matched.

That was the 13 original domain captures plus the three October 4 follow-ups.

Original domain captures: 13 of 13 matched
October 4 follow-ups: 3 of 3 matched
Total observations: 16 of 16 matched
Distinct domains: 13

There were 16 distinct hostname/token pairs in the input. The checker rejected malformed rows and duplicate observations instead of quietly skipping or counting them. The hostnames and displayed UTC submission times were checked against the saved screenshot headers too, because the HMAC does not cover those fields. The input list, screenshot mapping, script, and results are in the analysis records.

This was the part that really caught my attention. Similar logos and wording gave me a lead, but now the repo’s HMAC key and calculation could reproduce the signatures in the saved URLs. That was a much stronger connection. The check was done offline, without contacting the sites or replaying the tokens.

What does that let me say? The matches support shared token-generation configuration or closely related code. A copied kit could carry the same hard-coded key, so the match does not tell me that this exact repo deployed the sites or who was operating them. It also does not establish the remote AES key or every server-side check.

There was one more comparison: the megalaptopec token published by Stormshield. A separate assisted offline check reproduced its signature too. I kept it outside the 16 observations and 13 domains above. Its timestamp is a value inside the token, not an independently observed date, so I am not using it to say when the kit first appeared.

Beyond /ftx/: The Web Shell

Once I had worked through /ftx/, I started looking around the rest of the repo. There was actual shopping-site code here, including database models and migrations, sitting alongside the phishing files. That left me with another question: how did all of this end up together?

One file that caught my attention was 9WOLF.php. The name reminded me of a web shell called wwwolf-php-webshell, but a similar name was not enough to say they were the same tool.

When I opened the file on GitHub, I saw a long encoded block and a few interesting PHP operations:

file(__FILE__) reads the file’s own contents.

base64_decode() converts encoded text back into data.

eval() would interpret the recovered text as PHP code and execute it.

These were the instructions around the hidden code. The file was set up to read its own contents, unpack them, and execute the result.

I started putting the Base64 blocks into an online decoder, but recovering the full source required more than Base64 decoding. One of the decoded helpers contained this expression:

return(gzinflate(base64_decode($a)));

Read that from the inside out. base64_decode() recovers the compressed data, gzinflate() decompresses it, and return passes the recovered content back. The surrounding eval() is the part that would execute it.

This was something I had to slow down and understand. I had decoded the Base64, but I still did not have readable PHP because the data was compressed. Decoding it was not the same as running it, either.

The full source was recovered through assisted offline analysis without executing the PHP. Some strings still used escape sequences, which were expanded to make them readable. The recovered source contained the text “Welcome to 9WOLF shell,” a WSO title, and a 4.2.6 version marker.

The name was one thing, but what could this shell actually do? I went through the recovered code and focused on three functions:

actionPhp() takes submitted input and evaluates it as PHP code. The key statement is eval($_POST["p1"]);, which passes the submitted p1 value into PHP’s code evaluator.

ex($in) tries different PHP functions for executing operating-system commands and returning their output. The console action passes submitted input into this helper.

actionFilesMan() contains file-management operations, including uploading files and creating directories.

So I had a 9WOLF-branded PHP web shell with WSO 4.2.6 identifiers in its source. That was enough to describe the file. It did not tell me who put it in the repo, whether it had been used on a live server, or whether it was connected to the phishing kit.

I also found testo.php, a mail tester with the same 9WOLF branding. It accepts an email address and tries to send a fixed test message through PHP’s mail() function. Same branding, different job. That alone was not enough to connect either file to a particular operator.

The PowerShell Files

Then I found stuCb.ps1 under assets/css/. A PowerShell file in a CSS folder seemed worth a look.

When I opened it, there was another huge Base64 block. I was not going to understand much by staring at that, so I went down to the readable code near the bottom.

There was a section called “Main Execution Flow” that contained these lines:

$cipherBytes = ConvertFrom-Base64ToBytes -base64String $payload
$keyBytes = ConvertFrom-Base64ToBytes -base64String $keyData
$ivBytes = ConvertFrom-Base64ToBytes -base64String $ivData

These three lines gave me something readable to work with. Each converts a stored Base64 value into bytes:

$payload is the huge block at the beginning of the script. It contains an AES-encrypted script stored as Base64 text.

$keyData contains the decryption key, also stored as Base64 text.

$ivData contains the initialization vector, an additional starting value used during AES decryption.

The results are stored in $cipherBytes, $keyBytes, and $ivBytes. Both the key and IV are already included in the file.

There is an easy distinction to miss here: converting Base64 text into bytes does not decrypt the script. At this point, the payload is still encrypted.

Decrypting the Hidden Script

The next line passes those three values into Invoke-AESDecryption:

$scriptContent = Invoke-AESDecryption `
-cipherBytes $cipherBytes `
-keyBytes $keyBytes `
-ivBytes $ivBytes

I looked inside the function to see what it did with those values:

$aes.Key = $keyBytes
$aes.IV = $ivBytes
$aes.Mode = [System.Security.Cryptography.CipherMode]::CBC
$aes.Padding = [System.Security.Cryptography.PaddingMode]::PKCS7

There are the settings: AES in CBC mode with PKCS7 padding. The function uses the encrypted bytes, key, and IV to decrypt the data, then converts the result to UTF-8 text and returns it into $scriptContent.

So now we have recovered PowerShell code, but this step has not run it.

Then we get to this:

Invoke-Expression $scriptContent

This is where the script attempts to execute the recovered PowerShell. If the decryption returns content, Invoke-Expression takes that content and runs it as instructions.

This brought me back to 9WOLF.php. Getting the hidden code back is one step. Running it is another. The script is written to do both, but I only wanted to inspect what it contained.

For the analysis, the hidden script was decrypted offline with assistance and inspected as text. The PowerShell and the programs stored inside it were not run.

What Was Inside?

After decrypting the first layer, there was still more to unpack.

The recovered PowerShell contains a routine that checks for a process called aspnet_compiler. If that process is missing, it goes into the loading part of the code. This sits inside a loop that waits five seconds between checks.

In other words, it keeps checking for that process and tries the loading sequence when it is missing. That tells me what the script does while it is running, not that it starts itself again after a reboot.

Inside that loading code is another encoded block. It goes through Base64 decoding, an XOR transformation using an embedded key, and another Base64 decoding step. This recovers a .NET loader DLL. Unlike the first layer, the result is a compiled code component rather than readable PowerShell.

The script then uses this line:

$assemblyInstance = [Reflection.Assembly]::Load($BinaryAssembly)

This loads the recovered .NET component into memory from its bytes. The code then looks for a particular class and method inside it so it can call that method.

In this case, the class is MENNNNN.EXECUTE and the method is LAUNCH. A method is a function inside that component.

The script also prepares this path:

C:\Windows\Microsoft.NET\Framework\v4.0.30319\aspnet_compiler.exe

That path and a variable called $payloadData are passed to the loader. $payloadData is intended to contain the embedded executable bytes.

This is where I had to separate what I could read from what I had not worked out. I could see the path and payload variable being passed to the loader. I had not established exactly how the DLL used them, or whether anything successfully ran inside that process.

There was a catch in the order of the code too. It passes $payloadData to the loader before assigning the embedded bytes to that variable. On a standalone first attempt, the loader may get no initialized payload. If execution reaches the assignment and the loop continues, later attempts can use the value retained within the function.

I would not call the whole script broken because of that, or assume the next attempt works. I had found an ordering issue, not tested its runtime outcome.

The Embedded Payload

The offline analysis recovered both the .NET loader and an embedded managed Windows executable from stuCb.ps1.

Looking at the executable’s metadata, there were settings called EnableKeylogger, EnableScreenLogger, EnableClipboardLogger, and EnableCookies. There were also SMTP delivery settings, startup-related options, and strings referring to browser credential stores and password fields.

You can see why those names got my attention. But a field called EnableKeylogger does not tell me whether keylogging was enabled. The browser-related strings do not show that anyone’s passwords were stolen either.

What I had was a PowerShell loader containing an encrypted script and an embedded Windows payload with suspicious collection-related settings and strings. I had not observed those features running on a victim’s machine.

Another PowerShell File

There was a second file called stub.ps1 under docs/ertyuytr/.

This one used the same general loading structure but contained different embedded components. The recovered components included a managed loader DLL and a native Windows executable. Once the payload-specific data was removed from the two recovered scripts, the remaining code matched. They also used the same XOR key.

That connected the way the two files were packaged. It did not tell me who built them or who added them to the repo.

By now I had a phishing kit, a web shell, and two encrypted PowerShell loaders in the same repo. Were the phishing pages delivering those loaders?

I could not make that connection. /ftx/page.html pointed to an external ZIP, not either PowerShell file, and the source review found no literal references connecting their filenames to the application or /ftx/ code. Finding them together was not enough to put them into the ScreenConnect chain.

The Repository History

By this point, I wanted to know how these files got into the repo. I moved on to its history and the two branches I had looked at: main and sprint-1/homepage.

Source Snapshot and Method

A quick note on what I was working from, because it matters for the timeline. The source review used an exported ZIP whose comment identified commit e07382ef428aba143c4a6598231e252f03b817ac. My saved GitHub page dates it to July 31, 2026, with the message “handle data on 30 July 2026.”

Archive SHA-256:

6fb466df4969e600f02b3e3ef466786ecf33ec1042efcf0b0064747694b022cc

The ZIP also had a limitation worth knowing about. Its .gitattributes contained /.github export-ignore, which leaves that directory out of source archives. I had to review the Actions activity separately using the saved run-specific workflow definitions. The ZIP comment is metadata, not a signature, and I have not independently matched the files against a second copy of that commit’s tree.

I used ChatGPT-assisted analysis and Python readers and decoders to help me work through the source. The reviewed PHP, PowerShell, and embedded executables were read as data, not run. That work was separate from the browser visits and downloads described elsewhere in the hunt.

Comparing the Branches

The exported sprint-1/homepage snapshot contained a much smaller version of the marketplace application. There was shopping-site functionality, including mock product data and vendor registration code, but none of the known /ftx/ files, 9WOLF.php, testo.php, or either PowerShell loader found in the exported main snapshot.

That gave me a comparison, but not a before-and-after story. Missing files did not make this a confirmed clean version from before a compromise. I still needed to check how the branches related to each other and what the commits changed.

Were these files added together, or did they arrive in separate updates?

When Did the Files Appear?

After comparing the branches, I went through the commit history to see when these files appeared.

The update that stood out was commit 605fb92, dated July 20, 2026, with the message “afrisellers project handling.” It changed 297 files.

Press enter or click to view image in full size

I checked the individual histories of the /ftx/ files, 9WOLF.php, assets/css/stuCb.ps1, docs/ertyuytr/stub.ps1, and testo.php. In the histories I reviewed, their contents were added in that July 20 update.

It was not just an update containing the suspicious files, either. The same commit changed parts of the marketplace application. The phishing kit, shell, and loaders appeared alongside work on the shopping site.

Press enter or click to view image in full size
The contents of 9WOLF.php being added in the July 20 update.

I then checked the later application commits from July 27 through July 31. Those changes focused on features such as product ads, subscriptions, vendor settings, and updated build files. I did not see those commits change or remove the phishing kit, web shell, or PowerShell loaders. Those files were still present in the main snapshot I reviewed.

I now had a point in the recorded history where the files were added. That still left their earlier history open. I did not know when they were created or whether they had already been used on a server.

Get EternalR00ted’s stories in your inbox

Join Medium for free to get updates from this writer.

There is a limit to that July 31 snapshot too: it cannot tell me whether the files remained after the August 3 cleanup commit we will get to next.

GitHub Actions Runs

I moved over to the Actions tab next. There were eight workflow runs triggered between August 2 and August 3, 2026. Four were associated with shellpanel-toolkit-dispatch and four with shellpanel-toolkit-push.

All eight were marked as failed, with durations of just over 30 days. My first thought was that these things had somehow been running for a month. Then I read the actual failure message:

This workflow run required approval but was not approved before it expired.

Press enter or click to view image in full size
Saved Actions run list. The individual run pages recorded expiry while waiting for approval.

So they had been waiting for approval, not scanning for a month. The records did not show the toolkit running. I still wanted to know what these workflows were trying to do.

Why did they need approval? GitHub had announced an automatic hold for certain potentially malicious workflows in public repositories on July 28, 2026.

The timing fit these runs. They were push and manual-dispatch events, not fork pull requests. Neither saved workflow referenced a deployment environment or called a reusable workflow. The env: blocks only defined variables; they were not environment: references. Of the approval mechanisms I checked, the automatic security hold fit best. The expiry message did not name it, though, and I have not confirmed the reason with GitHub.

What Were the Workflows Doing?

There were two workflow files. One used workflow_dispatch, which can be triggered through GitHub’s interface, the CLI, or the API. So “manual” did not necessarily mean someone clicked a button. The other used push and watched for changes to specific files.

The naming took a little care to follow too. The shellpanel-toolkit names came from the YAML name: fields, while the filenames used shellpanel-legacy-compat.

This was a shopping-site repo, but these workflows were not set up to build or test the site. They were set up to download a program from another server and run it.

Both had a job called ci-run on an Ubuntu runner, with two steps: “Download toolkit” and “Run toolkit.”

The first step checked the machine’s CPU architecture and selected a download suffix such as amd64, arm64, arm, or 386. It then used an address stored in PANEL_URL to request a file from:

/api/dl/<architecture>

If the job executed, it would save the file as /tmp/.svc, check that it was not empty, make it executable, and attempt to run it with the ipscan command.

The arguments included --source random, a worker setting of 1000, and a list of ports. To me, this looked like the workflow was intended to use the runner for broad IP scanning. The worker setting was passed to the downloaded program. It did not mean there were 1,000 GitHub runners or 1,000 compromised machines.

The manual version also included:

--exploit CVE-2026-41940

That gave me a specific CVE to look up. It was an option passed to the downloaded program, not proof that the program had a working exploit. I had not examined that program at this stage. The push-triggered workflow did not include the explicit CVE option.

Looking up the CVE gave the command more context. CVE-2026-41940 is an authentication-bypass vulnerability in cPanel & WHM’s session handling. On affected installations, it can allow access without valid credentials.

The port list also stood out. It included 2082 and 2083 for cPanel, 2086 and 2087 for WHM, 2095 and 2096 for Webmail, and 2077 and 2078 for Web Disk. Together with the exploit option, this pointed toward intended targeting of cPanel-related services rather than just general web scanning.

I also had a cPanel-related clue in the repo itself. The root .htaccess contained a “cPanel-generated handler” block for ea-php83. Useful context, but it did not tell me that all 13 phishing sites used cPanel or that any of them were vulnerable.

Could the toolkit have been intended to find hosting environments for phishing pages or web shells? That was the idea I was considering, not a connection I had proved. These particular runs could not explain the files first appearing in the repo: the files were there in the July 20 update, before the August 2 and 3 attempts, and the runs expired without approval.

Press enter or click to view image in full size
The manual workflow’s download and execution instructions, including the CVE-specific option.

The manual workflow had a job timeout of 55 minutes, while the push workflow had a timeout of 360 minutes.

Both commands ended with:

2>&1 | tail -2 || true

This would combine the program’s output and errors, retain the final two lines, and mask an ordinary failing status from that pipeline. It would not bypass approval or prevent a platform timeout or cancellation.

What Was Triggering the Runs?

Next I wanted to know what was causing the push-triggered runs. I worked back through their associated commits to see what changed.

The push workflow watched these two paths:

.github/shellpanel-legacy-compat-trigger.txt
.github/workflows/shellpanel-legacy-compat-push-smoke.yml

Changing the text file could trigger the workflow. The scanning commands themselves were in the YAML.

Commit a45aa4b added the trigger file. It contained a short shellpanel-legacy-compat-smoke-v2 marker and a numeric value. The next commit, 9e1c4fc, changed that number without changing the workflow itself. This looked like a way to request another run without editing the scanning instructions.

Then I found commit 566ffcc, dated August 3, with the message:

Remove attacker-created shellpanel trigger file (security cleanup)

The diff showed that it deleted .github/shellpanel-legacy-compat-trigger.txt. It did not delete .github/workflows/shellpanel-legacy-compat-push-smoke.yml. Only the trigger text file was changed in that commit.

Press enter or click to view image in full size
The cleanup commit removed the trigger text file, not the workflow definition.

The commit message sounded like a cleanup, but the diff was more specific: one trigger file was removed. That was not a cleanup of the whole repo. “Attacker-created” was also the commit message’s wording, not an attribution I had independently established.

Deleting the file still changed a path watched by the workflow. The Actions record showed another push-triggered run associated with the cleanup commit, and that run also expired while waiting for approval.

That explains the failed run attached to a cleanup commit. The file deletion happened in the commit. The separate workflow request expired. Those were two different things.

Following the Panel IP

The workflow gave me another pivot through PANEL_URL:

hxxp://43.228.157[.]68:80

That was the configured download address, and the workflow also supplied it to the executable as an environment variable. It gave me a host to look into. The name PANEL_URL alone was not enough to say what was running there.

Shodan listed Singapore as the location and Cyber-Security-SG as the organization. My first thought was that Singapore’s Cyber Security Agency might have taken it over. But I was getting ahead of myself. I had an organization label, not evidence that the agency controlled the server.

The more interesting result was on port 8888. Shodan had recorded an HTTP 200 response with the title “ShellPanel C2.” Port 80 had returned a 404 to Shodan’s request, but that did not tell me whether the specific download paths were still available.

I searched the IP in URLscan too. There were records for the same /api/dl/amd64 and /api/dl/arm64 paths used by the workflow. The listings reported downloads named tk, with sizes of 11 MB and 14 MB in those examples.

Press enter or click to view image in full size
Historical URLscan results for the architecture-specific download paths.

Now I had more than an address written into a workflow. The saved records showed files being served from the configured paths. That still did not mean the blocked runs downloaded them, or that every download named tk had identical contents.

Two other paths appeared in the saved scans: /api/github-heartbeat and /api/github-results. This is where Socket’s report helped with context. It describes those endpoints being used for runner-status reporting and uploading collected results in the workflows it examined.

My saved YAML did not have those reporting steps or Socket’s DNSHook callback. It supplied the panel address to the executable, but I had not worked out whether the executable made those requests internally. I could use Socket’s findings to understand the endpoints without treating them as something the eight blocked runs I reviewed had done.

Was It Actually Offline?

When I checked the host myself, the /api page displayed “not found,” but I was still able to download a file named tk. The network monitor also flagged ELF file transfers over HTTP. Those alerts showed file-transfer activity, not that the downloaded programs had executed.

Press enter or click to view image in full size
The browser displayed “not found” at /api while its Downloads panel showed tk files.

So “not found” at one path did not mean the entire service was gone. Downloads were still available when I checked. That did not settle who controlled the server or whether its use had changed, either.

Finding the Panel

I then found an older URLscan result for 43.228.157[.]68:8888/. The submission details I recorded listed a manual submission on July 23, 2026, at 11:39:39 UTC. Its saved screenshot showed a dashboard labelled “SHELLPANEL.”

The date mattered because it came before the August 2 and 3 workflow attempts. I had an earlier record of the interface, not a date for when the panel was first set up. The recorded time was the scan submission time, not necessarily the exact second the screenshot was taken.

The left side listed domains under a “Shells” tab, with entries labelled php. There were controls called Check, Deploy, and Reinject, along with a command input and Run button. Other tabs included Operations, GitHub, Computing, cloud platforms, and Logs.

Press enter or click to view image in full size
ShellPanel interface from the URLscan result recorded as submitted on July 23, 2026, at 11:39:39 UTC.

Now this was more interesting than just a page title. The saved screenshot showed a shell list, management controls, and a command box. To me, it looked like a way to manage multiple shells from one place.

Then there were the counters: ALIVE, TOTAL, and RUNNING. I did not know what each one counted, so I was not going to turn those numbers into a victim total. The screenshot did not show a selected shell or command output. A domain in the list was not enough for me to call it compromised.

What the Evidence Supports

By this point, I needed to separate the connections I had established from the explanations I was considering.

Observed

What did I actually have in front of me? The exported repo contained the /ftx/ kit, a 9WOLF-branded PHP web shell, and two encrypted PowerShell loaders alongside a marketplace application. The saved YAML specified a download and scanner invocation, but the eight run records showed approval expiry. The ShellPanel image came from a historical third-party scan. I had not operated the panel.

Reproduced Offline

The token check was the strongest connection for me. The archived key and calculation reproduced all 16 signatures across the 13-domain comparison. Stormshield’s published token matched separately too. That supports shared token-generation configuration or closely related code, not proof that this exact GitHub copy deployed every page.

Context from Prior Reporting

I was not looking at this activity in a vacuum. Stormshield had overlapping indicators, and Socket had already analyzed the infrastructure. I have credited their findings where I use them. GitHub’s announcement also helped explain what might have held the workflows, although it did not confirm the reason for these particular runs.

Unresolved

The question that first sent me into the history is still open: how did the suspicious files get into this repo? A contaminated working or deployment directory, compromised access, or deliberate inclusion could each explain it. I do not have enough to choose between them.

I did wonder about a botnet, or a setup for finding servers to host phishing pages and other files. The scanning and shell-management pieces made those reasonable questions. I had not shown that sequence happening, though. The panel counters are not verified victim counts, and the August runs do not show successful exploitation.

The biggest gap is still how the pieces connect. I have not shown that the phishing pages delivered the Windows loaders, that this panel controlled the repo’s web shell, or that one person operated everything. I also have not verified the ScreenConnect instance or relay, identified the embedded payload families, or independently matched the files to the complete Git commit tree.

IOCs / Hunting

I have grouped the indicators by where I found them: the DocuSign-themed pages, the repository kit, and the ShellPanel infrastructure.

The addresses are defanged. Bot credentials, visitor IPs, and the domains displayed inside the panel are not included.

DocuSign-Themed /ftx/ Pages

These are the 13 domains from the saved scans I compared. All dates below are scan submission dates in 2026, with times in UTC, not the dates the pages first appeared.

whisperingjessie[.]com/ftx/

September 30. Attachment-opening prompt, download button, and an additional instruction button.

thamizhnadu[.]com/ftx

September 30. Laptop illustration, smaller logo, and a download-page layout similar to the repository’s lure.

caminospordescubrir[.]cl/ftx/

September 26. Document-opening prompt and download button, without the illustrations visible.

bestcareindia[.]com/ftx/

September 26. Similar download-page arrangement, with image placeholders instead of the illustrations.

airconalgarve[.]com/ftx/

September 26. Document-opening prompt and download button, without the illustrations visible.

padmaelectricals[.]com/ftx/

September 26. Attachment-opening prompt and download button, without the illustrations visible.

gigarush[.]com/ftx/

September 26. Similar laptop illustration and download-page layout, with wording referring to a RAR file. The wording did not establish what the download button delivered.

yonataxi[.]at/ftx/

September 27. Blurred-document view with the page title e-sign.

mncacompany[.]com/ftx/

September 27 and October 4 at 05:52:13. The earlier capture showed a blurred document. The follow-up showed the laptop illustration, smaller logo, attachment-opening prompt, and download button.

conciergesaudia[.]com/ftx/

September 26 and October 4 at 05:51:59. The earlier capture showed a blurred document. The follow-up showed the laptop illustration, smaller logo, attachment-opening prompt, and download button.

amepiequipamentos[.]com[.]br/ftx/

September 26 and October 4 at 05:50:47. The earlier capture showed a blurred document. The follow-up showed the attachment-opening prompt and download button, with image placeholders rather than the illustrations.

laboratoires-azbane[.]com/ftx

September 30. A different download-page design saying “Your secure document is ready.”

beservicesit[.]mx/ftx/

September 26. A preparation screen titled “Document Verification — DocuSign (Lab Demo).” This was separate from my October 1 observation of the page offering ScreenConnect.ClientSetup.msi.

The three October 4 follow-ups were additional research observations of domains already in the comparison, not three new sites.

References Inside the Repository Kit

These locations were configured in the copy of the kit I reviewed.

megalaptopec[.]com/ftx/notify.php

Notification endpoint called by the download-button handler in page.html.

megalaptopec[.]com/ftx/error.html

Redirect destination for the selected unsupported-device checks in index.php.

The external download link in page.html was:

hxxps://pub-1f8baff730604855bee5ad080a25e51e.r2[.]dev/E-sign_Document-ERDDS.zip

This was a source-code reference. The archive review did not establish the ZIP’s contents, so I kept it separate from the ScreenConnect installer observed elsewhere.

Other useful comparison points were:

/ftx/images/laptop_docusign.png
/ftx/images/time.png
Open the downloaded attachment on your computer
notifyDownloadAndRedirect

I would use those paths, wording, and function name together when looking for related copies.

ShellPanel and Toolkit Infrastructure

43.228.157[.]68:80

Toolkit download host configured in PANEL_URL. Historical scan records also reported downloads from this IP.

43.228.157[.]68:8888

Address associated with the historical ShellPanel dashboard.

The saved scan listings reported downloads named tk from these paths on that IP:

/api/dl/amd64
/api/dl/arm64
/api/dl/arm
/api/dl/386

The listings also contained:

/api/github-heartbeat
/api/github-results

These paths appeared in the saved scan listings. Socket documents heartbeat and results-upload behavior in its samples; my saved workflow definitions do not contain the corresponding requests.

tk was the filename reported by the download records. /tmp/.svc was the local filename the workflows were configured to use. A filename match alone did not establish identical contents.

The workflow-related paths were:

.github/workflows/shellpanel-legacy-compat-dispatch-smoke.yml
.github/workflows/shellpanel-legacy-compat-push-smoke.yml
.github/shellpanel-legacy-compat-trigger.txt

Useful strings from the workflow configuration included:

PANEL_URL
/api/dl/
/tmp/.svc
ipscan
--exploit CVE-2026-41940

The explicit CVE option appeared in the manual workflow. These strings describe the configuration, not a record of successful execution.

Repository File Hashes

These SHA-256 values belong to the original files in the archived main snapshot, before decoding or decryption. They do not identify the recovered Windows executables, a ScreenConnect installer, or the downloaded tk sample.

9WOLF.php:

049e9b2fe3ddcb790cebb8559c2357bab2e74aa22716691045f6745358e59ac6

This identifies the original encoded PHP web-shell file.

assets/css/stuCb.ps1:

842c7a35e9205b93b8675cfaa95c03a9f254fffc629d9b07db772233c7373698

This identifies the complete PowerShell file, including its encrypted content and readable loading code.

docs/ertyuytr/stub.ps1:

a8ccb0e8d82ea87b88666983a3f61ae389b9e07743b86bd75eb29b52366db49b

This identifies the second original PowerShell file.

I would use these hashes to look for exact copies, then use the source and behavior described earlier to investigate modified versions.

Where I Would Start Hunting

These are starting points from this investigation, not production detections I have tested.

For the phishing pages, I would start with the identified hostnames and /ftx/ paths, then look at the surrounding downloads and process activity. A visit to a page, a completed download, and an installer running are separate events. I would want to see the evidence for each before calling it a compromise. A download button appearing in a scan is not the same as confirming what it delivered.

The Telegram notification attempts could help with that timeline, but the source has both browser-side and server-side notifications. The PHP notification would originate from the hosting server, so browser traffic alone would not show every notification attempt in this kit.

For web servers, I would look for the shell’s hash and unexpected PHP files in deployed application directories. Requests to those files would be worth reviewing alongside process activity and file changes. Finding a sample in an analyst’s folder is different from finding it in a deployed website.

For the PowerShell files, I would look for the loading sequence rather than just the filenames. The combination of AES decryption, Invoke-Expression, and [Reflection.Assembly]::Load would be useful where script content is available. MENNNNN.EXECUTE and LAUNCH are additional strings from the recovered loading code. I would not use the presence of aspnet_compiler.exe alone to identify this activity.

For GitHub Actions, I would review workflows that download an external executable and immediately launch it with scanning arguments. I would then check the job records to see whether the commands actually executed. The workflow files in this investigation showed intended behavior, while the run records showed approval expiry.

I would also keep my own research traffic and sandbox activity separate from genuine user activity. The October 4 follow-up scans belong in the research timeline, not a victim count.

Reporting

On October 4, 2026, I reported the repository and historical GitHub Actions activity to GitHub. I also reported the observed Docusign impersonation URLs to Docusign.

Docusign later returned a “No abuse found” response referring to the email submitted for analysis. It did not provide individual URL findings, so the scope of the website review was unclear. I am not presenting that response as confirmation that the reported pages were safe.

Closing

This started with an email claiming to be from my insurance company, asking me to sign an updated privacy policy. I thought I was going to figure out why a supposed DocuSign download was offering ScreenConnect and leave it there.

That did not happen.

A Telegram request gave me my first pivot. A sentence on a phishing page led me to matching source code. Looking outside that folder turned up a web shell and encrypted PowerShell files, and the repository history led me to workflows pointing at another piece of infrastructure.

Some of this code was new to me, so I had to slow down and work through it. That was a big part of what made this hunt interesting. I was not just collecting domains. I was learning how the pages, scripts, and workflows were put together.

I also had to change my mind along the way. Different screenshots did not automatically mean unrelated pages. A failed workflow did not mean an exploit had failed. A “not found” page did not mean the entire service was offline. Finding several suspicious things together also did not prove they were all used in the same attack.

What I did find was enough to take this from a ScreenConnect phish into a much broader source-code and infrastructure investigation.

So much for just checking one suspicious email.

Let me know what you folks think, especially anything I missed or could have checked differently. I am sharing these hunts to keep learning and get better at explaining the work.

Hunt Diary #2 coming soon.

Sources

Stormshield report:
https://www.stormshield.com/news/cti-phishing-campaign-rmm-installation/

Socket report:
https://socket.dev/blog/github-actions-abuse-powers-cpanel-and-whm-exploitation

GitHub’s automatic approval-hold announcement:
https://github.blog/changelog/2026-07-28-github-actions-holds-potentially-malicious-workflows-for-approval/

GitHub manual workflow dispatch:
https://docs.github.com/en/actions/how-tos/manage-workflow-runs/manually-run-a-workflow

GitHub fork approval:
https://docs.github.com/en/actions/how-tos/manage-workflow-runs/approve-runs-from-forks

GitHub deployment environments:
https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments

Git archive and export-ignore:
https://git-scm.com/docs/git-archive

PowerShell scope:
https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_scopes?view=powershell-7.5

Existing wwwolf-php-webshell comparison:
https://github.com/WhiteWinterWolf/wwwolf-php-webshell

cPanel security advisory for CVE-2026–41940:
https://www.cpanel.net/blog/security/security-update-cve-2026-41940/

cPanel service-port documentation:
https://docs.cpanel.net/knowledge-base/general-systems-administration/how-to-configure-your-firewall-for-cpanel-services/

--

--